spb/cancerindex
Public
TypeScript 97.2%
SQL 1.5%
CSS 0.6%
JavaScript 0.5%
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