Integration: home modules (explorer, trial intelligence, map, graph), graph links on entity pages, gap card on cancer rankings tab, methodology sections, API docs, QA routes
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
10 changed files +144 −48
modified
apps/web/qa/smoke.mjs
+19 −2
@@ -23,7 +23,7 @@ const TIMEOUT_MS = Number(process.env.TIMEOUT_MS ?? 30000); | ||
| 23 | 23 | const MOBILE_WIDTH = 390; |
| 24 | 24 | const ONLY_HTTP = process.env.HTTP_ONLY === '1'; |
| 25 | 25 | |
| 26 | −/** @type {Array<{path: string, expect: string[], mustHaveData?: boolean, optionalLocal?: boolean, resolve?: (ctx: Record<string,string>) => string | null, kind?: 'html'|'xml'|'json'}>} */ | |
| 26 | +/** @type {Array<{path: string, expect: string[], mustHaveData?: boolean, optionalLocal?: boolean, maxKb?: number, resolve?: (ctx: Record<string,string>) => string | null, kind?: 'html'|'xml'|'json'}>} */ | |
| 27 | 27 | const ROUTES = [ |
| 28 | 28 | { path: '/', expect: ['CancerIndex'] }, |
| 29 | 29 | { path: '/cancers', expect: ['Cancers'] }, |
@@ -38,6 +38,23 @@ const ROUTES = [ | ||
| 38 | 38 | { path: '/drug/osimertinib', expect: ['Osimertinib', 'Approvals'], resolve: (ctx) => ctx.drug ?? '/drug/osimertinib' }, |
| 39 | 39 | { path: '/trial/<first NCT>', expect: ['NCT', 'Conditions'], resolve: (ctx) => ctx.trial ?? null }, |
| 40 | 40 | { path: '/rankings', expect: ['Rankings', 'metric'] }, |
| 41 | + { path: '/rankings/trial_gap_ratio', expect: ['Trial Gap Ratio', 'rank'] }, | |
| 42 | + { path: '/explore', expect: ['Data explorer', 'CSV'], mustHaveData: true }, | |
| 43 | + { path: '/explore/coverage', expect: ['Coverage'] }, | |
| 44 | + { path: '/trials/intelligence', expect: ['intelligence', 'HHI'], mustHaveData: true, maxKb: 900 }, | |
| 45 | + { path: '/trials/terminated', expect: ['NCT'] , maxKb: 900 }, | |
| 46 | + { path: '/trials/map', expect: ['Trial map', 'ISO'], maxKb: 1200 }, | |
| 47 | + { path: '/research-gap', expect: ['Research Gap', 'log'], mustHaveData: true, maxKb: 900 }, | |
| 48 | + { path: '/graph', expect: ['graph', 'CI-CAN-'], maxKb: 900 }, | |
| 49 | + { path: '/graph?focus=gene:KRAS', expect: ['KRAS'], maxKb: 900 }, | |
| 50 | + { path: '/approvals', expect: ['approvals', 'FDA'], maxKb: 900 }, | |
| 51 | + { path: '/pipeline', expect: ['pipeline', 'Phase'], maxKb: 900 }, | |
| 52 | + { path: '/methodology/trial-map', expect: ['ISO'] }, | |
| 53 | + { path: '/api/v1/research-gap', expect: ['data'], kind: 'json', optionalLocal: true }, | |
| 54 | + { path: '/api/v1/trials/intelligence?limit=2', expect: ['data'], kind: 'json', optionalLocal: true }, | |
| 55 | + { path: '/api/v1/epidemiology/metrics', expect: ['data'], kind: 'json', optionalLocal: true }, | |
| 56 | + { path: '/api/v1/approvals/recent?days=365&limit=5', expect: ['data'], kind: 'json', optionalLocal: true }, | |
| 57 | + { path: '/api/v1/graph/gene/TP53?limit=5', expect: ['data'], kind: 'json', optionalLocal: true }, | |
| 41 | 58 | { path: '/rankings/mortality_count', expect: ['mortality', 'rank'] }, |
| 42 | 59 | { path: '/taxonomy', expect: ['Taxonomy'] }, |
| 43 | 60 | { path: '/sources', expect: ['Sources', 'license'] }, |
@@ -153,7 +170,7 @@ async function main() { | ||
| 153 | 170 | const lower = res.text.toLowerCase(); |
| 154 | 171 | for (const key of r.expect) if (!lower.includes(key.toLowerCase())) problems.push(`missing text "${key}"`); |
| 155 | 172 | if (r.kind !== 'json' && r.kind !== 'xml') { |
| 156 | − if (kb > MAX_KB) problems.push(`weight ${kb} KB > ${MAX_KB} KB`); | |
| 173 | + if (kb > (r.maxKb ?? MAX_KB)) problems.push(`weight ${kb} KB > ${r.maxKb ?? MAX_KB} KB`); | |
| 157 | 174 | if (r.mustHaveData && lower.includes('data not yet available')) problems.push('"Data not yet available" on a page that must have data'); |
| 158 | 175 | if (/application error|internal server error|something went wrong rendering/i.test(res.text)) problems.push('error boundary rendered'); |
| 159 | 176 | } |
modified
apps/web/src/app/gene/[symbol]/page.tsx
+2 −0
@@ -14,6 +14,7 @@ import { evidenceForGene, evidenceForGeneCount, evidenceCancersForGene, EVIDENCE | ||
| 14 | 14 | import { loadProvenance } from '@/lib/queries/provenance'; |
| 15 | 15 | import { recentPublicationsFor, recentPublicationsForCount, PUBLICATION_PAGE_SIZE } from '@/lib/queries/publications'; |
| 16 | 16 | import { PublicationList } from '@/components/data/publication-list'; |
| 17 | +import { GraphLink } from '@/components/graph/graph-link'; | |
| 17 | 18 | import { fmtInt, humanize } from '@/lib/format'; |
| 18 | 19 | import { pageInfo } from '@/lib/pagination'; |
| 19 | 20 | import { int, withParams, type SP } from '@/lib/search-params'; |
@@ -58,6 +59,7 @@ export default async function GenePage({ params, searchParams }: { params: Promi | ||
| 58 | 59 | <article> |
| 59 | 60 | <PageHeader kicker="Gene" title={<span className="ci-mono font-sans">{g.symbol}</span>} lede={g.name ?? undefined}> |
| 60 | 61 | <p className="mt-2 flex flex-wrap items-center gap-2 text-[12.5px]"> |
| 62 | + <GraphLink type="gene" ref={g.symbol} /> | |
| 61 | 63 | <span className="ci-mono text-ink-3">{g.id}</span> |
| 62 | 64 | {g.hgnc_id ? ( |
| 63 | 65 | <a className="ci-link inline-flex items-center gap-1" href={`https://www.genenames.org/data/gene-symbol-report/#!/hgnc_id/${g.hgnc_id}`} target="_blank" rel="noopener noreferrer"> |
modified
apps/web/src/app/methodology/page.tsx
+66 −1
@@ -20,7 +20,7 @@ export default async function MethodologyPage() { | ||
| 20 | 20 | <PageHeader kicker="Methodology" title="How the index is built" lede="CancerIndex separates layers — raw, normalized, canonical, derived, ranked — and keeps them separable. This page documents the rules applied at each step and lists every metric with its exact formula and version." /> |
| 21 | 21 | |
| 22 | 22 | <nav aria-label="On this page" className="flex flex-wrap gap-x-4 gap-y-1 border-y border-rule py-2 text-[13px]"> |
| 23 | − {['layers', 'normalization', 'reconciliation', 'hierarchy', 'uncertainty', 'metrics', 'versioning', 'country-scopes', 'cagr', 'compare', 'gap-caveat', 'not-computed', 'limitations'].map((id) => ( | |
| 23 | + {['layers', 'normalization', 'reconciliation', 'hierarchy', 'uncertainty', 'metrics', 'versioning', 'country-scopes', 'cagr', 'compare', 'gap-caveat', 'research-gap', 'trial-intelligence', 'trial-map', 'pipeline', 'knowledge-graph', 'data-explorer', 'not-computed', 'limitations'].map((id) => ( | |
| 24 | 24 | <a key={id} href={`#${id}`} className="ci-link"> |
| 25 | 25 | {id === 'cagr' ? 'CAGR' : humanize(id)} |
| 26 | 26 | </a> |
@@ -206,6 +206,71 @@ export default async function MethodologyPage() { | ||
| 206 | 206 | </ul> |
| 207 | 207 | </Section> |
| 208 | 208 | |
| 209 | + <Section id="research-gap" kicker="§34 · §113" title="Research Gap Index (share-based)"> | |
| 210 | + <p> | |
| 211 | + For one burden scope (geography, year, sex, one epidemiology source) and the eligible top-level cancers (annual deaths ≥ 100), each cancer's <em>share of deaths</em> is compared with its <em>share of active interventional trials</em> and its <em>share of publications over 5 years</em>, all shares summed over the same eligible set. <code>trial_gap_ratio = log₂(death share / trial share)</code>, <code>research_gap_ratio = log₂(death share / publication share)</code>; 0 means the activity share matches the death share, +1 twice as many deaths as the activity would suggest, −1 half. The ratios are scale-free, unlike the percentile indexes above, so they can be read across scopes. The same rows carry <code>trials_per_1000_deaths</code> and <code>publications_per_1000_deaths</code>. A ratio is undefined (not zero) when the activity is 0. | |
| 212 | + </p> | |
| 213 | + <ul> | |
| 214 | + <li>Components are stored per cancer and scope in <code>research_gap_components</code> (formula <code>ci-research-gap-components-v1</code>) with the observation ids, counters and sums that produced them; ranking snapshots <code>trial_gap_ratio</code>, <code>research_gap_ratio</code>, <code>trials_per_1000_deaths</code>, <code>publications_per_1000_deaths</code> are persisted for the preferred source of each scope key.</li> | |
| 215 | + <li>Caveats are those of the percentile indexes (descendant aggregation of trials, query-based literature counts, US-only burden while GLOBOCAN is under review) plus a new one: narrow literature queries make small entities look under-published. Read it as a signal for further inquiry, never as a judgement of research quality or funding.</li> | |
| 216 | + </ul> | |
| 217 | + <p className="mt-2 text-[13px]"> | |
| 218 | + <Link className="ci-link" href="/research-gap">Open the Research Gap page</Link> · full method: <code>docs/methodology/research-gap.md</code>. | |
| 219 | + </p> | |
| 220 | + </Section> | |
| 221 | + | |
| 222 | + <Section id="trial-intelligence" kicker="§10 · §115" title="Clinical trial intelligence"> | |
| 223 | + <p> | |
| 224 | + <code>trial_intelligence</code> (formula <code>ci-trial-intel-v1</code>) is rebuilt daily for every top-level cancer and every malignant entity with at least one mapped study. Studies attach to a cancer through their reconciled conditions over the NCIt descendants (depth ≤ 12, distinct studies); counts use interventional studies and the same active statuses as the counters (recruiting, not yet recruiting, enrolling by invitation, active not recruiting). | |
| 225 | + </p> | |
| 226 | + <ul> | |
| 227 | + <li><strong>Growth</strong>: studies first posted in the last 12 months versus the preceding 12 months; the year-over-year ratio is shown only when the prior window holds ≥ 20 studies.</li> | |
| 228 | + <li><strong>Sponsor and country concentration</strong>: Herfindahl–Hirschman index (sum of squared shares) over active studies, by lead sponsor and by study–country pairs (a multinational study contributes to every country it lists); HHI needs ≥ 10 active studies. Industry share = lead sponsor class INDUSTRY; US share = studies listing a US site.</li> | |
| 229 | + <li><strong>Termination share</strong>: (terminated + withdrawn) / (completed + terminated + withdrawn) over studies first posted since 2010, shown when the denominator is ≥ 30. Stated reasons are classified by explicit keyword rules (<code>ci-stop-reasons-v1</code>; precedence covid › safety › efficacy › drug supply › investigator › enrollment › funding › sponsor decision; text without a match is "other stated"; empty is "not stated") and never inferred.</li> | |
| 230 | + <li><strong>Burden-normalized intensity</strong>: active studies per 1,000 annual deaths and per 100,000 new cases, using the latest US year with both counts from one source (deaths ≥ 100); trial counts are worldwide while the burden is US, which the table states.</li> | |
| 231 | + <li>Four rankings derive from this layer: <code>phase3_recruiting_trials</code>, <code>trial_growth_yoy</code>, <code>trial_termination_share</code>, <code>sponsor_concentration</code>.</li> | |
| 232 | + </ul> | |
| 233 | + <p className="mt-2 text-[13px]"> | |
| 234 | + <Link className="ci-link" href="/trials/intelligence">Trial intelligence table</Link> · <Link className="ci-link" href="/trials/terminated">Terminated studies</Link> · full method: <code>docs/methodology/trial-intelligence.md</code>. | |
| 235 | + </p> | |
| 236 | + </Section> | |
| 237 | + | |
| 238 | + <Section id="trial-map" kicker="§11" title="Clinical trial map"> | |
| 239 | + <p> | |
| 240 | + <code>trial_site_country_counts</code> (formula <code>ci-trial-sites-v1</code>) counts registrant-entered study locations per country for all oncology studies and for each top-level cancer (descendants included), any phase or one phase (a Phase II/III study counts in both), all statuses or recruiting only (location status when present, otherwise the study status). A study with forty US sites weighs forty sites and one study. Country names are mapped to ISO 3166-1 alpha-3 by a tested table; historical names that no longer exist stay unmapped and are listed, not painted. Fill classes are quantiles of the displayed values and the legend states the breaks; the data table below the map is the reference. | |
| 241 | + </p> | |
| 242 | + <p className="mt-2 text-[13px]"> | |
| 243 | + <Link className="ci-link" href="/trials/map">Open the map</Link> · <Link className="ci-link" href="/methodology/trial-map">detailed method page</Link>. | |
| 244 | + </p> | |
| 245 | + </Section> | |
| 246 | + | |
| 247 | + <Section id="pipeline" kicker="§15 · §114 · §119" title="Regulatory records and the drug development pipeline"> | |
| 248 | + <p> | |
| 249 | + An approval record is one authority's decision for one application or product identifier, with jurisdiction, date, status and the indication text as published — never a bare "approved". US records come from openFDA (Drugs@FDA applications and labels); Canadian records come from the Health Canada Drug Product Database (one record per DIN with ATC class, market date and product status; the DPD does not publish indications, so no cancer is attached to these records and none is guessed from the ATC class). <code>drug_pipeline</code> (formula <code>ci-drug-pipeline-v1</code>) then assigns each drug — overall and per top-level cancer reached through trial conditions or approvals — a stage: approved when any approval is current; withdrawn when approvals exist but none is current; otherwise the highest registry phase among its interventional studies (Phase II/III → Phase III), or "phase not stated". Duplicate drug entities (salt forms, shared codes) are proposed to a merge queue, never merged automatically. | |
| 250 | + </p> | |
| 251 | + <p className="mt-2 text-[13px]"> | |
| 252 | + <Link className="ci-link" href="/approvals">Approvals feed</Link> · <Link className="ci-link" href="/pipeline">Pipeline</Link> · full method: <code>docs/methodology/pipeline.md</code>. | |
| 253 | + </p> | |
| 254 | + </Section> | |
| 255 | + | |
| 256 | + <Section id="knowledge-graph" kicker="§18 · §80" title="Knowledge graph"> | |
| 257 | + <p> | |
| 258 | + The graph page shows the neighbourhood of one entity. Two families of links are never merged: <strong>source-native edges</strong> from <code>knowledge_edges</code> (CIViC, ChEMBL, openFDA…) with their native relationship, direction, evidence level, cancer context and provenance, aggregated for display per neighbour, relationship, direction, level and source; and <strong>derived registry links</strong> (dashed) that are counts computed at query time — studies per cancer, alteration frequency per cohort (≥ 5 %, ≥ 20 cases, largest denominator), approvals, drugs investigated in trials. At most 60 nodes are drawn, 25 per relationship group unless expanded; the table under the graph is complete for the expanded groups. CancerIndex never infers an edge and no language model writes into the graph. | |
| 259 | + </p> | |
| 260 | + <p className="mt-2 text-[13px]"> | |
| 261 | + <Link className="ci-link" href="/graph">Open the graph</Link> · full method: <code>docs/methodology/knowledge-graph.md</code>. | |
| 262 | + </p> | |
| 263 | + </Section> | |
| 264 | + | |
| 265 | + <Section id="data-explorer" kicker="§110" title="Data explorer comparability rules"> | |
| 266 | + <p> | |
| 267 | + Observations are only overlaid on one chart when they share metric, unit, geography, source, standard population and age group; otherwise they are drawn as separate charts with a caption explaining the split (for example two registries publishing deaths for the same year, or two standard populations). Series are one cancer × one sex, dashed when the source labels the value as estimated. Defaults (metric, geography, the five cancers shown) are computed from the data present, not curated. Every view has a permalink, a CSV with attribution rows and the equivalent API call, and the observations table lists each value with its year, geography, sex, age, confidence interval, estimate type, site definition and source. | |
| 268 | + </p> | |
| 269 | + <p className="mt-2 text-[13px]"> | |
| 270 | + <Link className="ci-link" href="/explore">Open the Data explorer</Link> · <Link className="ci-link" href="/explore/coverage">Coverage matrix</Link> · full method: <code>docs/methodology/data-explorer.md</code>. | |
| 271 | + </p> | |
| 272 | + </Section> | |
| 273 | + | |
| 209 | 274 | <Section id="not-computed" kicker="Phase 1" title="What is not yet computed"> |
| 210 | 275 | <ul> |
| 211 | 276 | <li>Burden, lethality and gap rankings exist only for scopes with licensed registry observations — currently the United States (CDC U.S. Cancer Statistics), per year and sex, top level. Global scopes wait for IARC / GLOBOCAN (license review) and SEER (credentials); 5-year survival waits for survival observations.</li> |
modified
apps/web/src/app/page.tsx
+18 −45
@@ -7,22 +7,25 @@ import { Section } from '@/components/ui/section'; | ||
| 7 | 7 | import { Badge, ClaimBadge, ConfidenceBadge, StatusBadge } from '@/components/ui/badge'; |
| 8 | 8 | import { getSiteCounts, getRareSpotlight } from '@/lib/queries/stats'; |
| 9 | 9 | import { previewSnapshot } from '@/lib/queries/rankings'; |
| 10 | −import { mostActiveResearch, mostCuratedEvidence } from '@/lib/queries/trials'; | |
| 10 | +import { mostCuratedEvidence } from '@/lib/queries/trials'; | |
| 11 | 11 | import { resolveTopLevel } from '@/lib/queries/cancers'; |
| 12 | 12 | import { listSources } from '@/lib/queries/sources'; |
| 13 | 13 | import { fmtInt, fmtValue, fmtDate, scopeLabel, unitLabel, humanize, parseScopeKey } from '@/lib/format'; |
| 14 | 14 | import { BurdenModule } from '@/components/home/burden-module'; |
| 15 | 15 | import { RisingModule } from '@/components/home/rising-module'; |
| 16 | 16 | import { GapsModule } from '@/components/home/gaps-module'; |
| 17 | +import { TrialIntelModule } from '@/components/home/trial-intel-module'; | |
| 18 | +import { TrialMapModule } from '@/components/home/trial-map-module'; | |
| 19 | +import { GraphModule } from '@/components/home/graph-module'; | |
| 20 | +import { ExplorerModule } from '@/components/home/explorer-module'; | |
| 17 | 21 | |
| 18 | 22 | export const revalidate = 900; |
| 19 | 23 | |
| 20 | 24 | export default async function HomePage() { |
| 21 | − const [counts, spotlight, preview, active, curated, sources] = await Promise.all([ | |
| 25 | + const [counts, spotlight, preview, curated, sources] = await Promise.all([ | |
| 22 | 26 | getSiteCounts(), |
| 23 | 27 | getRareSpotlight(), |
| 24 | 28 | previewSnapshot(['mortality_count', 'incidence_count', 'as_mortality_rate', 'active_trials', 'publications_5y', 'curated_evidence_items']), |
| 25 | − mostActiveResearch(8), | |
| 26 | 29 | mostCuratedEvidence(8), |
| 27 | 30 | listSources(), |
| 28 | 31 | ]); |
@@ -79,6 +82,12 @@ export default async function HomePage() { | ||
| 79 | 82 | {/* Cancer burden (US, latest year) — §105 "Cancer burden today" */} |
| 80 | 83 | <BurdenModule slug="united-states" /> |
| 81 | 84 | |
| 85 | + {/* Data explorer default chart (SPEC §110) */} | |
| 86 | + <ExplorerModule /> | |
| 87 | + | |
| 88 | + {/* Clinical trial intelligence (SPEC §10) */} | |
| 89 | + <TrialIntelModule limit={8} /> | |
| 90 | + | |
| 82 | 91 | {/* Rankings preview */} |
| 83 | 92 | <Section |
| 84 | 93 | id="rankings" |
@@ -152,48 +161,6 @@ export default async function HomePage() { | ||
| 152 | 161 | {/* Largest trial / research gaps (§105, §265-266) */} |
| 153 | 162 | <GapsModule geo="USA" /> |
| 154 | 163 | |
| 155 | − {/* Active research */} | |
| 156 | − <Section id="active-research" kicker="Clinical research" title="Most active clinical research" description="Cancers with the most interventional trials in an active status (recruiting, not yet recruiting, enrolling by invitation, active not recruiting), counted over the entity and its descendants."> | |
| 157 | − {active.length ? ( | |
| 158 | − <> | |
| 159 | − <div className="ci-table-wrap"> | |
| 160 | − <table className="ci-table"> | |
| 161 | − <thead> | |
| 162 | − <tr> | |
| 163 | − <th>Cancer</th> | |
| 164 | − <th className="num">Active trials (count)</th> | |
| 165 | − <th className="num">Recruiting (count)</th> | |
| 166 | − </tr> | |
| 167 | − </thead> | |
| 168 | − <tbody> | |
| 169 | − {active.map((r) => ( | |
| 170 | − <tr key={r.slug}> | |
| 171 | − <td> | |
| 172 | − <Link className="ci-link" href={`/cancer/${r.slug}/trials`}> | |
| 173 | − {r.canonical_name} | |
| 174 | − </Link> | |
| 175 | − </td> | |
| 176 | − <td className="num">{fmtInt(r.active_trial_count)}</td> | |
| 177 | − <td className="num">{fmtInt(r.recruiting_trial_count)}</td> | |
| 178 | − </tr> | |
| 179 | − ))} | |
| 180 | − </tbody> | |
| 181 | − </table> | |
| 182 | − </div> | |
| 183 | − <Freshness dataUpdatedAt={active[0]?.computed_at} extra="source: clinicaltrials · counters refreshed deterministically" /> | |
| 184 | − </> | |
| 185 | − ) : ( | |
| 186 | − <EmptyState> | |
| 187 | − No clinical trials have been ingested yet; trial counters are computed from ClinicalTrials.gov conditions mapped to the taxonomy. | |
| 188 | − <div className="mt-1"> | |
| 189 | − <Link href="/trials" className="ci-link"> | |
| 190 | − Trials explorer | |
| 191 | − </Link> | |
| 192 | − </div> | |
| 193 | − </EmptyState> | |
| 194 | − )} | |
| 195 | − </Section> | |
| 196 | − | |
| 197 | 164 | {/* Curated evidence */} |
| 198 | 165 | <Section id="curated" kicker="Molecular knowledge" title="Most curated molecular evidence" description="Accepted CIViC evidence items whose disease maps to the cancer or its descendants."> |
| 199 | 166 | {curated.length ? ( |
@@ -231,6 +198,12 @@ export default async function HomePage() { | ||
| 231 | 198 | </div> |
| 232 | 199 | |
| 233 | 200 | <aside className="space-y-10"> |
| 201 | + {/* Where trials recruit (SPEC §11) */} | |
| 202 | + <TrialMapModule topN={8} /> | |
| 203 | + | |
| 204 | + {/* Knowledge graph teaser (SPEC §18) */} | |
| 205 | + <GraphModule limit={3} /> | |
| 206 | + | |
| 234 | 207 | {/* Taxonomy at a glance */} |
| 235 | 208 | <Section |
| 236 | 209 | id="taxonomy" |
modified
apps/web/src/app/trial/[nct]/page.tsx
+2 −0
@@ -2,6 +2,7 @@ import type { Metadata } from 'next'; | ||
| 2 | 2 | import Link from 'next/link'; |
| 3 | 3 | import { notFound, permanentRedirect } from 'next/navigation'; |
| 4 | 4 | import { ExternalLink } from 'lucide-react'; |
| 5 | +import { GraphLink } from '@/components/graph/graph-link'; | |
| 5 | 6 | import { PageHeader, Section, KV, Note } from '@/components/ui/section'; |
| 6 | 7 | import { Badge, ClaimBadge, MatchBadge, StatusBadge } from '@/components/ui/badge'; |
| 7 | 8 | import { EmptyState } from '@/components/ui/empty-state'; |
@@ -50,6 +51,7 @@ export default async function TrialPage({ params, searchParams }: { params: Prom | ||
| 50 | 51 | </a> |
| 51 | 52 | <SourceBadge p={{ sourceSlug: 'clinicaltrials', sourceName: 'ClinicalTrials.gov', retrievedAt: t.updated_at, ingestRunId: t.ingest_run_id, layer: 'normalized' }} /> |
| 52 | 53 | <ClaimBadge kind="published" /> |
| 54 | + <GraphLink type="trial" ref={t.nct_id} /> | |
| 53 | 55 | </div> |
| 54 | 56 | {t.why_stopped ? <Note tone="warn">Why stopped (as posted): {t.why_stopped}</Note> : null} |
| 55 | 57 | </PageHeader> |
modified
apps/web/src/app/variant/[slug]/page.tsx
+2 −0
@@ -2,6 +2,7 @@ import type { Metadata } from 'next'; | ||
| 2 | 2 | import Link from 'next/link'; |
| 3 | 3 | import { notFound } from 'next/navigation'; |
| 4 | 4 | import { ExternalLink } from 'lucide-react'; |
| 5 | +import { GraphLink } from '@/components/graph/graph-link'; | |
| 5 | 6 | import { PageHeader, Section, KV, Note } from '@/components/ui/section'; |
| 6 | 7 | import { Badge, ClaimBadge } from '@/components/ui/badge'; |
| 7 | 8 | import { EmptyState } from '@/components/ui/empty-state'; |
@@ -45,6 +46,7 @@ export default async function VariantPage({ params, searchParams }: { params: Pr | ||
| 45 | 46 | <PageHeader kicker={`Variant${v.variant_type ? ` · ${humanize(v.variant_type)}` : ''}`} title={<>{v.gene_symbol ? <Link href={`/gene/${v.gene_symbol}`} className="ci-mono font-sans text-ink no-underline hover:text-accent">{v.gene_symbol}</Link> : null} {v.name}</>}> |
| 46 | 47 | <p className="mt-2 flex flex-wrap items-center gap-2 text-[12.5px]"> |
| 47 | 48 | <span className="ci-mono text-ink-3">{v.id}</span> |
| 49 | + <GraphLink type="variant" ref={v.slug} /> | |
| 48 | 50 | {v.hgvs_p ? <span className="ci-mono">{v.hgvs_p}</span> : null} |
| 49 | 51 | {v.hgvs_c ? <span className="ci-mono">{v.hgvs_c}</span> : null} |
| 50 | 52 | {v.clinvar_variation_id ? ( |
modified
apps/web/src/components/cancer/header.tsx
+2 −0
@@ -3,6 +3,7 @@ import { ExternalLink } from 'lucide-react'; | ||
| 3 | 3 | import { Badge } from '@/components/ui/badge'; |
| 4 | 4 | import { Breadcrumbs } from '@/components/layout/breadcrumbs'; |
| 5 | 5 | import { Tabs } from '@/components/ui/tabs'; |
| 6 | +import { GraphLink } from '@/components/graph/graph-link'; | |
| 6 | 7 | import { CODE_SYSTEM_LABEL, codeUrl } from '@/lib/site'; |
| 7 | 8 | import { humanize, fmtInt } from '@/lib/format'; |
| 8 | 9 | import { TABS, type CancerBundle, type TabKey } from './load'; |
@@ -46,6 +47,7 @@ export function CancerHeader({ b, tab }: { b: CancerBundle; tab: TabKey }) { | ||
| 46 | 47 | <span className="ci-mono text-ink-2">{c.id}</span> |
| 47 | 48 | {c.short_name && c.short_name !== c.canonical_name ? <span>{c.short_name}</span> : null} |
| 48 | 49 | {abbreviations.length ? <span>{abbreviations.slice(0, 6).join(' · ')}</span> : null} |
| 50 | + <GraphLink type="cancer" ref={c.slug} /> | |
| 49 | 51 | </p> |
| 50 | 52 | {badges.length ? ( |
| 51 | 53 | <div className="mt-2 flex flex-wrap gap-1.5"> |
modified
apps/web/src/components/cancer/tabs/rankings.tsx
+2 −0
@@ -6,6 +6,7 @@ import { JsonView } from '@/components/ui/json-view'; | ||
| 6 | 6 | import { rankingsForCancer } from '@/lib/queries/rankings'; |
| 7 | 7 | import { fmtDate, fmtInt, fmtValue, scopeLabel, unitLabel } from '@/lib/format'; |
| 8 | 8 | import { latestYearPerScope } from '@/lib/rankings-util'; |
| 9 | +import { CancerGapCard } from '@/components/research-gap/cancer-gap-card'; | |
| 9 | 10 | import type { CancerBundle } from '../load'; |
| 10 | 11 | |
| 11 | 12 | function Delta({ rank, prev }: { rank: number; prev: number | null }) { |
@@ -108,6 +109,7 @@ export async function RankingsTab({ b }: { b: CancerBundle }) { | ||
| 108 | 109 | </p> |
| 109 | 110 | </Section> |
| 110 | 111 | <Note>"Deadliest" is ambiguous: annual deaths, age-standardized mortality rate, mortality-to-incidence ratio and 5-year survival rank cancers differently. CancerIndex publishes each as a separate metric.</Note> |
| 112 | + <CancerGapCard cancerId={b.cancer.id} /> | |
| 111 | 113 | </div> |
| 112 | 114 | ); |
| 113 | 115 | } |
modified
docs/API.md
+8 −0
@@ -91,6 +91,14 @@ Payload objects that carry a number also carry the category (`observed_data`, `c | ||
| 91 | 91 | | `GET /v1/sources` · `GET /v1/sources/:slug` | registry, license status, connector health, runs, counts | |
| 92 | 92 | | `GET /v1/stats` | live counts (60 s cache) | |
| 93 | 93 | | `GET /v1/changes` | change events; `entityType`, `entityId`, `kind`, `since` | |
| 94 | +| `GET /v1/epidemiology` · `/epidemiology/coverage` · `/epidemiology/metrics` | time-aware observations with provenance (`metric`, `cancer` ≤ 8, `geography`, `sex`, `age`, `from`, `to`, `source`, `estimateType`); coverage matrix; metrics present | | |
| 95 | +| `GET /v1/trials/intelligence` · `/trials/intelligence/:cancer` | derived per-cancer trial metrics (`level=top|all`, `sort`, `order`, `minActive`): counts, growth YoY, enrollment, sponsor/country HHI, termination share, trials per 1,000 deaths — formula `ci-trial-intel-v1` | | |
| 96 | +| `GET /v1/trials/terminated` | terminated / withdrawn / suspended studies with registrant-stated reason and keyword category (`cancer`, `reason`, `status`, `since`) | | |
| 97 | +| `GET /v1/trials/sites` | country (or city) aggregates of trial sites (`cancer` top-level, `phase`, `recruiting`, `level=country|city`) | | |
| 98 | +| `GET /v1/research-gap` · `/research-gap/scopes` | Research Gap components per burden scope (`geography`, `year`, `sex`, `source`): deaths, trials, publications, shares, log₂ ratios, per-1,000-deaths intensities | | |
| 99 | +| `GET /v1/approvals` · `/approvals/recent` | jurisdiction-aware approval records (`authority`, `jurisdiction`, `cancer`, `drug`, `status`, `from`, `to`, `q`); recent feed grouped by month | | |
| 100 | +| `GET /v1/pipeline` · `/pipeline/summary` | drug development stage per drug / per drug × top-level cancer (`cancer`, `stage`, `drug`) — formula `ci-drug-pipeline-v1` | | |
| 101 | +| `GET /v1/graph/:type/:id` · `/graph/cancer/:id/paths` | knowledge-graph neighbourhood (source-native edges + derived registry links; `limit`, `rel`, `context`, `includeDerived`) and cancer → gene → variant → drug → approval → trial chains | | |
| 94 | 102 | | `GET/POST /v1/admin/*` | operators only (`x-admin-token`): connectors, run/pause/resume, runs/:runId, unresolved + resolve, trace, jobs/counters, jobs/rank, audit | |
| 95 | 103 | |
| 96 | 104 | ## Examples |
modified
docs/METHODOLOGY.md
+23 −0
@@ -179,3 +179,26 @@ lake path). The same lineage is available to operators with `pnpm cix trace <tab | ||
| 179 | 179 | deprecations with before/after snapshots and the ingest run that caused them |
| 180 | 180 | (`GET /v1/changes`). Ranking rows keep `previous_rank`, and every response states its |
| 181 | 181 | `dataRelease` (`CancerIndex YYYY-MM`) and `generatedAt`. |
| 182 | + | |
| 183 | + | |
| 184 | +## 6. Intelligence layer (wave 3, 2026-09-11) | |
| 185 | + | |
| 186 | +Derived tables recomputed daily by the worker (`maintenance.intel`, 06:15 UTC, between counters and | |
| 187 | +rankings) or by hand with `pnpm cix intel`. Each has its own method page with formulas, thresholds | |
| 188 | +and caveats; every row stores `formula_version` and an `inputs` JSON. | |
| 189 | + | |
| 190 | +| Module | Table(s) | Formula version | Method | | |
| 191 | +|---|---|---|---| | |
| 192 | +| Clinical trial intelligence (counts, growth, enrollment, sponsor & country concentration, termination share and stop reasons, burden-normalized intensity) | `trial_intelligence` | `ci-trial-intel-v1`, stop-reason rules `ci-stop-reasons-v1` | [docs/methodology/trial-intelligence.md](methodology/trial-intelligence.md) | | |
| 193 | +| Clinical trial map (country aggregates of registrant-entered sites) | `trial_site_country_counts` | `ci-trial-sites-v1` | [docs/methodology/trial-map.md](methodology/trial-map.md) | | |
| 194 | +| Research Gap Index (death share vs trial / publication share, log₂ ratios, per-1,000-deaths intensities) | `research_gap_components` + snapshots `trial_gap_ratio`, `research_gap_ratio`, `trials_per_1000_deaths`, `publications_per_1000_deaths` | `ci-research-gap-components-v1` | [docs/methodology/research-gap.md](methodology/research-gap.md) | | |
| 195 | +| Drug development pipeline (stage per drug and per drug × top-level cancer; duplicate-drug proposals) | `drug_pipeline`, `entity_merges` | `ci-drug-pipeline-v1` | [docs/methodology/pipeline.md](methodology/pipeline.md) | | |
| 196 | +| Knowledge graph (contextual neighbourhoods; source-native edges vs derived registry links) | query-time over `knowledge_edges` + relations | — (no derived numbers stored) | [docs/methodology/knowledge-graph.md](methodology/knowledge-graph.md) | | |
| 197 | +| Data explorer (comparability groups, computed defaults, permalinks, CSV) | query-time over `epidemiology_observations` | — | [docs/methodology/data-explorer.md](methodology/data-explorer.md) | | |
| 198 | + | |
| 199 | +Trial interventions are reconciled to canonical drugs by `reconcileInterventionDrugs` | |
| 200 | +(`packages/connectors/src/connectors/clinicaltrials/drugs.ts`): exact normalized alias → salt / dose / | |
| 201 | +label-stripped alias → probabilistic head match, each recorded as its own `match_type`; aliases shared | |
| 202 | +by several drugs are resolved only by a deterministic preference (own generic name > brand > base | |
| 203 | +molecule) and otherwise left unresolved. Salt-form duplicate drugs are *proposed* to `entity_merges`, | |
| 204 | +never merged automatically. | |
| 182 | 205 | |