spb/cancerindex
Public
TypeScript 97.2%
SQL 1.5%
CSS 0.6%
JavaScript 0.5%
1import Link from 'next/link';2import { Badge, ClaimBadge, MatchBadge, isExactMatch } from '@/components/ui/badge';3import { SourceBadge, TableProvenance } from '@/components/ui/source-badge';4import { Freshness } from '@/components/ui/freshness';5import type { FreqRow } from '@/lib/queries/genomics';6import type { ProvRow } from '@/lib/queries/provenance';7import { toInfo } from '@/lib/queries/provenance';8import { fmtInt, fmtPct, humanize } from '@/lib/format';9import { Pager } from '@/components/ui/pager';10import { pageInfo } from '@/lib/pagination';1112/**13 * Gene alteration frequencies with mandatory denominators (§260-261), one table per cohort.14 * Every row of a cohort table shares one provenance (one cohort, one release), so the popover is15 * rendered once per cohort in the caption; rows carry the compact badge.16 */17export function FrequencyTables({ rows, prov, cohortFilter, showCancer = false }: { rows: FreqRow[]; prov: Map<number, ProvRow>; cohortFilter?: string | null; showCancer?: boolean }) {18 const cohorts = new Map<string, FreqRow[]>();19 for (const r of rows) {20 if (cohortFilter && r.cohort_id !== cohortFilter) continue;21 if (!cohorts.has(r.cohort_id)) cohorts.set(r.cohort_id, []);22 cohorts.get(r.cohort_id)!.push(r);23 }24 return (25 <div className="space-y-8">26 {[...cohorts.entries()].map(([cohortId, rs]) => {27 const c = rs[0]!;28 const info = toInfo(prov.get(c.provenance_id)) ?? { sourceSlug: c.source_slug, sourceName: c.source_name, datasetVersion: c.data_release };29 return (30 <section key={cohortId} aria-label={c.cohort_name}>31 <header className="mb-1.5 flex flex-wrap items-baseline justify-between gap-2">32 <h3 className="font-sans text-[15px] font-medium">33 {c.cohort_name} <span className="ci-mono text-[12px] text-ink-3">{c.study_id}</span>34 {c.program ? <Badge className="ml-2">{c.program}</Badge> : null}35 </h3>36 <p className="text-[12.5px] text-ink-3">37 {c.cases_with_ssm != null ? (38 <>39 <span className="ci-num">{fmtInt(c.cases_with_ssm)}</span> cases with simple somatic mutation data40 </>41 ) : null}42 {c.case_count != null ? (43 <>44 {' '}45 · <span className="ci-num">{fmtInt(c.case_count)}</span> cases total46 </>47 ) : null}48 {c.data_release ? <span className="ci-mono"> · {c.data_release}</span> : null}49 {showCancer && c.cancer_slug ? (50 <>51 {' '}52 · mapped to{' '}53 <Link className="ci-link" href={`/cancer/${c.cancer_slug}`}>54 {c.cancer_name}55 </Link>{' '}56 <MatchBadge matchType={c.cancer_match_type} />57 </>58 ) : null}59 </p>60 </header>61 <TableProvenance p={info} claim={<ClaimBadge kind="observed" />}>62 {rs.length} gene{rs.length === 1 ? '' : 's'} · frequency = affected / profiled, as published by the cohort.63 </TableProvenance>64 <div className="ci-table-wrap">65 <table className="ci-table">66 <thead>67 <tr>68 <th scope="col" className="num">69 #70 </th>71 <th scope="col">Gene</th>72 <th scope="col">Alteration</th>73 <th scope="col" className="num">74 Affected (n)75 </th>76 <th scope="col" className="num">77 Profiled (n)78 </th>79 <th scope="col" className="num">80 Frequency (%)81 </th>82 <th scope="col">Source</th>83 </tr>84 </thead>85 <tbody>86 {rs.map((r, i) => (87 <tr key={r.id}>88 <td className="num text-ink-3">{r.rank ?? i + 1}</td>89 <td>90 <Link className="ci-mono ci-link font-medium" href={`/gene/${r.gene_symbol}`}>91 {r.gene_symbol}92 </Link>93 </td>94 <td>95 <Badge tone="outline">{humanize(r.alteration_type)}</Badge>96 </td>97 <td className="num">{fmtInt(r.cases_affected)}</td>98 <td className="num">{fmtInt(r.cases_profiled)}</td>99 <td className="num font-medium">{fmtPct(r.frequency, 1)}</td>100 <td>101 <SourceBadge compact title={null} p={info} />102 </td>103 </tr>104 ))}105 </tbody>106 </table>107 </div>108 <Freshness dataUpdatedAt={c.updated_at} sourceVersion={c.data_release} />109 </section>110 );111 })}112 </div>113 );114}115116/**117 * Gene-centric view: one compact row per cohort (used on /gene/[symbol]); denominators stay mandatory.118 * Rows come from different cohorts (different releases), so each compact badge carries a `title`119 * with dataset · version · retrieved; the caption shows one popover for the source.120 */121export function GeneFrequencyTable({ rows, prov, page = 1, pageSize = 50, hrefFor }: { rows: FreqRow[]; prov: Map<number, ProvRow>; page?: number; pageSize?: number; hrefFor?: (page: number) => string }) {122 const all = [...rows].sort((a, b) => b.frequency - a.frequency);123 const info = pageInfo(page, pageSize, all.length);124 const sorted = all.slice(info.offset, info.offset + pageSize);125 const first = sorted[0];126 const caption = first ? (toInfo(prov.get(first.provenance_id)) ?? { sourceSlug: first.source_slug, sourceName: first.source_name }) : null;127 return (128 <div>129 {caption ? (130 <TableProvenance p={caption} claim={<ClaimBadge kind="observed" />}>131 {all.length} cohort row{all.length === 1 ? '' : 's'}{all.length > sorted.length ? ` (showing ${sorted.length}, sorted by frequency)` : ''} · frequency = affected / profiled, as published by each cohort; cohorts are not pooled. Hover a row badge for its release.132 </TableProvenance>133 ) : null}134 <div className="ci-table-wrap">135 <table className="ci-table">136 <thead>137 <tr>138 <th scope="col">Cohort</th>139 <th scope="col">Mapped cancer</th>140 <th scope="col">Alteration</th>141 <th scope="col" className="num">142 Affected (n)143 </th>144 <th scope="col" className="num">145 Profiled (n)146 </th>147 <th scope="col" className="num">148 Frequency (%)149 </th>150 <th scope="col" className="num">151 Rank in cohort152 </th>153 <th scope="col">Source</th>154 </tr>155 </thead>156 <tbody>157 {sorted.map((r) => (158 <tr key={r.id}>159 <td>160 <span className="font-medium">{r.cohort_name}</span> <span className="ci-mono text-[11.5px] text-ink-3">{r.study_id}</span>161 </td>162 <td>163 {r.cancer_slug ? (164 <>165 <Link className="ci-link" href={`/cancer/${r.cancer_slug}`}>166 {r.cancer_name}167 </Link>168 {!isExactMatch(r.cancer_match_type) ? <MatchBadge matchType={r.cancer_match_type} className="ml-1" /> : null}169 </>170 ) : (171 <span className="text-ink-3">unmapped</span>172 )}173 </td>174 <td>175 <Badge tone="outline">{humanize(r.alteration_type)}</Badge>176 </td>177 <td className="num">{fmtInt(r.cases_affected)}</td>178 <td className="num">{fmtInt(r.cases_profiled)}</td>179 <td className="num font-medium">{fmtPct(r.frequency, 1)}</td>180 <td className="num text-ink-3">{r.rank ?? '—'}</td>181 <td>182 <SourceBadge compact p={toInfo(prov.get(r.provenance_id)) ?? { sourceSlug: r.source_slug, sourceName: r.source_name, dataset: r.cohort_name, datasetVersion: r.data_release }} />183 </td>184 </tr>185 ))}186 </tbody>187 </table>188 </div>189 {hrefFor ? <Pager total={all.length} pageSize={pageSize} page={info.page} hrefFor={hrefFor} label="Cohort pages" noun="cohorts" className="mt-2 text-[12px]" /> : null}190 </div>191 );192}193