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%
15.8 KB · 259 lines tsx
Raw Blame History
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/&lt;slug&gt;) 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