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 { Download } from 'lucide-react';4import { PageHeader, Section, Note } from '@/components/ui/section';5import { Badge, ClaimBadge } from '@/components/ui/badge';6import { EmptyState } from '@/components/ui/empty-state';7import { Freshness } from '@/components/ui/freshness';8import { SourceBadge } from '@/components/ui/source-badge';9import { JsonView } from '@/components/ui/json-view';10import { ScatterChart, type ScatterPoint } from '@/components/charts/scatter-chart';11import { listGapScopes, pickGapScope, gapComponents, gapSums, gapScopeKey, gapMetricDefs, type GapComponent } from '@/lib/queries/research-gap';12import { fmtInt, fmtNum, fmtPct, fmtValue, fmtDateTime, humanize } from '@/lib/format';13import { str, int, oneOf, withParams, type SP } from '@/lib/search-params';1415export const dynamic = 'force-dynamic';1617export const metadata: Metadata = {18 title: 'Research Gap Index — burden vs research activity',19 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.',20};2122const 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;23type SortKey = (typeof SORT_KEYS)[number];2425function sortRows(rows: GapComponent[], key: SortKey, dir: 'asc' | 'desc'): GapComponent[] {26 const sgn = dir === 'asc' ? 1 : -1;27 return [...rows].sort((a, b) => {28 // Ineligible rows always sink to the bottom.29 if (a.eligible !== b.eligible) return a.eligible ? -1 : 1;30 if (key === 'canonical_name') return sgn * a.canonical_name.localeCompare(b.canonical_name);31 const av = a[key];32 const bv = b[key];33 if (av == null && bv == null) return a.canonical_name.localeCompare(b.canonical_name);34 if (av == null) return 1;35 if (bv == null) return -1;36 return sgn * (Number(av) - Number(bv)) || a.canonical_name.localeCompare(b.canonical_name);37 });38}3940/** Short display label for chart points: the entity's short name, else the canonical name without the "Malignant … Neoplasm" wrapper. */41function chartLabel(r: { canonical_name: string; short_name: string | null }): string {42 if (r.short_name) return r.short_name;43 return r.canonical_name.replace(/^Malignant\s+/i, '').replace(/\s+Neoplasm$/i, '');44}4546function Ratio({ v }: { v: number | null }) {47 if (v == null) return <span className="text-ink-4">—</span>;48 const cls = v >= 1 ? 'text-danger font-medium' : v > 0 ? 'text-ink' : 'text-ink-3';49 return <span className={cls}>{fmtValue(v, 'log2_ratio')}</span>;50}5152export default async function ResearchGapPage({ searchParams }: { searchParams: Promise<SP> }) {53 const sp = await searchParams;54 const scopes = await listGapScopes();55 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') };56 const scope = pickGapScope(scopes, want);57 const sort = oneOf(sp, 'sort', SORT_KEYS, 'research_gap_ratio');58 const dir = oneOf(sp, 'dir', ['asc', 'desc'] as const, sort === 'canonical_name' ? 'asc' : 'desc');59 const [rowsRaw, defs] = scope ? await Promise.all([gapComponents(scope), gapMetricDefs()]) : [[] as GapComponent[], new Map()];60 const rows = sortRows(rowsRaw, sort, dir);61 const eligible = rows.filter((r) => r.eligible);62 const sums = gapSums(rows);63 const current = { geography: scope?.geography ?? want.geography, year: scope?.year ?? null, sex: scope?.sex ?? want.sex, source: scope?.source_slug ?? null, sort, dir };64 const sortLink = (key: SortKey) => withParams(current, { sort: key, dir: sort === key ? (dir === 'desc' ? 'asc' : 'desc') : key === 'canonical_name' ? 'asc' : 'desc' });65 const sortMark = (key: SortKey) => (sort === key ? (dir === 'desc' ? ' ▼' : ' ▲') : '');66 const geographies = [...new Set(scopes.map((s) => s.geography))];67 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);68 const sexesFor = (geo: string) => [...new Set(scopes.filter((s) => s.geography === geo).map((s) => s.sex))];69 const sourcesFor = (geo: string, year: number | null, sex: string) => scopes.filter((s) => s.geography === geo && Number(s.year) === year && s.sex === sex);70 const pubSlope = sums.deaths > 0 ? sums.publications5y / sums.deaths : 0;71 const trialSlope = sums.deaths > 0 ? sums.activeTrials / sums.deaths : 0;72 const cancerHref = (r: GapComponent) => `/cancer/${r.slug}/rankings`;73 const sourceBadge = scope ? <SourceBadge p={{ sourceSlug: scope.source_slug, sourceName: scope.source_name, dataset: `mortality_count · ${scope.geography} · ${scope.year} · ${scope.sex}`, layer: 'normalized' }} compact /> : null;74 const pubPoints: ScatterPoint[] = eligible.map((r) => ({75 id: r.cancer_id,76 label: chartLabel(r),77 href: cancerHref(r),78 x: Number(r.deaths),79 y: Number(r.publications_5y),80 size: Number(r.active_trials),81 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')}`,82 }));83 const trialPoints: ScatterPoint[] = eligible.map((r) => ({84 id: r.cancer_id,85 label: chartLabel(r),86 href: cancerHref(r),87 x: Number(r.deaths),88 y: Number(r.active_trials),89 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')}`,90 }));91 const dTrial = defs.get('trial_gap_ratio');92 const dRes = defs.get('research_gap_ratio');93 const dTpd = defs.get('trials_per_1000_deaths');94 const dPpd = defs.get('publications_per_1000_deaths');9596 return (97 <div>98 <PageHeader99 kicker="Unmet need · computed index"100 title="Research Gap Index"101 lede="How a cancer's share of deaths compares with its share of registered research activity — active interventional trials (ClinicalTrials.gov) and publications of the last five years (PubMed) — within one burden scope. It measures where registered activity is thin relative to mortality; it does not measure the quality, funding or difficulty of research, and it inherits every limit of the burden source."102 >103 <p className="mt-2 flex flex-wrap items-center gap-2 text-[12.5px] text-ink-3">104 <ClaimBadge kind="computed" />105 {scope ? <span className="ci-mono">{scope.formula_version}</span> : null}106 {dTrial ? <span className="ci-mono">{dTrial.formula_version}</span> : null}107 {dRes ? <span className="ci-mono">{dRes.formula_version}</span> : null}108 <Link href="/methodology#research-gap" className="ci-link">109 Methodology110 </Link>111 <Link href="/rankings/trial_gap_ratio" className="ci-link">112 Trial Gap Ratio ranking113 </Link>114 <Link href="/rankings/research_gap_ratio" className="ci-link">115 Research Gap Ratio ranking116 </Link>117 </p>118 <Note>119 Burden is currently <strong>United States only</strong> (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.120 </Note>121 </PageHeader>122123 {!scope ? (124 <EmptyState title="Research gap components not yet computed" knows={[{ label: 'Percentile-based Trial Gap Index', href: '/rankings/trial_gap' }, { label: 'Percentile-based Research Gap Index', href: '/rankings/research_gap' }, { label: 'Methodology', href: '/methodology#research-gap' }]}>125 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 <code className="ci-mono">pnpm cix intel</code>.126 </EmptyState>127 ) : (128 <>129 {/* Scope selector (GET params, server-rendered) */}130 <form method="get" action="/research-gap" className="ci-rule flex flex-wrap items-end gap-3 pt-4 text-[13px]" aria-label="Burden scope">131 <label className="flex flex-col gap-0.5">132 <span className="ci-kicker">Geography</span>133 <select name="geography" defaultValue={scope.geography} className="border border-rule bg-paper px-2 py-1">134 {geographies.map((g) => (135 <option key={g} value={g}>136 {g}137 </option>138 ))}139 </select>140 </label>141 <label className="flex flex-col gap-0.5">142 <span className="ci-kicker">Sex</span>143 <select name="sex" defaultValue={scope.sex} className="border border-rule bg-paper px-2 py-1">144 {sexesFor(scope.geography).map((s) => (145 <option key={s} value={s}>146 {s === 'all' ? 'both sexes' : humanize(s)}147 </option>148 ))}149 </select>150 </label>151 <label className="flex flex-col gap-0.5">152 <span className="ci-kicker">Year</span>153 <select name="year" defaultValue={String(scope.year)} className="border border-rule bg-paper px-2 py-1">154 {yearsFor(scope.geography, scope.sex).map((y) => (155 <option key={y} value={y}>156 {y}157 </option>158 ))}159 </select>160 </label>161 <label className="flex flex-col gap-0.5">162 <span className="ci-kicker">Burden source</span>163 <select name="source" defaultValue={scope.source_slug} className="border border-rule bg-paper px-2 py-1">164 {sourcesFor(scope.geography, Number(scope.year), scope.sex).map((s) => (165 <option key={s.source_slug} value={s.source_slug}>166 {s.source_slug} ({s.n_eligible} eligible)167 </option>168 ))}169 </select>170 </label>171 <input type="hidden" name="sort" value={sort} />172 <input type="hidden" name="dir" value={dir} />173 <button type="submit" className="border border-rule-strong px-3 py-1 text-ink hover:border-accent hover:text-accent">174 Apply175 </button>176 <span className="text-[12px] text-ink-3">177 Scope key <span className="ci-mono">{gapScopeKey(scope)}</span> · burden source {sourceBadge}178 </span>179 </form>180181 {/* KPI strip */}182 <section aria-label="Scope totals" className="mt-5">183 <ul className="grid grid-cols-2 gap-x-6 gap-y-4 sm:grid-cols-5">184 {[185 { label: 'Scope', value: `${scope.geography} · ${scope.year}`, note: `${scope.sex === 'all' ? 'both sexes' : humanize(scope.sex)} · all ages · top-level cancers`, text: true },186 { label: 'Eligible cancers', value: fmtInt(sums.eligible), note: `of ${fmtInt(rows.length)} with a mortality observation · deaths ≥ 100` },187 { label: 'Σ deaths', value: fmtInt(sums.deaths), note: `${scope.source_slug} · ${scope.year} · eligible set` },188 { label: 'Σ active trials', value: fmtInt(sums.activeTrials), note: 'ClinicalTrials.gov · cancer + NCIt descendants' },189 { label: 'Σ publications (5 y)', value: fmtInt(sums.publications5y), note: 'PubMed · query-based per entity' },190 ].map((k) => (191 <li key={k.label}>192 <span className={`block font-display text-ink ${k.text ? 'text-xl sm:text-2xl' : 'ci-num text-2xl sm:text-3xl'}`}>{k.value}</span>193 <span className="block text-[12.5px] font-medium text-ink-2">{k.label}</span>194 <span className="block text-[11.5px] text-ink-3">{k.note}</span>195 </li>196 ))}197 </ul>198 <Freshness dataUpdatedAt={scope.computed_at} extra={`components ${scope.formula_version} · sums are over the eligible set only`} />199 </section>200201 {/* Scatter 1 */}202 <Section203 id="deaths-vs-publications"204 kicker="Burden vs literature"205 title="Annual deaths vs publications of the last five years"206 description={`Each bubble is one eligible top-level cancer; bubble area is proportional to its active interventional trials. The dashed line is the scope's overall intensity (${fmtNum(pubSlope * 1000, 0)} publications per 1,000 deaths): cancers below it have a positive Research Gap Ratio — fewer publications than their share of deaths would suggest — and cancers above it a negative one.`}207 className="mt-8"208 actions={209 <Link href={`/rankings/research_gap_ratio?scope=${encodeURIComponent(gapScopeKey(scope))}`} className="ci-link">210 Research Gap Ratio ranking →211 </Link>212 }213 >214 {pubPoints.length ? (215 <ScatterChart points={pubPoints} xLabel={`Annual deaths, ${scope.year}`} yLabel="Publications, last 5 years" sizeLabel="Active trials" ariaLabel={`Scatter of annual deaths against publications for ${pubPoints.length} eligible top-level cancers, ${scope.geography} ${scope.year}`} reference={{ slope: pubSlope, label: `Scope average: ${fmtNum(pubSlope * 1000, 0)} publications per 1,000 deaths (Research Gap Ratio = 0)` }} width={880} height={460} />216 ) : (217 <EmptyState compact>No eligible cancer in this scope.</EmptyState>218 )}219 <p className="mt-1.5 flex flex-wrap items-center gap-2 text-[11.5px] text-ink-3">220 <ClaimBadge kind="computed" />221 <span>222 x: {sourceBadge} mortality_count · y: <Link href="/source/pubmed" className="ci-src">pubmed</Link> publications_5y · size: <Link href="/source/clinicaltrials" className="ci-src">clinicaltrials</Link> active_trials223 </span>224 </p>225 </Section>226227 {/* Scatter 2 */}228 <Section229 id="deaths-vs-trials"230 kicker="Burden vs clinical research"231 title="Annual deaths vs active interventional trials"232 description={`Dashed line: ${fmtNum(trialSlope * 1000, 1)} active trials per 1,000 deaths (Trial Gap Ratio = 0). Below the line: positive Trial Gap Ratio.`}233 className="mt-8"234 actions={235 <Link href={`/rankings/trial_gap_ratio?scope=${encodeURIComponent(gapScopeKey(scope))}`} className="ci-link">236 Trial Gap Ratio ranking →237 </Link>238 }239 >240 <div className="max-w-3xl">241 {trialPoints.length ? <ScatterChart points={trialPoints} xLabel={`Annual deaths, ${scope.year}`} yLabel="Active interventional trials" ariaLabel={`Scatter of annual deaths against active trials for ${trialPoints.length} eligible top-level cancers, ${scope.geography} ${scope.year}`} reference={{ slope: trialSlope, label: `Scope average: ${fmtNum(trialSlope * 1000, 1)} active trials per 1,000 deaths (Trial Gap Ratio = 0)` }} width={640} height={320} /> : <EmptyState compact>No eligible cancer in this scope.</EmptyState>}242 </div>243 </Section>244245 {/* Table */}246 <Section247 id="components"248 kicker="Components"249 title="Deaths, activity, shares and gap ratios per cancer"250 description="Click a column header to sort (the order is kept in the URL). Ineligible cancers are listed last with the reason. Shares are the cancer's fraction of the eligible set's total; ratios are log₂(death share ÷ activity share): 0 = proportional, +1 = twice the death share, −1 = half."251 className="mt-8"252 actions={253 <a href={`/api/export/research-gap.csv${withParams({ geography: scope.geography, year: scope.year, sex: scope.sex, source: scope.source_slug }, {})}`} className="inline-flex items-center gap-1.5 border border-rule px-2.5 py-1 text-[13px] text-ink no-underline hover:border-accent hover:text-accent">254 <Download className="h-3.5 w-3.5" aria-hidden /> CSV (with attribution)255 </a>256 }257 >258 <div className="mb-2 flex flex-wrap items-center gap-2 text-[12px] text-ink-3">259 {sourceBadge}260 <Link href="/source/clinicaltrials" className="ci-src">261 clinicaltrials262 </Link>263 <Link href="/source/pubmed" className="ci-src">264 pubmed265 </Link>266 <ClaimBadge kind="computed" />267 <span>268 {fmtInt(rows.length)} top-level cancers with a {scope.year} mortality observation · {fmtInt(sums.eligible)} eligible269 </span>270 </div>271 <div className="ci-table-wrap">272 <table className="ci-table">273 <thead>274 <tr>275 <th>276 <Link href={sortLink('canonical_name')} className="no-underline">277 Cancer{sortMark('canonical_name')}278 </Link>279 </th>280 <th className="num">281 <Link href={sortLink('deaths')} className="no-underline" title={`mortality_count · ${scope.source_slug} · ${scope.year}`}>282 Deaths {scope.year}283 {sortMark('deaths')}284 </Link>285 </th>286 <th className="num">287 <Link href={sortLink('death_share')} className="no-underline">288 Death share{sortMark('death_share')}289 </Link>290 </th>291 <th className="num">292 <Link href={sortLink('active_trials')} className="no-underline">293 Active trials{sortMark('active_trials')}294 </Link>295 </th>296 <th className="num">297 <Link href={sortLink('trial_share')} className="no-underline">298 Trial share{sortMark('trial_share')}299 </Link>300 </th>301 <th className="num">302 <Link href={sortLink('publications_5y')} className="no-underline">303 Publications 5 y{sortMark('publications_5y')}304 </Link>305 </th>306 <th className="num">307 <Link href={sortLink('publication_share')} className="no-underline">308 Publication share{sortMark('publication_share')}309 </Link>310 </th>311 <th className="num">312 <Link href={sortLink('trial_gap_ratio')} className="no-underline" title={dTrial?.formula}>313 Trial gap ratio{sortMark('trial_gap_ratio')}314 </Link>315 </th>316 <th className="num">317 <Link href={sortLink('research_gap_ratio')} className="no-underline" title={dRes?.formula}>318 Research gap ratio{sortMark('research_gap_ratio')}319 </Link>320 </th>321 <th className="num">322 <Link href={sortLink('trials_per_1000_deaths')} className="no-underline" title={dTpd?.formula}>323 Trials / 1,000 deaths{sortMark('trials_per_1000_deaths')}324 </Link>325 </th>326 <th className="num">327 <Link href={sortLink('publications_per_1000_deaths')} className="no-underline" title={dPpd?.formula}>328 Pubs / 1,000 deaths{sortMark('publications_per_1000_deaths')}329 </Link>330 </th>331 <th>Eligibility</th>332 <th>Inputs</th>333 </tr>334 </thead>335 <tbody>336 {rows.map((r) => (337 <tr key={r.cancer_id} className={r.eligible ? '' : 'text-ink-3'}>338 <td className="min-w-[200px]">339 <Link className="ci-link" href={cancerHref(r)}>340 {r.canonical_name}341 </Link>342 {r.eligible && (r.trial_gap_rank != null || r.research_gap_rank != null) ? (343 <span className="ml-1.5 text-[11px] text-ink-3" title="Rank in the Trial Gap Ratio / Research Gap Ratio rankings of this scope (1 = largest gap)">344 #{r.trial_gap_rank ?? '—'} / #{r.research_gap_rank ?? '—'}345 </span>346 ) : null}347 </td>348 <td className="num">349 <span className="ci-num">{fmtInt(r.deaths)}</span>350 <span className="ml-1 text-[11px] text-ink-3">351 {scope.year} {sourceBadge}352 </span>353 </td>354 <td className="num">{fmtPct(r.death_share, 1)}</td>355 <td className="num">{fmtInt(r.active_trials)}</td>356 <td className="num">{fmtPct(r.trial_share, 1)}</td>357 <td className="num">{fmtInt(r.publications_5y)}</td>358 <td className="num">{fmtPct(r.publication_share, 1)}</td>359 <td className="num">360 <Ratio v={r.trial_gap_ratio} />361 </td>362 <td className="num">363 <Ratio v={r.research_gap_ratio} />364 </td>365 <td className="num">{fmtValue(r.trials_per_1000_deaths, 'per_1000_deaths')}</td>366 <td className="num">{fmtValue(r.publications_per_1000_deaths, 'per_1000_deaths')}</td>367 <td>368 {r.eligible ? (369 <Badge tone="ok" title="deaths ≥ 100; ratios require ≥ 1 trial / ≥ 1 publication">370 eligible371 </Badge>372 ) : (373 <Badge tone="outline" title={r.ineligible_reason ?? undefined}>374 {r.ineligible_reason?.split(' ')[0]?.replace(/_/g, ' ') ?? 'ineligible'}375 </Badge>376 )}377 </td>378 <td>379 <details>380 <summary className="ci-link text-[12.5px]">inputs</summary>381 <div className="mt-1 max-w-[380px]">382 <JsonView data={r.inputs} />383 <p className="ci-mono mt-1 text-[10.5px] text-ink-4">384 component {r.component_id} · {r.formula_version}385 </p>386 </div>387 </details>388 </td>389 </tr>390 ))}391 </tbody>392 </table>393 </div>394 <Freshness dataUpdatedAt={scope.computed_at} extra={`computed ${fmtDateTime(scope.computed_at)} · ${scope.formula_version}`} />395 </Section>396397 <div className="mt-8 grid gap-6 lg:grid-cols-[1fr_1fr]">398 <Section id="formulas" kicker="Formulas" title="How each column is computed" level={3}>399 {/* Stacked (not the two-column KV): formulas are long and must keep the full width on narrow screens. */}400 <dl className="space-y-2.5 text-[13px]">401 {[402 { k: 'Death share', f: 'deaths / Σ deaths (eligible set)', v: null },403 { k: 'Trial share', f: 'active_trials / Σ active_trials (eligible set)', v: null },404 { k: 'Publication share', f: 'publications_5y / Σ publications_5y (eligible set)', v: null },405 ...[dTrial, dRes, dTpd, dPpd].filter((d): d is NonNullable<typeof d> => !!d).map((d) => ({ k: d.name, f: d.formula, v: d.formula_version })),406 ].map((it) => (407 <div key={it.k}>408 <dt className="text-ink-3">{it.k}</dt>409 <dd className="min-w-0 break-words">410 <code className="ci-mono text-[12px]">{it.f}</code>411 {it.v ? <span className="ci-mono ml-1.5 text-[11px] text-ink-3">{it.v}</span> : null}412 </dd>413 </div>414 ))}415 <div>416 <dt className="text-ink-3">Eligibility</dt>417 <dd>deaths ≥ 100 in the scope; a ratio is undefined (—) when the cancer has no trial / no publication.</dd>418 </div>419 <div>420 <dt className="text-ink-3">Components</dt>421 <dd className="ci-mono">{scope.formula_version}</dd>422 </div>423 </dl>424 <p className="mt-3 text-[12px] text-ink-3">425 <Link href="/methodology#research-gap" className="ci-link">426 Full methodology427 </Link>{' '}428 · percentile-based indexes: <Link href="/rankings/trial_gap" className="ci-link">Trial Gap Index</Link>, <Link href="/rankings/research_gap" className="ci-link">Research Gap Index</Link>.429 </p>430 </Section>431 <Section id="caveats" kicker="Read with care" title="What this index is not" level={3}>432 <ul className="list-disc space-y-1.5 pl-5 text-[13px] text-ink-2">433 <li>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.</li>434 <li>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.</li>435 <li>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.</li>436 <li>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.</li>437 <li>United States only for now; a US death share is not a global death share.</li>438 <li>Shares are relative to the eligible set: adding or removing one cancer changes every other cancer’s ratio slightly.</li>439 </ul>440 </Section>441 </div>442 </>443 )}444 </div>445 );446}447