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%
18.4 KB · 261 lines tsx
Raw Blame History
1import type { Metadata } from 'next';2import Link from 'next/link';3import type { ReactNode } from 'react';4import { PageHeader, Section, Note } from '@/components/ui/section';5import { EmptyState } from '@/components/ui/empty-state';6import { Freshness } from '@/components/ui/freshness';7import { Badge, ClaimBadge, ConfidenceBadge } from '@/components/ui/badge';8import { SourceBadge } from '@/components/ui/source-badge';9import { CompareBars, type CompareBarDatum } from '@/components/charts/compare-bars';10import { loadCompare, parseCompareIds, COMPARE_MIN, COMPARE_MAX, type CompareEntity } from '@/lib/queries/compare';11import { loadProvenance, toInfo } from '@/lib/queries/provenance';12import { bestRankFor } from '@/lib/queries/rankings';13import { EPI_METRIC_LABEL } from '@/lib/queries/epidemiology';14import { str, type SP } from '@/lib/search-params';15import { fmtInt, fmtValue, humanize, scopeLabel, toDate, unitLabel } from '@/lib/format';16import { ComparePicker } from './picker';1718export const dynamic = 'force-dynamic';1920export async function generateMetadata({ searchParams }: { searchParams: Promise<SP> }): Promise<Metadata> {21  const ids = parseCompareIds(str(await searchParams, 'ids'));22  const ents = ids.length ? await loadCompare(ids) : [];23  const names = ents.map((e) => e.cancer.canonical_name);24  return {25    title: names.length >= 2 ? `Compare: ${names.join(' vs ')}` : 'Compare cancers',26    description: names.length >= 2 ? `Side-by-side facts for ${names.join(', ')}: taxonomy, US registry figures, trials, literature, curated evidence, cohorts and current ranks — every value with its source and period.` : 'Compare two to four cancers side by side: taxonomy, US registry figures, clinical research, literature, molecular evidence and current ranks, with a source on every value.',27    alternates: { canonical: ids.length ? `/compare?ids=${ids.join(',')}` : '/compare' },28    robots: ids.length ? { index: false, follow: true } : undefined,29  };30}3132const EPI_ROWS: Array<[string, string]> = [33  ['mortality_count', 'US annual deaths'],34  ['incidence_count', 'US annual new cases'],35  ['as_mortality_rate', 'US age-standardized mortality'],36  ['as_incidence_rate', 'US age-standardized incidence'],37];3839const RANK_METRICS = ['mortality_count', 'incidence_count', 'as_mortality_rate', 'as_incidence_rate', 'mortality_incidence_ratio', 'trial_gap', 'research_gap', 'active_trials', 'recruiting_trials', 'phase3_trials', 'publications_5y', 'publications_12m', 'publication_growth', 'curated_evidence_items', 'associated_genes', 'genomic_cohorts'];4041/**42 * /compare?ids=a,b,c (§100): side-by-side table of facts that exist in the database. Registry figures below the43 * top level fall back to the nearest top-level ancestor with an explicit label (methodology#compare).44 */45export default async function ComparePage({ searchParams }: { searchParams: Promise<SP> }) {46  const sp = await searchParams;47  const ids = parseCompareIds(str(sp, 'ids'));48  const ents = ids.length ? await loadCompare(ids) : [];49  const missing = ids.filter((s) => !ents.some((e) => e.cancer.slug === s));50  const enough = ents.length >= COMPARE_MIN;51  const provIds = ents.flatMap((e) => [...e.figures.values()].map((f) => f.provenance_id));52  const prov = await loadProvenance(provIds);5354  const registryNote = (e: CompareEntity) => (e.registry && e.registry.depth > 0 ? `registry level: ${e.registry.canonical_name}` : null);55  const freshest = ents56    .flatMap((e) => [...[...e.figures.values()].map((f) => toDate(f.updated_at)), e.counters ? toDate(e.counters.updated_at) : null, ...e.ranks.map((r) => toDate(r.generated_at))])57    .filter((d): d is Date => !!d)58    .sort((a, b) => b.getTime() - a.getTime())[0];5960  return (61    <div>62      <PageHeader kicker="Comparison engine" title="Compare cancers" lede="Two to four cancers side by side. Only facts present in the database are shown; every figure carries its source, period and unit. Entities at different hierarchy depths are labelled so counts are not read as equivalent." />63      <ComparePicker selected={ents.map((e) => ({ slug: e.cancer.slug, name: e.cancer.canonical_name }))} max={COMPARE_MAX} min={COMPARE_MIN} />64      {missing.length ? (65        <p className="mt-2 text-[12.5px] text-warn">66          Unknown or merged slug{missing.length > 1 ? 's' : ''}: <span className="ci-mono">{missing.join(', ')}</span> — ignored.67        </p>68      ) : null}6970      {!enough ? (71        <div className="mt-6">72          <EmptyState title={ents.length === 1 ? 'Add at least one more cancer' : 'Pick cancers to compare'} knows={[{ label: 'Pancreatic vs lung vs glioblastoma', href: '/compare?ids=malignant-pancreatic-neoplasm,malignant-lung-neoplasm,glioblastoma' }, { label: 'Breast vs prostate', href: '/compare?ids=malignant-breast-neoplasm,malignant-prostate-neoplasm' }, { label: 'Cancer explorer', href: '/cancers' }]}>73            Use the picker above or open <span className="ci-mono">/compare?ids=slug-1,slug-2</span> with cancer slugs from their pages.74          </EmptyState>75        </div>76      ) : (77        <div className="mt-6 space-y-8">78          <CompareTable ents={ents} title="Taxonomy" id="taxonomy" rows={[79            { k: 'CancerIndex ID', cells: ents.map((e) => <span className="ci-mono text-[12px]">{e.cancer.id}</span>) },80            { k: 'Entity type', cells: ents.map((e) => humanize(e.cancer.entity_type)) },81            { k: 'Hierarchy depth', cells: ents.map((e) => <span className="ci-num">{e.cancer.depth}</span>) },82            { k: 'Top-level registry site', cells: ents.map((e) => (e.cancer.top_level ? 'Yes' : e.registry ? <span>No — nearest: <Link className="ci-link" href={`/cancer/${e.registry.slug}`}>{e.registry.canonical_name}</Link> <span className="text-ink-3">({e.registry.depth} level{e.registry.depth === 1 ? '' : 's'} up)</span></span> : <span className="text-ink-3">No top-level ancestor</span>)) },83            { k: 'Parents', cells: ents.map((e) => (e.parents.length ? <ul className="space-y-0.5">{e.parents.map((p) => <li key={`${p.hierarchy_type}-${p.slug}`}><Link className="ci-link" href={`/cancer/${p.slug}`}>{p.canonical_name}</Link> <span className="text-[11px] text-ink-3">{p.hierarchy_type}</span></li>)}</ul> : <span className="text-ink-3">root</span>)) },84            { k: 'NCIt / OncoTree', cells: ents.map((e) => <span className="ci-mono text-[12px]">{[e.cancer.primary_ncit_code, e.cancer.primary_oncotree_code].filter(Boolean).join(' · ') || '—'}</span>) },85            { k: 'Badges', cells: ents.map((e) => <span className="flex flex-wrap gap-1">{e.cancer.rare_cancer === true ? <Badge tone="accent">Rare</Badge> : null}{e.cancer.pediatric_relevant ? <Badge>Pediatric</Badge> : null}{e.cancer.hematologic ? <Badge>Hematologic</Badge> : null}{e.cancer.solid_tumor && !e.cancer.hematologic ? <Badge tone="outline">Solid tumor</Badge> : null}{!e.cancer.malignant ? <Badge tone="outline">Non-malignant</Badge> : null}{e.cancer.top_level ? <Badge tone="outline">Top-level</Badge> : null}{e.cancer.rare_cancer == null ? <span className="text-[11.5px] text-ink-3">rarity unknown</span> : null}</span>) },86          ]} caption={<>Taxonomy from NCIt / OncoTree (<ClaimBadge kind="curated" />). Badges are rule-derived from stored attributes.</>} />8788          <CompareTable89            ents={ents}90            title="US registry figures (latest year available per metric)"91            id="registry"92            headNote={ents.map((e) => registryNote(e))}93            rows={EPI_ROWS.map(([metric, label]) => ({94              k: label,95              cells: ents.map((e) => {96                const f = e.figures.get(metric);97                if (!f) return e.registry ? <span className="text-[12.5px] text-ink-3">{e.registry.depth > 0 ? 'no registry observation at this level; none for the ancestor either' : 'no observation'}</span> : <span className="text-[12.5px] text-ink-3">no registry observation at this level (no top-level ancestor)</span>;98                const p = toInfo(prov.get(f.provenance_id), 'normalized') ?? { sourceSlug: f.source_slug, sourceName: f.source_name };99                return (100                  <span className="flex flex-col gap-0.5">101                    <span className="flex flex-wrap items-baseline gap-x-1.5">102                      <span className="ci-num text-[16px] font-medium">{fmtValue(f.value, f.unit)}</span>103                      <span className="text-[11px] text-ink-3">{unitLabel(f.unit)}</span>104                      {f.estimate_type !== 'observed' ? <span className="text-[11px] italic text-warn">{f.estimate_type}</span> : null}105                    </span>106                    <span className="text-[11.5px] text-ink-3">107                      {f.year_end && f.year_end !== f.year ? `${f.year}–${f.year_end}` : f.year} · both sexes · all ages{f.standard_population ? ` · ${f.standard_population.replace(/\s*\(.*\)$/, '')}` : ''}108                    </span>109                    <span className="flex flex-wrap items-center gap-1">110                      <SourceBadge p={p} />111                      <ClaimBadge kind="observed" />112                    </span>113                    {e.registry && e.registry.depth > 0 ? <span className="text-[11px] italic text-warn">figures for {e.registry.canonical_name}</span> : null}114                  </span>115                );116              }),117            }))}118            caption={<>Observations as published (site definitions on each entity's Statistics tab). No global figures: IARC / GLOBOCAN is under license review and SEER awaits credentials. For entities below the top level the nearest top-level ancestor's registry figures are shown and labelled; they describe the whole site group, not the subtype.</>}119          />120121          <CompareTable122            ents={ents}123            title="Counters (entity + descendants)"124            id="counters"125            rows={[126              ['Active trials', 'active_trial_count', 'clinicaltrials', '/trials'],127              ['Recruiting trials', 'recruiting_trial_count', 'clinicaltrials', '/trials?status=RECRUITING'],128              ['Active Phase III trials', 'phase3_trial_count', 'clinicaltrials', '/trials?phase=PHASE3'],129              ['Publications, last 5 years', 'publication_count_5y', 'pubmed', '/research'],130              ['Publications, last 12 months', 'publication_count_12m', 'pubmed', '/research'],131              ['Curated evidence items', 'evidence_count', 'civic', '/evidence'],132              ['Genes with evidence', 'gene_count', 'civic', '/genomics'],133              ['Genomic cohorts', 'cohort_count', 'gdc', '/genomics'],134              ['Drugs (any / approved)', 'drug_count', 'civic', '/drugs'],135            ].map(([label, field, src, path]) => ({136              k: label as string,137              cells: ents.map((e) => {138                const c = e.counters;139                if (!c) return <span className="text-[12.5px] text-ink-3">counters not computed</span>;140                const v = c[field as keyof typeof c] as number;141                return (142                  <span className="flex flex-col gap-0.5">143                    <Link href={`/cancer/${e.cancer.slug}${path}`} className="ci-num text-[16px] font-medium no-underline hover:text-accent">144                      {fmtInt(v)}145                      {field === 'drug_count' ? <span className="text-[13px] text-ink-3"> / {fmtInt(c.approved_drug_count)}</span> : null}146                    </Link>147                    <span className="flex flex-wrap items-center gap-1 text-[11px] text-ink-3">148                      <SourceBadge p={{ sourceSlug: src as string, layer: 'derived', note: 'Counter computed by CancerIndex over the entity and its descendants.' }} compact />149                      <ClaimBadge kind="computed" />150                    </span>151                  </span>152                );153              }),154            }))}155            caption={<>Counts aggregate over descendants across hierarchy types, so a broad family counts more than one of its subtypes; publication counts are query-based per entity. Counters refreshed {freshest ? <span title={freshest.toISOString()}>{freshest.toISOString().slice(0, 10)}</span> : 'unknown'}.</>}156          />157158          <CompareTable159            ents={ents}160            title="Current ranks"161            id="ranks"162            rows={RANK_METRICS.filter((m) => ents.some((e) => e.ranks.some((r) => r.metric_slug === m))).map((m) => ({163              k: ents.flatMap((e) => e.ranks).find((r) => r.metric_slug === m)?.metric_name ?? humanize(m),164              cells: ents.map((e) => {165                const own = bestRankFor(166                  e.ranks.filter((r) => r.cancer_id === e.cancer.id),167                  m,168                  { geo: 'USA', level: e.cancer.top_level ? 'top' : 'all' },169                );170                const viaAncestor = !own && e.registry && e.registry.depth > 0 ? bestRankFor(e.ranks.filter((r) => r.cancer_id === e.registry!.id), m, { geo: 'USA', level: 'top' }) : null;171                const r = own ?? viaAncestor;172                if (!r) return <span className="text-[12px] text-ink-3">not ranked</span>;173                return (174                  <span className="flex flex-col gap-0.5 text-[12.5px]">175                    <Link className="ci-link" href={`/rankings/${r.metric_slug}?scope=${encodeURIComponent(r.scope_key)}`}>176                      <span className="ci-num font-medium">#{r.rank}</span> of {fmtInt(r.eligible_entities)}177                    </Link>178                    <span className="text-[11px] text-ink-3">179                      {fmtValue(r.value, r.unit)} {unitLabel(r.unit)} · {scopeLabel(r.scope_key)}180                    </span>181                    <span className="flex flex-wrap gap-1">182                      <ConfidenceBadge level={r.confidence} />183                      {viaAncestor ? <span className="text-[11px] italic text-warn">rank of {e.registry!.canonical_name}</span> : null}184                    </span>185                  </span>186                );187              }),188            }))}189            caption={<>Ranks come from current snapshots (metric × scope × formula version); "of n" is the number of eligible entities in that scope. Top-level entities are ranked among the 36 mutually exclusive site groups, others among all malignant entities — the two are not comparable. <Link className="ci-link" href="/methodology#versioning">Versioning</Link>.</>}190            emptyText="No current ranking snapshot covers these entities."191          />192193          <Section id="charts" kicker="Charts" title="Side by side" description="Bars are proportional within each chart; missing values are shown as a dash, never as zero.">194            <div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-4">195              <CompareBars title="US annual deaths" unit="count" data={barsFor(ents, (e) => e.figures.get('mortality_count'))} caption={<>Registry observation · latest year · <ClaimBadge kind="observed" /></>} />196              <CompareBars title="US age-standardized mortality" unit="per_100k" data={barsFor(ents, (e) => e.figures.get('as_mortality_rate'))} caption="Per 100,000 · standard population declared by the source" />197              <CompareBars title="Active trials" unit="count" data={ents.map((e) => ({ label: e.cancer.canonical_name, value: e.counters ? e.counters.active_trial_count : null, note: 'ClinicalTrials.gov · entity + descendants', href: `/cancer/${e.cancer.slug}/trials` }))} caption={<>Counter · <ClaimBadge kind="computed" /></>} />198              <CompareBars title="Publications, last 5 years" unit="count" data={ents.map((e) => ({ label: e.cancer.canonical_name, value: e.counters ? e.counters.publication_count_5y : null, note: 'PubMed · stored query per entity', href: `/cancer/${e.cancer.slug}/research` }))} caption={<>Counter · <ClaimBadge kind="computed" /></>} />199            </div>200          </Section>201202          <Note>Comparisons juxtapose facts of different natures (registry counts, registrations, literature volume). They are not a judgment of severity or priority, and population figures never describe an individual.</Note>203          <Freshness dataUpdatedAt={freshest ?? null} extra="each cell states its own period and source" />204        </div>205      )}206    </div>207  );208}209210function barsFor(ents: CompareEntity[], pick: (e: CompareEntity) => { value: number; year: number; estimate_type: string } | undefined): CompareBarDatum[] {211  return ents.map((e) => {212    const f = pick(e);213    const anc = e.registry && e.registry.depth > 0 ? e.registry.canonical_name : null;214    return { label: e.cancer.canonical_name, value: f ? Number(f.value) : null, note: f ? `${f.year} · ${f.estimate_type}${anc ? ` · registry level: ${anc}` : ''}` : 'no registry observation', href: `/cancer/${e.cancer.slug}/statistics`, muted: !!anc };215  });216}217218function CompareTable({ ents, title, id, rows, caption, headNote, emptyText }: { ents: CompareEntity[]; title: string; id: string; rows: Array<{ k: string; cells: ReactNode[] }>; caption?: ReactNode; headNote?: Array<string | null>; emptyText?: string }) {219  const nonEmpty = rows;220  return (221    <Section id={id} kicker="Facts" title={title}>222      {nonEmpty.length === 0 ? (223        <EmptyState compact>{emptyText ?? 'Nothing to compare in this block.'}</EmptyState>224      ) : (225        <div className="ci-table-wrap">226          <table className="ci-table" style={{ minWidth: `${180 + ents.length * 200}px` }}>227            <thead>228              <tr>229                <th className="sticky-col">Fact</th>230                {ents.map((e, i) => (231                  <th key={e.cancer.id}>232                    <Link className="ci-link normal-case tracking-normal" href={`/cancer/${e.cancer.slug}`} style={{ fontSize: 13 }}>233                      {e.cancer.canonical_name}234                    </Link>235                    {headNote?.[i] ? <span className="block text-[10.5px] font-normal normal-case tracking-normal text-warn">{headNote[i]}</span> : null}236                  </th>237                ))}238              </tr>239            </thead>240            <tbody>241              {nonEmpty.map((r) => (242                <tr key={r.k}>243                  <th scope="row" className="sticky-col text-left text-[12.5px] font-medium text-ink-2" style={{ verticalAlign: 'top', padding: '7px 10px' }}>244                    {r.k}245                  </th>246                  {r.cells.map((c, i) => (247                    <td key={ents[i]?.cancer.id ?? i} className="text-[13px]">248                      {c}249                    </td>250                  ))}251                </tr>252              ))}253            </tbody>254          </table>255        </div>256      )}257      {caption ? <p className="mt-2 flex flex-wrap items-center gap-1 text-[12px] text-ink-3">{caption}</p> : null}258    </Section>259  );260}261