SPB Git forge

spb/cancerindex

Public
37commits 1branches 0releases
2.9 MBsize
maindefault branch
10 days agolast push
TypeScript 97.2% SQL 1.5% CSS 0.6% JavaScript 0.5%
8.7 KB · 193 lines tsx
Raw Blame History
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