import type { Metadata } from 'next'; import Link from 'next/link'; import { Download } from 'lucide-react'; import { PageHeader, Section, Note } from '@/components/ui/section'; import { Badge, ClaimBadge } from '@/components/ui/badge'; import { EmptyState } from '@/components/ui/empty-state'; import { Freshness } from '@/components/ui/freshness'; import { SourceBadge } from '@/components/ui/source-badge'; import { JsonView } from '@/components/ui/json-view'; import { ScatterChart, type ScatterPoint } from '@/components/charts/scatter-chart'; import { listGapScopes, pickGapScope, gapComponents, gapSums, gapScopeKey, gapMetricDefs, type GapComponent } from '@/lib/queries/research-gap'; import { fmtInt, fmtNum, fmtPct, fmtValue, fmtDateTime, humanize } from '@/lib/format'; import { str, int, oneOf, withParams, type SP } from '@/lib/search-params'; export const dynamic = 'force-dynamic'; export const metadata: Metadata = { title: 'Research Gap Index — burden vs research activity', description: 'Deaths, active trials and publications per top-level cancer in one burden scope; shares, log2 gap ratios and per-1,000-deaths intensities with their formula versions and inputs.', }; const SORT_KEYS = ['canonical_name', 'deaths', 'death_share', 'active_trials', 'trial_share', 'publications_5y', 'publication_share', 'trial_gap_ratio', 'research_gap_ratio', 'trials_per_1000_deaths', 'publications_per_1000_deaths'] as const; type SortKey = (typeof SORT_KEYS)[number]; function sortRows(rows: GapComponent[], key: SortKey, dir: 'asc' | 'desc'): GapComponent[] { const sgn = dir === 'asc' ? 1 : -1; return [...rows].sort((a, b) => { // Ineligible rows always sink to the bottom. if (a.eligible !== b.eligible) return a.eligible ? -1 : 1; if (key === 'canonical_name') return sgn * a.canonical_name.localeCompare(b.canonical_name); const av = a[key]; const bv = b[key]; if (av == null && bv == null) return a.canonical_name.localeCompare(b.canonical_name); if (av == null) return 1; if (bv == null) return -1; return sgn * (Number(av) - Number(bv)) || a.canonical_name.localeCompare(b.canonical_name); }); } /** Short display label for chart points: the entity's short name, else the canonical name without the "Malignant … Neoplasm" wrapper. */ function chartLabel(r: { canonical_name: string; short_name: string | null }): string { if (r.short_name) return r.short_name; return r.canonical_name.replace(/^Malignant\s+/i, '').replace(/\s+Neoplasm$/i, ''); } function Ratio({ v }: { v: number | null }) { if (v == null) return —; const cls = v >= 1 ? 'text-danger font-medium' : v > 0 ? 'text-ink' : 'text-ink-3'; return {fmtValue(v, 'log2_ratio')}; } export default async function ResearchGapPage({ searchParams }: { searchParams: Promise }) { const sp = await searchParams; const scopes = await listGapScopes(); const want = { geography: str(sp, 'geography', 'USA'), year: int(sp, 'year', 0, 1900, 2100) || null, sex: oneOf(sp, 'sex', ['all', 'male', 'female'] as const, 'all'), source: str(sp, 'source') }; const scope = pickGapScope(scopes, want); const sort = oneOf(sp, 'sort', SORT_KEYS, 'research_gap_ratio'); const dir = oneOf(sp, 'dir', ['asc', 'desc'] as const, sort === 'canonical_name' ? 'asc' : 'desc'); const [rowsRaw, defs] = scope ? await Promise.all([gapComponents(scope), gapMetricDefs()]) : [[] as GapComponent[], new Map()]; const rows = sortRows(rowsRaw, sort, dir); const eligible = rows.filter((r) => r.eligible); const sums = gapSums(rows); const current = { geography: scope?.geography ?? want.geography, year: scope?.year ?? null, sex: scope?.sex ?? want.sex, source: scope?.source_slug ?? null, sort, dir }; const sortLink = (key: SortKey) => withParams(current, { sort: key, dir: sort === key ? (dir === 'desc' ? 'asc' : 'desc') : key === 'canonical_name' ? 'asc' : 'desc' }); const sortMark = (key: SortKey) => (sort === key ? (dir === 'desc' ? ' ▼' : ' ▲') : ''); const geographies = [...new Set(scopes.map((s) => s.geography))]; const yearsFor = (geo: string, sex: string) => [...new Set(scopes.filter((s) => s.geography === geo && s.sex === sex).map((s) => Number(s.year)))].sort((a, b) => b - a); const sexesFor = (geo: string) => [...new Set(scopes.filter((s) => s.geography === geo).map((s) => s.sex))]; const sourcesFor = (geo: string, year: number | null, sex: string) => scopes.filter((s) => s.geography === geo && Number(s.year) === year && s.sex === sex); const pubSlope = sums.deaths > 0 ? sums.publications5y / sums.deaths : 0; const trialSlope = sums.deaths > 0 ? sums.activeTrials / sums.deaths : 0; const cancerHref = (r: GapComponent) => `/cancer/${r.slug}/rankings`; const sourceBadge = scope ? : null; const pubPoints: ScatterPoint[] = eligible.map((r) => ({ id: r.cancer_id, label: chartLabel(r), href: cancerHref(r), x: Number(r.deaths), y: Number(r.publications_5y), size: Number(r.active_trials), tooltip: `${r.canonical_name} — deaths ${fmtInt(r.deaths)} (${scope?.year}) · publications 5 y ${fmtInt(r.publications_5y)} · active trials ${fmtInt(r.active_trials)} · research gap ratio ${fmtValue(r.research_gap_ratio, 'log2_ratio')}`, })); const trialPoints: ScatterPoint[] = eligible.map((r) => ({ id: r.cancer_id, label: chartLabel(r), href: cancerHref(r), x: Number(r.deaths), y: Number(r.active_trials), tooltip: `${r.canonical_name} — deaths ${fmtInt(r.deaths)} (${scope?.year}) · active trials ${fmtInt(r.active_trials)} · trial gap ratio ${fmtValue(r.trial_gap_ratio, 'log2_ratio')}`, })); const dTrial = defs.get('trial_gap_ratio'); const dRes = defs.get('research_gap_ratio'); const dTpd = defs.get('trials_per_1000_deaths'); const dPpd = defs.get('publications_per_1000_deaths'); return (

{scope ? {scope.formula_version} : null} {dTrial ? {dTrial.formula_version} : null} {dRes ? {dRes.formula_version} : null} Methodology Trial Gap Ratio ranking Research Gap Ratio ranking

Burden is currently United States only (CDC WONDER and U.S. Cancer Statistics): global estimates (IARC / GLOBOCAN) remain under license review, so no world scope exists yet. Shares and ratios are relative to the eligible top-level cancers of the scope, not to all cancers.
{!scope ? ( Components require a burden scope (annual deaths per top-level cancer for one geography, year, sex and source) plus trial and literature counters. They are recomputed by pnpm cix intel. ) : ( <> {/* Scope selector (GET params, server-rendered) */}
Scope key {gapScopeKey(scope)} · burden source {sourceBadge}
{/* KPI strip */}
    {[ { label: 'Scope', value: `${scope.geography} · ${scope.year}`, note: `${scope.sex === 'all' ? 'both sexes' : humanize(scope.sex)} · all ages · top-level cancers`, text: true }, { label: 'Eligible cancers', value: fmtInt(sums.eligible), note: `of ${fmtInt(rows.length)} with a mortality observation · deaths ≥ 100` }, { label: 'Σ deaths', value: fmtInt(sums.deaths), note: `${scope.source_slug} · ${scope.year} · eligible set` }, { label: 'Σ active trials', value: fmtInt(sums.activeTrials), note: 'ClinicalTrials.gov · cancer + NCIt descendants' }, { label: 'Σ publications (5 y)', value: fmtInt(sums.publications5y), note: 'PubMed · query-based per entity' }, ].map((k) => (
  • {k.value} {k.label} {k.note}
  • ))}
{/* Scatter 1 */}
Research Gap Ratio ranking → } > {pubPoints.length ? ( ) : ( No eligible cancer in this scope. )}

x: {sourceBadge} mortality_count · y: pubmed publications_5y · size: clinicaltrials active_trials

{/* Scatter 2 */}
Trial Gap Ratio ranking → } >
{trialPoints.length ? : No eligible cancer in this scope.}
{/* Table */}
CSV (with attribution) } >
{sourceBadge} clinicaltrials pubmed {fmtInt(rows.length)} top-level cancers with a {scope.year} mortality observation · {fmtInt(sums.eligible)} eligible
{rows.map((r) => ( ))}
Cancer{sortMark('canonical_name')} Deaths {scope.year} {sortMark('deaths')} Death share{sortMark('death_share')} Active trials{sortMark('active_trials')} Trial share{sortMark('trial_share')} Publications 5 y{sortMark('publications_5y')} Publication share{sortMark('publication_share')} Trial gap ratio{sortMark('trial_gap_ratio')} Research gap ratio{sortMark('research_gap_ratio')} Trials / 1,000 deaths{sortMark('trials_per_1000_deaths')} Pubs / 1,000 deaths{sortMark('publications_per_1000_deaths')} Eligibility Inputs
{r.canonical_name} {r.eligible && (r.trial_gap_rank != null || r.research_gap_rank != null) ? ( #{r.trial_gap_rank ?? '—'} / #{r.research_gap_rank ?? '—'} ) : null} {fmtInt(r.deaths)} {scope.year} {sourceBadge} {fmtPct(r.death_share, 1)} {fmtInt(r.active_trials)} {fmtPct(r.trial_share, 1)} {fmtInt(r.publications_5y)} {fmtPct(r.publication_share, 1)} {fmtValue(r.trials_per_1000_deaths, 'per_1000_deaths')} {fmtValue(r.publications_per_1000_deaths, 'per_1000_deaths')} {r.eligible ? ( eligible ) : ( {r.ineligible_reason?.split(' ')[0]?.replace(/_/g, ' ') ?? 'ineligible'} )}
inputs

component {r.component_id} · {r.formula_version}

{/* Stacked (not the two-column KV): formulas are long and must keep the full width on narrow screens. */}
{[ { k: 'Death share', f: 'deaths / Σ deaths (eligible set)', v: null }, { k: 'Trial share', f: 'active_trials / Σ active_trials (eligible set)', v: null }, { k: 'Publication share', f: 'publications_5y / Σ publications_5y (eligible set)', v: null }, ...[dTrial, dRes, dTpd, dPpd].filter((d): d is NonNullable => !!d).map((d) => ({ k: d.name, f: d.formula, v: d.formula_version })), ].map((it) => (
{it.k}
{it.f} {it.v ? {it.v} : null}
))}
Eligibility
deaths ≥ 100 in the scope; a ratio is undefined (—) when the cancer has no trial / no publication.
Components
{scope.formula_version}

Full methodology {' '} · percentile-based indexes: Trial Gap Index, Research Gap Index.

  • Not a judgement of research quality, funding or difficulty — only registered activity counted against deaths. A positive ratio is a signal to look closer, not an accusation.
  • Trial counts aggregate a cancer and its NCIt descendants; registrations phrased at a broader level (e.g. “colorectal cancer”) are attributed to the broader entity and can overstate the gap of narrower sites.
  • Literature counts are query-based per entity (the query is stored with each count) and are not aggregated over descendants; a narrow query can inflate a Research Gap Ratio.
  • Burden depends on the epidemiology source and its site definitions; the same cancer can have a different ratio under another source or year. Compare only within one table.
  • United States only for now; a US death share is not a global death share.
  • Shares are relative to the eligible set: adding or removing one cancer changes every other cancer’s ratio slightly.
)}
); }