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 { PageHeader, Section, Note } from '@/components/ui/section';4import { EmptyState } from '@/components/ui/empty-state';5import { Freshness } from '@/components/ui/freshness';6import { Badge } from '@/components/ui/badge';7import { ExplorerFilters } from '@/components/explorer/filters';8import { ChartGroup, assignSlots, groupProvenance } from '@/components/explorer/chart-group';9import { ObservationsTable } from '@/components/explorer/observations-table';10import { CoverageTable } from '@/components/explorer/coverage-table';11import { explorerOptions, resolveGeographyRef, resolveCancerRefs, topLevelCancerChoices, topCancersByLatest, yearRangeFor, explorerObservations, coverageMatrix, pendingEpidemiologySources, type ExplorerObsRow } from '@/lib/queries/explorer';12import { EPI_METRIC_LABEL } from '@/lib/queries/epidemiology';13import { parseExplorerParams, serializeExplorerParams, apiQueryFor, yearRangeLabel, sexLabel, ageLabel, SEX_ANY, MAX_CANCERS, type ExplorerState } from '@/lib/explorer-params';14import { groupComparable, explainSplit } from '@/lib/explorer-series';15import { SITE_URL, SITE_NAME } from '@/lib/site';16import type { SP } from '@/lib/search-params';17import { fmtDate, fmtInt, humanize, toDate, unitLabel } from '@/lib/format';1819export const revalidate = 600;2021export const metadata: Metadata = {22 title: 'Data explorer — cancer statistics by metric, cancer, geography, sex and year',23 description: 'Explore cancer incidence and mortality observations: choose a metric, up to six cancers, a geography, sex, age group and years; chart, table, sources, CSV and API links, permalink.',24 alternates: { canonical: '/explore' },25};2627const DEFAULT_METRIC = 'mortality_count';28const DEFAULT_GEOGRAPHY = 'united-states';2930/**31 * /explore — "Our World in Data"-style explorer over epidemiology_observations. URL = state = permalink.32 * Comparable observations (same metric, unit, geography, source, standard population, age group) share33 * one chart; anything else is a separate chart with the reason stated. Every number is shown with its34 * unit, geography, years, sex, age group, standard population, source and retrieval date.35 */36export default async function ExplorePage({ searchParams }: { searchParams: Promise<SP> }) {37 const sp = await searchParams;38 const [options, pending] = await Promise.all([explorerOptions(), pendingEpidemiologySources()]);39 const metricFallback = options.metrics.some((m) => m.metric === DEFAULT_METRIC) ? DEFAULT_METRIC : (options.metrics[0]?.metric ?? DEFAULT_METRIC);40 const geoFallback = options.geographies.some((g) => g.slug === DEFAULT_GEOGRAPHY) ? DEFAULT_GEOGRAPHY : (options.geographies[0]?.slug ?? DEFAULT_GEOGRAPHY);4142 // Pass 1: metric / geography / sex / age from the URL so the computed defaults (top cancers, years) match them.43 const prelim = parseExplorerParams(sp, { metric: metricFallback, geography: geoFallback, cancers: [] });44 const geo = await resolveGeographyRef(prelim.geography);45 const [top, range] = geo ? await Promise.all([topCancersByLatest(prelim.metric, geo.id, prelim.sex === SEX_ANY ? 'all' : prelim.sex, prelim.age, 5), yearRangeFor(prelim.metric, geo.id)]) : [null, null];46 const state = parseExplorerParams(sp, { metric: metricFallback, geography: geoFallback, cancers: top?.cancers.map((c) => c.slug) ?? [], from: range?.min ?? null, to: range?.max ?? null });47 const usingDefaultCancers = !sp.cancers || (Array.isArray(sp.cancers) ? sp.cancers.every((s) => !s.trim()) : !sp.cancers.trim());4849 const [cancers, choices] = await Promise.all([resolveCancerRefs(state.cancers), topLevelCancerChoices(state.metric, geo?.id ?? null)]);50 const unresolved = state.cancers.filter((c) => !cancers.some((k) => k.slug === c || k.id === c));51 const obs = geo && cancers.length > 0 ? await explorerObservations({ metric: state.metric, cancerIds: cancers.map((c) => c.id), geographyId: geo.id, sex: state.sex, age: state.age, from: state.from, to: state.to }) : [];52 const groups = groupComparable(obs);53 const slots = assignSlots(groups);54 const split = explainSplit(groups);5556 const metricLabel = EPI_METRIC_LABEL[state.metric] ?? humanize(state.metric);57 const metricOpt = options.metrics.find((m) => m.metric === state.metric);58 const permalink = `${SITE_URL}/explore${serializeExplorerParams(state, { page: 1 })}`;59 const csvHref = `/api/export/epidemiology.csv${serializeExplorerParams(state, { page: 1 })}`;60 const apiHref = `/api/v1/epidemiology${apiQueryFor(state)}`;61 const hrefFor = (page: number) => `/explore${serializeExplorerParams(state, { page })}`;6263 // Sources actually behind the result, with their latest retrieval date.64 const sourcesUsed = sourcesIn(obs);65 const freshest = obs.map((o) => toDate(o.updated_at)).filter((d): d is Date => !!d).sort((a, b) => b.getTime() - a.getTime())[0] ?? null;66 const coverage = obs.length === 0 ? await coverageMatrix({ cancerIds: cancers.map((c) => c.id), geographyId: geo?.id ?? null }) : [];67 const ingestedNames = options.sources.map((s) => s.name.split(' — ')[0]!);6869 return (70 <div>71 <PageHeader kicker="Data" title="Data explorer" lede="Chart and download registry observations by metric, cancer, geography, sex, age group and year. Values are exactly as published by the source; CancerIndex harmonizes units and labels, never the numbers. The URL is the permalink.">72 <p className="mt-2 text-[12.5px] text-ink-3">73 {fmtInt(options.n_obs)} observations · {options.metrics.length} metrics · {options.geographies.length} {options.geographies.length === 1 ? 'geography' : 'geographies'} · {options.year_min}–{options.year_max} · sources: {options.sources.map((s) => s.slug).join(', ')} ·{' '}74 <Link className="ci-link" href="/explore/coverage">75 full coverage matrix76 </Link>{' '}77 ·{' '}78 <Link className="ci-link" href="/methodology#data-explorer">79 comparability rules80 </Link>81 </p>82 </PageHeader>8384 <ExplorerFilters options={options} state={state} choices={choices} />8586 {unresolved.length > 0 ? (87 <Note tone="warn">88 Unknown cancer reference{unresolved.length === 1 ? '' : 's'} ignored: <span className="ci-mono">{unresolved.join(', ')}</span>. Use a taxonomy slug (as in /cancer/<slug>) or a CI-CAN id.89 </Note>90 ) : null}9192 <Section93 id="results"94 kicker="Selection"95 title={<SelectionSentence state={state} metricLabel={metricLabel} unit={metricOpt?.unit ?? obs[0]?.unit ?? null} geographyName={geo?.name ?? state.geography} />}96 description={97 <>98 {sourcesUsed.length > 0 ? (99 <>100 Source{sourcesUsed.length === 1 ? '' : 's'}: {sourcesUsed.map((s) => `${s.name}${s.retrieved ? ` (retrieved ${fmtDate(s.retrieved)})` : ''}`).join('; ')}.{' '}101 </>102 ) : null}103 {usingDefaultCancers && top?.cancers.length ? (104 <>105 Default selection: the {top.cancers.length} top-level cancers with the highest {metricLabel.toLowerCase()} in {top.year} ({geo?.name}), computed from the observations — not a curated list.106 </>107 ) : null}108 </>109 }110 actions={<Downloads csvHref={csvHref} apiHref={apiHref} disabled={obs.length === 0} />}111 >112 {obs.length === 0 ? (113 <div className="space-y-4">114 <EmptyState115 title="Data not yet available"116 knows={[117 ...(geo ? [{ label: `${geo.name} country page`, href: `/country/${geo.slug}` }] : []),118 ...cancers.slice(0, 3).map((c) => ({ label: c.canonical_name, href: `/cancer/${c.slug}/statistics` })),119 { label: 'Full coverage matrix', href: '/explore/coverage' },120 { label: 'Sources and license status', href: '/sources' },121 ]}122 >123 No observation matches {metricLabel.toLowerCase()} · {geo?.name ?? `geography "${state.geography}"`} · {sexLabel(state.sex)} · {ageLabel(state.age)} · {yearRangeLabel(state.from, state.to)}124 {cancers.length > 0 ? ` for ${cancers.map((c) => c.canonical_name).join(', ')}` : cancers.length === 0 && state.cancers.length === 0 ? ' — no cancer selected' : ''}.125 {!geo ? ` "${state.geography}" is not a known geography slug or ISO3 code.` : ''} Nothing is estimated or extrapolated. {coverage.length > 0 ? 'What does exist for this selection is listed below.' : ''}126 </EmptyState>127 {coverage.length > 0 ? (128 <div>129 <p className="ci-kicker mb-1">What exists for {cancers.length > 0 ? `${cancers.length === 1 ? cancers[0]!.canonical_name : `these ${cancers.length} cancers`}` : 'every cancer'}{geo ? ` in ${geo.name}` : ''}</p>130 <CoverageTable rows={coverage} cancers={cancers.map((c) => c.slug)} compact />131 </div>132 ) : null}133 </div>134 ) : (135 <div className="space-y-8">136 {split ? <Note tone="warn">{split}</Note> : null}137 {groups.map((g, i) => (138 <ChartGroup key={g.key} group={g} view={state.view} slots={slots} provenance={groupProvenance(g, provenanceFor(g.source_slug, g.provenance_ids, obs))} index={i} total={groups.length} />139 ))}140 <Freshness dataUpdatedAt={freshest} sourceVersion={sourcesUsed.map((s) => s.version).filter(Boolean).join(' · ') || null} extra={`${fmtInt(obs.length)} observations · ${groups.length} comparable ${groups.length === 1 ? 'group' : 'groups'}`} />141 </div>142 )}143 </Section>144145 {obs.length > 0 ? (146 <Section id="observations" kicker="Table" title="Observations" description="Every observation behind the charts. Sorted by cancer, geography, sex and year. Counts are per site group as published — they are not summed into an all-sites total.">147 <ObservationsTable rows={obs} page={state.page} hrefFor={hrefFor} />148 </Section>149 ) : null}150151 <Section id="share" kicker="Share and cite" title="Permalink, downloads and citation">152 <div className="grid gap-4 md:grid-cols-2">153 <div className="min-w-0">154 <label className="flex flex-col gap-1 text-[13px]">155 <span className="ci-kicker">Permalink (this selection)</span>156 <input readOnly value={permalink} className="ci-mono w-full border border-rule-strong bg-paper-2 px-2 py-1.5 text-[12px] text-ink-2" aria-label="Permalink for this selection" />157 </label>158 <ul className="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-[13px]">159 <li>160 <a className="ci-link" href={csvHref}>161 Download CSV162 </a>{' '}163 <span className="text-ink-3">(attribution header, provenance id and source URL per row, ≤ 50 000 rows)</span>164 </li>165 <li>166 <a className="ci-link" href={apiHref}>167 JSON (API)168 </a>{' '}169 <span className="text-ink-3">170 · <Link className="ci-link" href="/developers">API docs</Link>171 </span>172 </li>173 </ul>174 </div>175 <div className="min-w-0 text-[13px] leading-relaxed">176 <p className="ci-kicker mb-1">Cite</p>177 <p className="text-ink-2">178 {SITE_NAME} ({new Date().getUTCFullYear()}). Data explorer: {metricLabel}, {geo?.name ?? state.geography}, {sexLabel(state.sex)}, {ageLabel(state.age)}, {yearRangeLabel(state.from, state.to)}. {SITE_URL}/explore (accessed {fmtDate(new Date())}).{' '}179 {sourcesUsed.length > 0 ? (180 <>181 Underlying observations: {sourcesUsed.map((s) => `${s.name}${s.dataset ? `, ${s.dataset}` : ''}${s.retrieved ? `, retrieved ${fmtDate(s.retrieved)}` : ''}`).join('; ')}.182 </>183 ) : null}184 </p>185 <p className="mt-1 text-[12px] text-ink-3">Underlying observations remain under their providers' licenses (see each source page); CancerIndex's harmonization is CC BY 4.0.</p>186 </div>187 </div>188 </Section>189190 <div className="mt-6 space-y-2">191 <Note tone="warn">Population statistics describe groups defined by geography, period, sex and age. They do not predict any individual's risk or outcome. Values labelled "estimated" or "projected" are model outputs of the source, not registry counts.</Note>192 <Note>193 Geographies are limited to the sources currently ingested: {ingestedNames.join('; ')} ({options.geographies.map((g) => g.name).join(', ')}).194 {pending.length > 0 ? <> Registered but not yet ingested: {pending.map((p) => `${p.name.split(' — ')[0]} (${pendingReason(p)})`).join('; ')} — their geographies appear only once the licensing gate is passed and a sync has succeeded; nothing is shown from them until then.</> : null} Up to {MAX_CANCERS} cancers per chart; different standard populations, sources or age groups are never overlaid (195 <Link className="ci-link" href="/methodology#data-explorer">196 rules197 </Link>198 ).199 </Note>200 </div>201 </div>202 );203}204205/** Why a registered epidemiology source has no observation yet: its connector status when not active, else its license status. */206function pendingReason(p: { status: string; license_status: string }): string {207 const s = p.status !== 'active' ? p.status : p.license_status;208 return s.replace(/_/g, ' ');209}210211function SelectionSentence({ state, metricLabel, unit, geographyName }: { state: ExplorerState; metricLabel: string; unit: string | null; geographyName: string }) {212 return (213 <span className="text-lg sm:text-xl">214 {metricLabel}215 {unit ? <span className="text-ink-3"> ({unitLabel(unit)})</span> : null} · {geographyName} · {sexLabel(state.sex)} · {ageLabel(state.age)} · {yearRangeLabel(state.from, state.to)}216 {state.view === 'multiples' ? <Badge tone="outline" className="ml-2 align-middle">small multiples</Badge> : null}217 </span>218 );219}220221function Downloads({ csvHref, apiHref, disabled }: { csvHref: string; apiHref: string; disabled: boolean }) {222 if (disabled) return <span className="text-ink-4">no rows to download</span>;223 return (224 <>225 <a className="ci-link" href={csvHref}>226 CSV227 </a>228 <span aria-hidden className="text-ink-4">229 ·230 </span>231 <a className="ci-link" href={apiHref}>232 JSON (API)233 </a>234 </>235 );236}237238function sourcesIn(obs: ExplorerObsRow[]): Array<{ slug: string; name: string; retrieved: Date | null; dataset: string | null; version: string | null }> {239 const m = new Map<string, { slug: string; name: string; retrieved: Date | null; dataset: string | null; version: string | null }>();240 for (const o of obs) {241 const r = toDate(o.retrieved_at);242 const cur = m.get(o.source_slug);243 if (!cur) m.set(o.source_slug, { slug: o.source_slug, name: o.source_name, retrieved: r, dataset: o.dataset, version: o.dataset_version });244 else if (r && (!cur.retrieved || r > cur.retrieved)) m.set(o.source_slug, { ...cur, retrieved: r, dataset: o.dataset, version: o.dataset_version });245 }246 return [...m.values()].sort((a, b) => a.slug.localeCompare(b.slug));247}248249/** Most recently retrieved provenance row of a group (its observations may span several datasets/runs). */250function provenanceFor(sourceSlug: string, provenanceIds: number[], obs: ExplorerObsRow[]) {251 const ids = new Set(provenanceIds);252 let best: ExplorerObsRow | undefined;253 for (const o of obs) {254 if (o.source_slug !== sourceSlug || !ids.has(o.provenance_id)) continue;255 if (!best || String(o.retrieved_at ?? '') > String(best.retrieved_at ?? '')) best = o;256 }257 return best ? { dataset: best.dataset, dataset_version: best.dataset_version, retrieved_at: best.retrieved_at, source_url: best.source_url, license: best.source_license } : undefined;258}259