spb/cancerindex
Public
1import Link from 'next/link';2import { Badge, ClaimBadge, MatchBadge } from '@/components/ui/badge';3import { SourceBadge } 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/** Gene alteration frequencies with mandatory denominators (§260-261), one table per cohort. */11export function FrequencyTables({ rows, prov, cohortFilter, showCancer = false }: { rows: FreqRow[]; prov: Map<number, ProvRow>; cohortFilter?: string | null; showCancer?: boolean }) {12 const cohorts = new Map<string, FreqRow[]>();13 for (const r of rows) {14 if (cohortFilter && r.cohort_id !== cohortFilter) continue;15 if (!cohorts.has(r.cohort_id)) cohorts.set(r.cohort_id, []);16 cohorts.get(r.cohort_id)!.push(r);17 }18 return (19 <div className="space-y-8">20 {[...cohorts.entries()].map(([cohortId, rs]) => {21 const c = rs[0]!;22 return (23 <section key={cohortId} aria-label={c.cohort_name}>24 <header className="mb-1.5 flex flex-wrap items-baseline justify-between gap-2">25 <h3 className="font-sans text-[15px] font-medium">26 {c.cohort_name} <span className="ci-mono text-[12px] text-ink-3">{c.study_id}</span>27 {c.program ? <Badge className="ml-2">{c.program}</Badge> : null}28 </h3>29 <p className="text-[12.5px] text-ink-3">30 {c.cases_with_ssm != null ? (31 <>32 <span className="ci-num">{fmtInt(c.cases_with_ssm)}</span> cases with simple somatic mutation data33 </>34 ) : null}35 {c.case_count != null ? (36 <>37 {' '}38 · <span className="ci-num">{fmtInt(c.case_count)}</span> cases total39 </>40 ) : null}41 {c.data_release ? <span className="ci-mono"> · {c.data_release}</span> : null}42 {showCancer && c.cancer_slug ? (43 <>44 {' '}45 · mapped to{' '}46 <Link className="ci-link" href={`/cancer/${c.cancer_slug}`}>47 {c.cancer_name}48 </Link>{' '}49 <MatchBadge matchType={c.cancer_match_type} />50 </>51 ) : null}52 </p>53 </header>54 <div className="ci-table-wrap">55 <table className="ci-table">56 <thead>57 <tr>58 <th className="num">#</th>59 <th>Gene</th>60 <th>Alteration</th>61 <th className="num">Affected (n)</th>62 <th className="num">Profiled (n)</th>63 <th className="num">Frequency (%)</th>64 <th>Source</th>65 </tr>66 </thead>67 <tbody>68 {rs.map((r, i) => (69 <tr key={r.id}>70 <td className="num text-ink-3">{r.rank ?? i + 1}</td>71 <td>72 <Link className="ci-mono ci-link font-medium" href={`/gene/${r.gene_symbol}`}>73 {r.gene_symbol}74 </Link>75 </td>76 <td>77 <Badge tone="outline">{humanize(r.alteration_type)}</Badge>78 </td>79 <td className="num">{fmtInt(r.cases_affected)}</td>80 <td className="num">{fmtInt(r.cases_profiled)}</td>81 <td className="num font-medium">{fmtPct(r.frequency, 1)}</td>82 <td>83 <span className="inline-flex gap-1">84 <SourceBadge p={toInfo(prov.get(r.provenance_id)) ?? { sourceSlug: r.source_slug, sourceName: r.source_name }} />85 <ClaimBadge kind="observed" />86 </span>87 </td>88 </tr>89 ))}90 </tbody>91 </table>92 </div>93 <Freshness dataUpdatedAt={c.updated_at} sourceVersion={c.data_release} extra="frequency = affected / profiled, as published by the cohort" />94 </section>95 );96 })}97 </div>98 );99}100101/** Gene-centric view: one compact row per cohort (used on /gene/[symbol]); denominators stay mandatory. */102export function GeneFrequencyTable({ rows, prov }: { rows: FreqRow[]; prov: Map<number, ProvRow> }) {103 const sorted = [...rows].sort((a, b) => b.frequency - a.frequency);104 return (105 <div className="ci-table-wrap">106 <table className="ci-table">107 <thead>108 <tr>109 <th>Cohort</th>110 <th>Mapped cancer</th>111 <th>Alteration</th>112 <th className="num">Affected (n)</th>113 <th className="num">Profiled (n)</th>114 <th className="num">Frequency (%)</th>115 <th className="num">Rank in cohort</th>116 <th>Source</th>117 </tr>118 </thead>119 <tbody>120 {sorted.map((r) => (121 <tr key={r.id}>122 <td>123 <span className="font-medium">{r.cohort_name}</span> <span className="ci-mono text-[11.5px] text-ink-3">{r.study_id}</span>124 </td>125 <td>126 {r.cancer_slug ? (127 <>128 <Link className="ci-link" href={`/cancer/${r.cancer_slug}`}>129 {r.cancer_name}130 </Link>{' '}131 <MatchBadge matchType={r.cancer_match_type} />132 </>133 ) : (134 <span className="text-ink-3">unmapped</span>135 )}136 </td>137 <td>138 <Badge tone="outline">{humanize(r.alteration_type)}</Badge>139 </td>140 <td className="num">{fmtInt(r.cases_affected)}</td>141 <td className="num">{fmtInt(r.cases_profiled)}</td>142 <td className="num font-medium">{fmtPct(r.frequency, 1)}</td>143 <td className="num text-ink-3">{r.rank ?? '—'}</td>144 <td>145 <span className="inline-flex gap-1">146 <SourceBadge p={toInfo(prov.get(r.provenance_id)) ?? { sourceSlug: r.source_slug, sourceName: r.source_name }} />147 <ClaimBadge kind="observed" />148 </span>149 </td>150 </tr>151 ))}152 </tbody>153 </table>154 <p className="mt-1 text-[12px] text-ink-3">frequency = affected / profiled, as published by each cohort; cohorts are not pooled.</p>155 </div>156 );157}158