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