SPB Git

spb/cancerindex

Public

Knowledge graph: contextual neighbourhoods (source-native edges + derived registry links), radial SVG explorer /graph, cancer→gene→variant→drug→approval→trial paths, /v1/graph API, methodology

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Simon-Pierre Boucher committed (Sep 11, 2026) parent 33aae95 Browse files

11 changed files +2,904 −5

modified apps/api/src/routes/graph.ts +620 −5
@@ -1,10 +1,625 @@
1+import { sql, type SQL } from 'drizzle-orm';
1 import type { FastifyPluginAsyncZod } from 'fastify-type-provider-zod'; 2 import type { FastifyPluginAsyncZod } from 'fastify-type-provider-zod';
3+import { z } from 'zod';
4+import type { Database } from '@cancerindex/database';
5+import { descendantIds } from '../lib/descendants.js';
6+import { BadRequest } from '../lib/errors.js';
7+import { boolQuery } from '../lib/pagination.js';
8+import { resolveCancer, resolveDrug, resolveGene, resolveTrial, resolveVariant } from '../lib/resolve.js';
9+import { AnyRecord, num, ok, respond } from '../lib/respond.js';
2 10
3 /** 11 /**
4− * Knowledge-graph routes (SPEC §18, §80): `GET /graph/:type/:id` → contextual neighbours of one 12+ * Knowledge-graph routes (SPEC §18, §80).
5− * entity (cancer | gene | variant | drug | biomarker | trial) with relationship type, cancer context, 13+ *
6− * evidence level and provenance on every edge. Filled by the Knowledge Graph work package. 14+ * GET /graph/:type/:id?limit=&rel=&context=&includeDerived= → contextual neighbourhood of one entity
15+ * GET /graph/:type/:id/paths → cancer → gene → variant → drug → approval → trials chains (cancer only)
16+ *
17+ * Two families of links, never merged: source-native `knowledge_edges` rows (CIViC, ChEMBL, openFDA)
18+ * kept with their native evidence level, direction and cancer context, aggregated per
19+ * (neighbour, relationship, direction, level, source); and `derived: true` registry counts computed
20+ * at query time (trial_conditions, trial_interventions, cancer_gene_frequencies, drug_approvals,
21+ * civic_evidence_items). CancerIndex never infers an edge.
22+ *
23+ * The SQL is intentionally duplicated from `apps/web/src/lib/queries/graph.ts`: that module is
24+ * `server-only` and bound to the Next.js database helpers, so it cannot be imported here. Keep the
25+ * two in step when changing thresholds or ordering.
7 */ 26 */
8−export const graphRoutes: FastifyPluginAsyncZod = async (_app) => { 27+
9− /* routes added by the knowledge-graph work package */ 28+const TYPES = ['cancer', 'gene', 'variant', 'drug', 'trial'] as const;
29+type FocusType = (typeof TYPES)[number];
30+type NodeType = FocusType | 'approval';
31+
32+const ACTIVE = ['RECRUITING', 'NOT_YET_RECRUITING', 'ENROLLING_BY_INVITATION', 'ACTIVE_NOT_RECRUITING'];
33+const FREQ_MIN = 0.05;
34+const CASES_MIN = 20;
35+const TRIAL_LIMIT = 10;
36+const MAX_DESCENDANTS = 600;
37+const SRC = { clinicaltrials: 'CI-SOURCE-00000004', civic: 'CI-SOURCE-00000006' } as const;
38+
39+const inList = (ids: string[]): SQL => sql`(${sql.join(ids.map((i) => sql`${i}`), sql`, `)})`;
40+const activeList = (): SQL => sql`(${sql.join(ACTIVE.map((s) => sql`${s}`), sql`, `)})`;
41+const LEVEL_RANK = sql`CASE upper(coalesce(ke.evidence_level, '')) WHEN 'A' THEN 0 WHEN 'FDA ORIG' THEN 0 WHEN 'B' THEN 1 WHEN '4' THEN 1 WHEN 'C' THEN 2 WHEN '3' THEN 2 WHEN 'D' THEN 3 WHEN '2' THEN 3 WHEN 'E' THEN 4 WHEN '1' THEN 4 WHEN '' THEN 99 ELSE 50 END`;
42+const CIVIC_LEVEL_RANK = sql`CASE e.evidence_level WHEN 'A' THEN 0 WHEN 'B' THEN 1 WHEN 'C' THEN 2 WHEN 'D' THEN 3 WHEN 'E' THEN 4 ELSE 99 END`;
43+
44+interface Node {
45+ type: NodeType;
46+ id: string;
47+ ref: string | null;
48+ label: string;
49+ sublabel?: string | null;
50+ href: string;
51+}
52+interface Edge {
53+ relationshipType: string;
54+ outgoing: boolean;
55+ direction: string | null;
56+ evidenceLevel: string | null;
57+ evidenceCategory: string;
58+ cancerContext: Array<{ id: string; name: string; slug: string }>;
59+ supportCount: number;
60+ sourceIds: string[];
61+ provenanceIds: number[];
62+ derived: boolean;
63+ detail?: string | null;
64+ date?: string | null;
65+ via?: { type: NodeType; id: string; label: string; href: string } | null;
66+}
67+interface Link {
68+ node: Node;
69+ edge: Edge;
70+}
71+interface Derived {
72+ relationshipType: string;
73+ total: number;
74+ links: Link[];
75+}
76+
77+const href = (type: NodeType, ref: string): string => (type === 'cancer' ? `/cancer/${ref}` : type === 'gene' ? `/gene/${ref}` : type === 'variant' ? `/variant/${ref}` : type === 'drug' ? `/drug/${ref}` : type === 'trial' ? `/trial/${ref}` : ref);
78+const pct = (v: number) => `${(v * 100).toFixed(v >= 0.1 ? 0 : 1)} %`;
79+
80+async function resolveFocus(db: Database, type: FocusType, ref: string): Promise<Node & { ref: string }> {
81+ switch (type) {
82+ case 'cancer': {
83+ const c = await resolveCancer(db, ref);
84+ const r = await db.execute<{ canonical_name: string; entity_type: string }>(sql`SELECT canonical_name, entity_type FROM cancers WHERE id = ${c.id}`);
85+ return { type, id: c.id, ref: c.slug, label: r[0]?.canonical_name ?? c.slug, sublabel: r[0]?.entity_type ?? null, href: href(type, c.slug) };
86+ }
87+ case 'gene': {
88+ const g = await resolveGene(db, ref);
89+ const r = await db.execute<{ name: string | null }>(sql`SELECT name FROM genes WHERE id = ${g.id}`);
90+ return { type, id: g.id, ref: g.symbol, label: g.symbol, sublabel: r[0]?.name ?? null, href: href(type, g.symbol) };
91+ }
92+ case 'variant': {
93+ const v = await resolveVariant(db, ref);
94+ const r = await db.execute<{ label: string; variant_type: string | null }>(sql`SELECT coalesce(gene_symbol || ' ', '') || name AS label, variant_type FROM variants WHERE id = ${v.id}`);
95+ return { type, id: v.id, ref: v.slug, label: r[0]?.label ?? v.slug, sublabel: r[0]?.variant_type ?? null, href: href(type, v.slug) };
96+ }
97+ case 'drug': {
98+ const d = await resolveDrug(db, ref);
99+ const r = await db.execute<{ name: string; kind: string | null }>(sql`SELECT name, kind FROM drugs WHERE id = ${d.id}`);
100+ return { type, id: d.id, ref: d.slug, label: r[0]?.name ?? d.slug, sublabel: r[0]?.kind ?? null, href: href(type, d.slug) };
101+ }
102+ case 'trial': {
103+ const t = await resolveTrial(db, ref);
104+ const r = await db.execute<{ brief_title: string; overall_status: string | null }>(sql`SELECT brief_title, overall_status FROM clinical_trials WHERE id = ${t.id}`);
105+ return { type, id: t.id, ref: t.nctId, label: r[0]?.brief_title ?? t.nctId, sublabel: r[0]?.overall_status ?? null, href: href(type, t.nctId) };
106+ }
107+ }
108+}
109+
110+// ------------------------------------------------------------------ source-native edges
111+
112+type KeRow = {
113+ relationship_type: string;
114+ outgoing: boolean;
115+ ctx_only: boolean;
116+ n_type: NodeType;
117+ n_id: string;
118+ n_ref: string | null;
119+ n_label: string | null;
120+ n_sublabel: string | null;
121+ via_type: NodeType | null;
122+ via_id: string | null;
123+ via_ref: string | null;
124+ via_label: string | null;
125+ direction: string | null;
126+ evidence_level: string | null;
127+ evidence_category: string;
128+ source_id: string;
129+ support: string;
130+ edge_ids: number[];
131+ provenance_ids: number[];
132+ context_ids: string[];
133+ last_seen: Date | null;
134+ total: string;
135+};
136+
137+async function knowledgeEdges(db: Database, focus: Node, limit: number, rel: string | null, contextId: string | null): Promise<KeRow[]> {
138+ const t = focus.type;
139+ const id = focus.id;
140+ const ctx = t === 'cancer' ? sql`OR (${id} = ANY(ke.cancer_context_ids) AND ke.source_entity_id <> ${id} AND ke.target_entity_id <> ${id})` : sql``;
141+ const relF = rel ? sql`AND ke.relationship_type = ${rel}` : sql``;
142+ const ctxF = contextId ? sql`AND ${contextId} = ANY(ke.cancer_context_ids)` : sql``;
143+ return db.execute<KeRow>(sql`
144+ WITH e AS (
145+ SELECT ke.relationship_type,
146+ (ke.source_entity_type = ${t} AND ke.source_entity_id = ${id}) AS outgoing,
147+ (ke.source_entity_id <> ${id} AND ke.target_entity_id <> ${id}) AS ctx_only,
148+ CASE WHEN ke.source_entity_id = ${id} THEN ke.target_entity_type ELSE ke.source_entity_type END AS n_type,
149+ CASE WHEN ke.source_entity_id = ${id} THEN ke.target_entity_id ELSE ke.source_entity_id END AS n_id,
150+ CASE WHEN ke.source_entity_id <> ${id} AND ke.target_entity_id <> ${id} THEN ke.target_entity_type END AS via_type,
151+ CASE WHEN ke.source_entity_id <> ${id} AND ke.target_entity_id <> ${id} THEN ke.target_entity_id END AS via_id,
152+ ke.direction, ke.evidence_level, ke.evidence_category, ke.source_id, ke.id, ke.provenance_ids, ke.cancer_context_ids, ke.support_count, ke.last_seen_at, ${LEVEL_RANK} AS lvl
153+ FROM knowledge_edges ke
154+ WHERE ke.status = 'active' AND ((ke.source_entity_type = ${t} AND ke.source_entity_id = ${id}) OR (ke.target_entity_type = ${t} AND ke.target_entity_id = ${id}) ${ctx}) ${relF} ${ctxF}
155+ ), a AS (
156+ SELECT relationship_type, outgoing, ctx_only, n_type, n_id, via_type, via_id, direction, evidence_level, evidence_category, source_id, min(lvl) AS lvl,
157+ sum(support_count) AS support, array_agg(id ORDER BY id) AS edge_ids,
158+ (SELECT array_agg(DISTINCT x::int ORDER BY x::int) FROM unnest(string_to_array(string_agg(array_to_string(provenance_ids, ','), ','), ',')) x WHERE x <> '') AS provenance_ids,
159+ (SELECT array_agg(DISTINCT x ORDER BY x) FROM unnest(string_to_array(string_agg(array_to_string(cancer_context_ids, ','), ','), ',')) x WHERE x <> '') AS context_ids,
160+ max(last_seen_at) AS last_seen
161+ FROM e GROUP BY 1,2,3,4,5,6,7,8,9,10,11
162+ ), r AS (
163+ SELECT a.*, row_number() OVER (PARTITION BY a.relationship_type ORDER BY a.lvl, a.support DESC, a.last_seen DESC NULLS LAST, a.n_id, a.via_id) AS rn,
164+ count(*) OVER (PARTITION BY a.relationship_type) AS total
165+ FROM a
166+ )
167+ SELECT r.relationship_type, r.outgoing, r.ctx_only, r.n_type, r.n_id, r.via_type, r.via_id, r.direction, r.evidence_level, r.evidence_category, r.source_id,
168+ r.support, r.edge_ids, r.provenance_ids, r.context_ids, r.last_seen, r.total,
169+ coalesce(c.slug, g.symbol, v.slug, d.slug) AS n_ref,
170+ coalesce(c.canonical_name, g.symbol, coalesce(v.gene_symbol || ' ', '') || v.name, d.name) AS n_label,
171+ coalesce(c.entity_type, g.name, v.variant_type, d.kind) AS n_sublabel,
172+ coalesce(vc.slug, vg.symbol, vv.slug, vd.slug) AS via_ref,
173+ coalesce(vc.canonical_name, vg.symbol, coalesce(vv.gene_symbol || ' ', '') || vv.name, vd.name) AS via_label
174+ FROM r
175+ LEFT JOIN cancers c ON r.n_type = 'cancer' AND c.id = r.n_id
176+ LEFT JOIN genes g ON r.n_type = 'gene' AND g.id = r.n_id
177+ LEFT JOIN variants v ON r.n_type = 'variant' AND v.id = r.n_id
178+ LEFT JOIN drugs d ON r.n_type = 'drug' AND d.id = r.n_id
179+ LEFT JOIN cancers vc ON r.via_type = 'cancer' AND vc.id = r.via_id
180+ LEFT JOIN genes vg ON r.via_type = 'gene' AND vg.id = r.via_id
181+ LEFT JOIN variants vv ON r.via_type = 'variant' AND vv.id = r.via_id
182+ LEFT JOIN drugs vd ON r.via_type = 'drug' AND vd.id = r.via_id
183+ WHERE r.rn <= ${limit}
184+ ORDER BY r.relationship_type, r.rn`);
185+}
186+
187+// ------------------------------------------------------------------ derived registry links
188+
189+const total = (rows: Array<{ total: string }>) => (rows.length ? num(rows[0]!.total) : 0);
190+
191+async function frequencyLinks(db: Database, side: 'cancer' | 'gene', focus: Node, ids: string[], limit: number): Promise<Derived> {
192+ type Row = { gene_id: string; symbol: string; is_cancer_gene: boolean; cancer_id: string; cancer_slug: string; cancer_name: string; alteration_type: string; cases_affected: number; cases_profiled: number; frequency: number; study_id: string; source_id: string; provenance_id: number; cohorts: string; total: string };
193+ const where = side === 'cancer' ? sql`f.cancer_id IN ${inList(ids)} AND f.gene_id IS NOT NULL` : sql`f.gene_id = ${focus.id} AND f.cancer_id IS NOT NULL`;
194+ const part = side === 'cancer' ? sql`f.gene_id` : sql`f.cancer_id`;
195+ const order = side === 'cancer' ? sql`g.is_cancer_gene DESC, f.frequency DESC, g.symbol` : sql`f.frequency DESC, c.canonical_name`;
196+ const rows = await db.execute<Row>(sql`
197+ WITH f AS (
198+ SELECT f.gene_id, f.cancer_id, f.alteration_type, f.cases_affected, f.cases_profiled, f.frequency, f.provenance_id, co.study_id, co.source_id,
199+ count(*) OVER (PARTITION BY ${part}) AS cohorts, row_number() OVER (PARTITION BY ${part} ORDER BY f.cases_profiled DESC, f.frequency DESC, f.id) AS rn
200+ FROM cancer_gene_frequencies f JOIN genomic_cohorts co ON co.id = f.cohort_id
201+ WHERE ${where} AND f.frequency >= ${FREQ_MIN} AND f.cases_affected >= ${CASES_MIN}
202+ )
203+ SELECT f.*, g.symbol, g.is_cancer_gene, c.slug AS cancer_slug, c.canonical_name AS cancer_name, count(*) OVER() AS total
204+ FROM f JOIN genes g ON g.id = f.gene_id JOIN cancers c ON c.id = f.cancer_id WHERE f.rn = 1 ORDER BY ${order} LIMIT ${limit}`);
205+ return {
206+ relationshipType: 'ALTERED_IN',
207+ total: total(rows),
208+ links: rows.map((r) => ({
209+ node: side === 'cancer' ? { type: 'gene', id: r.gene_id, ref: r.symbol, label: r.symbol, sublabel: r.is_cancer_gene ? 'cancer gene' : null, href: href('gene', r.symbol) } : { type: 'cancer', id: r.cancer_id, ref: r.cancer_slug, label: r.cancer_name, href: href('cancer', r.cancer_slug) },
210+ edge: {
211+ relationshipType: 'ALTERED_IN',
212+ outgoing: side === 'gene',
213+ direction: null,
214+ evidenceLevel: null,
215+ evidenceCategory: 'observed_data',
216+ cancerContext: [{ id: r.cancer_id, name: r.cancer_name, slug: r.cancer_slug }],
217+ supportCount: num(r.cohorts),
218+ sourceIds: [r.source_id],
219+ provenanceIds: [r.provenance_id],
220+ derived: true,
221+ detail: `${r.cases_affected} / ${r.cases_profiled} cases (${pct(r.frequency)}) · ${r.alteration_type} · ${r.study_id}`,
222+ frequency: r.frequency,
223+ casesAffected: r.cases_affected,
224+ casesProfiled: r.cases_profiled,
225+ cohorts: num(r.cohorts),
226+ } as Edge,
227+ })),
228+ };
229+}
230+
231+const trialNode = (r: { id: string; nct_id: string; overall_status: string | null; phases: string[] }): Node => ({ type: 'trial', id: r.id, ref: r.nct_id, label: r.nct_id, sublabel: [r.phases.join('/'), r.overall_status].filter(Boolean).join(' · ') || null, href: href('trial', r.nct_id) });
232+
233+async function cancerTrialLinks(db: Database, focus: Node, ids: string[], limit: number): Promise<Derived> {
234+ type Row = { id: string; nct_id: string; brief_title: string; overall_status: string | null; phases: string[]; last_update_posted_date: string | null; cancer_id: string; cancer_slug: string; cancer_name: string; match_type: string; total: string; active: string };
235+ const rows = await db.execute<Row>(sql`
236+ WITH m AS (SELECT DISTINCT ON (tc.trial_id) tc.trial_id, tc.cancer_id, tc.match_type FROM trial_conditions tc WHERE tc.cancer_id IN ${inList(ids)} ORDER BY tc.trial_id, (tc.cancer_id = ${focus.id}) DESC, tc.id)
237+ SELECT t.id, t.nct_id, t.brief_title, t.overall_status, t.phases, t.last_update_posted_date, m.cancer_id, c.slug AS cancer_slug, c.canonical_name AS cancer_name, m.match_type,
238+ count(*) OVER() AS total, count(*) FILTER (WHERE t.overall_status IN ${activeList()}) OVER() AS active
239+ FROM m JOIN clinical_trials t ON t.id = m.trial_id JOIN cancers c ON c.id = m.cancer_id
240+ ORDER BY t.last_update_posted_date DESC NULLS LAST, t.nct_id LIMIT ${limit}`);
241+ const tot = total(rows);
242+ const active = rows.length ? num(rows[0]!.active) : 0;
243+ return {
244+ relationshipType: 'STUDIED_IN',
245+ total: tot,
246+ links: rows.map((r) => ({
247+ node: trialNode(r),
248+ edge: { relationshipType: 'STUDIED_IN', outgoing: true, direction: null, evidenceLevel: null, evidenceCategory: 'observed_data', cancerContext: [{ id: r.cancer_id, name: r.cancer_name, slug: r.cancer_slug }], supportCount: 1, sourceIds: [SRC.clinicaltrials], provenanceIds: [], derived: true, detail: `${r.brief_title} · condition mapped ${r.match_type}`, date: r.last_update_posted_date, trialsTotal: tot, trialsActive: active } as Edge,
249+ })),
250+ };
251+}
252+
253+async function cancerDrugTrialLinks(db: Database, focus: Node, ids: string[], limit: number): Promise<Derived> {
254+ type Row = { drug_id: string; slug: string; name: string; kind: string | null; trials: string; active: string; last: string | null; total: string };
255+ const rows = await db.execute<Row>(sql`
256+ SELECT ti.drug_id, d.slug, d.name, d.kind, count(DISTINCT ti.trial_id) AS trials, count(DISTINCT ti.trial_id) FILTER (WHERE t.overall_status IN ${activeList()}) AS active, max(t.last_update_posted_date) AS last, count(*) OVER() AS total
257+ FROM trial_conditions tc JOIN trial_interventions ti ON ti.trial_id = tc.trial_id AND ti.drug_id IS NOT NULL JOIN clinical_trials t ON t.id = tc.trial_id JOIN drugs d ON d.id = ti.drug_id
258+ WHERE tc.cancer_id IN ${inList(ids)} GROUP BY ti.drug_id, d.slug, d.name, d.kind ORDER BY trials DESC, d.name LIMIT ${limit}`);
259+ return {
260+ relationshipType: 'INVESTIGATED_IN_TRIALS',
261+ total: total(rows),
262+ links: rows.map((r) => ({
263+ node: { type: 'drug', id: r.drug_id, ref: r.slug, label: r.name, sublabel: r.kind, href: href('drug', r.slug) },
264+ edge: { relationshipType: 'INVESTIGATED_IN_TRIALS', outgoing: false, direction: null, evidenceLevel: null, evidenceCategory: 'observed_data', cancerContext: [{ id: focus.id, name: focus.label, slug: focus.ref ?? '' }], supportCount: num(r.trials), sourceIds: [SRC.clinicaltrials], provenanceIds: [], derived: true, detail: `${num(r.trials)} trials (${num(r.active)} active) · roll-up of the cancer and its descendants`, date: r.last, trialsTotal: num(r.trials), trialsActive: num(r.active) } as Edge,
265+ })),
266+ };
267+}
268+
269+async function approvalLinks(db: Database, side: 'cancer' | 'drug', focus: Node, ids: string[], limit: number): Promise<Derived> {
270+ type Row = { id: number; drug_id: string; drug_slug: string; drug_name: string; kind: string | null; cancer_id: string | null; cancer_slug: string | null; cancer_name: string | null; tumor_agnostic: boolean; jurisdiction: string; authority: string; indication: string; approval_date: string | null; status: string; source_id: string; provenance_id: number; total: string };
271+ const where = side === 'cancer' ? sql`a.cancer_id IN ${inList(ids)} AND NOT EXISTS (SELECT 1 FROM knowledge_edges ke WHERE ke.relationship_type = 'APPROVED_FOR' AND ke.source_entity_id = a.drug_id AND ke.target_entity_id = ${focus.id})` : sql`a.drug_id = ${focus.id} AND (a.cancer_id IS NULL OR NOT EXISTS (SELECT 1 FROM knowledge_edges ke WHERE ke.relationship_type = 'APPROVED_FOR' AND ke.source_entity_id = a.drug_id AND ke.target_entity_id = a.cancer_id))`;
272+ const rows = await db.execute<Row>(sql`
273+ SELECT a.id, a.drug_id, d.slug AS drug_slug, d.name AS drug_name, d.kind, a.cancer_id, c.slug AS cancer_slug, c.canonical_name AS cancer_name, a.tumor_agnostic, a.jurisdiction, a.authority, a.indication, a.approval_date, a.status, a.source_id, a.provenance_id, count(*) OVER() AS total
274+ FROM drug_approvals a JOIN drugs d ON d.id = a.drug_id LEFT JOIN cancers c ON c.id = a.cancer_id WHERE ${where} ORDER BY a.approval_date DESC NULLS LAST, a.id LIMIT ${limit}`);
275+ return {
276+ relationshipType: 'APPROVED_FOR',
277+ total: total(rows),
278+ links: rows.map((r) => {
279+ const node: Node =
280+ side === 'cancer'
281+ ? { type: 'drug', id: r.drug_id, ref: r.drug_slug, label: r.drug_name, sublabel: r.kind, href: href('drug', r.drug_slug) }
282+ : r.cancer_id && r.cancer_slug && r.cancer_name
283+ ? { type: 'cancer', id: r.cancer_id, ref: r.cancer_slug, label: r.cancer_name, href: href('cancer', r.cancer_slug) }
284+ : { type: 'approval', id: `approval:${r.id}`, ref: null, label: `${r.authority} · ${r.jurisdiction}${r.approval_date ? ` · ${r.approval_date.slice(0, 4)}` : ''}`, sublabel: r.indication, href: `/drug/${r.drug_slug}#approvals` };
285+ return {
286+ node,
287+ edge: { relationshipType: 'APPROVED_FOR', outgoing: side === 'drug', direction: null, evidenceLevel: r.status, evidenceCategory: 'regulatory_status', cancerContext: r.cancer_id && r.cancer_slug && r.cancer_name ? [{ id: r.cancer_id, name: r.cancer_name, slug: r.cancer_slug }] : [], supportCount: 1, sourceIds: [r.source_id], provenanceIds: [r.provenance_id], derived: true, detail: `${r.authority} (${r.jurisdiction}) · ${r.status}${r.tumor_agnostic ? ' · tumour-agnostic' : ''}`, date: r.approval_date, jurisdiction: r.jurisdiction, authority: r.authority, status: r.status, indication: r.indication, tumorAgnostic: r.tumor_agnostic } as Edge,
288+ };
289+ }),
290+ };
291+}
292+
293+async function geneVariantLinks(db: Database, focus: Node, limit: number): Promise<Derived> {
294+ type Row = { id: string; slug: string; name: string; variant_type: string | null; ev: string; context_ids: string[] | null; provenance_ids: number[] | null; total: string };
295+ const rows = await db.execute<Row>(sql`
296+ WITH ev AS (
297+ SELECT vid, count(*) AS ev, (array_agg(DISTINCT e.cancer_id) FILTER (WHERE e.cancer_id IS NOT NULL))[1:5] AS context_ids, (array_agg(DISTINCT e.provenance_id))[1:20] AS provenance_ids
298+ FROM civic_evidence_items e CROSS JOIN LATERAL unnest(e.variant_ids) vid WHERE e.status = 'ACCEPTED' AND (${focus.id} = ANY(e.gene_ids) OR ${focus.label} = ANY(e.gene_symbols)) GROUP BY vid
299+ )
300+ SELECT v.id, v.slug, v.name, v.variant_type, coalesce(ev.ev, 0) AS ev, ev.context_ids, ev.provenance_ids, count(*) OVER() AS total
301+ FROM variants v LEFT JOIN ev ON ev.vid = v.id WHERE v.gene_id = ${focus.id} ORDER BY coalesce(ev.ev, 0) DESC, v.name LIMIT ${limit}`);
302+ return {
303+ relationshipType: 'HAS_VARIANT',
304+ total: total(rows),
305+ links: rows.map((r) => ({
306+ node: { type: 'variant', id: r.id, ref: r.slug, label: r.name, sublabel: r.variant_type, href: href('variant', r.slug) },
307+ edge: { relationshipType: 'HAS_VARIANT', outgoing: true, direction: null, evidenceLevel: null, evidenceCategory: num(r.ev) > 0 ? 'curated_evidence' : 'observed_data', cancerContext: (r.context_ids ?? []).map((id) => ({ id, name: id, slug: '' })), supportCount: num(r.ev), sourceIds: [SRC.civic], provenanceIds: r.provenance_ids ?? [], derived: true, detail: `${num(r.ev)} accepted CIViC evidence items` } as Edge,
308+ })),
309+ };
310+}
311+
312+async function variantEvidenceLinks(db: Database, focus: Node, limit: number): Promise<Derived> {
313+ type Row = { cancer_id: string; slug: string; name: string; items: string; levels: string[] | null; best: string | null; sens: string; res: string; supports: string; does_not: string; provenance_ids: number[]; total: string };
314+ const rows = await db.execute<Row>(sql`
315+ SELECT e.cancer_id, c.slug, c.canonical_name AS name, count(*) AS items, array_agg(DISTINCT e.evidence_level ORDER BY e.evidence_level) FILTER (WHERE e.evidence_level IS NOT NULL) AS levels, min(e.evidence_level) AS best,
316+ count(*) FILTER (WHERE e.significance ILIKE '%SENSITIV%') AS sens, count(*) FILTER (WHERE e.significance ILIKE '%RESIST%') AS res,
317+ count(*) FILTER (WHERE e.evidence_direction = 'SUPPORTS') AS supports, count(*) FILTER (WHERE e.evidence_direction = 'DOES_NOT_SUPPORT') AS does_not,
318+ (array_agg(DISTINCT e.provenance_id))[1:50] AS provenance_ids, count(*) OVER() AS total
319+ FROM civic_evidence_items e JOIN cancers c ON c.id = e.cancer_id WHERE e.status = 'ACCEPTED' AND ${focus.id} = ANY(e.variant_ids)
320+ GROUP BY e.cancer_id, c.slug, c.canonical_name ORDER BY min(${CIVIC_LEVEL_RANK}), items DESC, c.canonical_name LIMIT ${limit}`);
321+ return {
322+ relationshipType: 'HAS_EVIDENCE_IN',
323+ total: total(rows),
324+ links: rows.map((r) => {
325+ const sens = num(r.sens);
326+ const res = num(r.res);
327+ return {
328+ node: { type: 'cancer', id: r.cancer_id, ref: r.slug, label: r.name, href: href('cancer', r.slug) },
329+ edge: { relationshipType: 'HAS_EVIDENCE_IN', outgoing: true, direction: sens && res ? 'mixed' : sens ? 'sensitivity' : res ? 'resistance' : num(r.supports) && !num(r.does_not) ? 'supports' : num(r.does_not) ? 'does not support' : null, evidenceLevel: r.best, evidenceCategory: 'curated_evidence', cancerContext: [{ id: r.cancer_id, name: r.name, slug: r.slug }], supportCount: num(r.items), sourceIds: [SRC.civic], provenanceIds: r.provenance_ids ?? [], derived: true, levels: r.levels ?? [], sensitivity: sens, resistance: res, supports: num(r.supports), doesNotSupport: num(r.does_not) } as Edge,
330+ };
331+ }),
332+ };
333+}
334+
335+async function variantDrugCivicLinks(db: Database, focus: Node, limit: number): Promise<Derived> {
336+ type Row = { drug_id: string; slug: string; name: string; kind: string | null; items: string; best: string | null; sens: string; res: string; context_ids: string[] | null; provenance_ids: number[]; total: string };
337+ const rows = await db.execute<Row>(sql`
338+ SELECT tid AS drug_id, d.slug, d.name, d.kind, count(*) AS items, min(e.evidence_level) AS best, count(*) FILTER (WHERE e.significance ILIKE '%SENSITIV%') AS sens, count(*) FILTER (WHERE e.significance ILIKE '%RESIST%') AS res,
339+ (array_agg(DISTINCT e.cancer_id) FILTER (WHERE e.cancer_id IS NOT NULL))[1:5] AS context_ids, (array_agg(DISTINCT e.provenance_id))[1:50] AS provenance_ids, count(*) OVER() AS total
340+ FROM civic_evidence_items e CROSS JOIN LATERAL unnest(e.therapy_ids) tid JOIN drugs d ON d.id = tid
341+ WHERE e.status = 'ACCEPTED' AND e.evidence_type = 'PREDICTIVE' AND ${focus.id} = ANY(e.variant_ids)
342+ AND NOT EXISTS (SELECT 1 FROM knowledge_edges ke WHERE ke.relationship_type = 'PREDICTS_RESPONSE_TO' AND ke.source_entity_id = ${focus.id} AND ke.target_entity_id = tid)
343+ GROUP BY tid, d.slug, d.name, d.kind ORDER BY min(${CIVIC_LEVEL_RANK}), items DESC, d.name LIMIT ${limit}`);
344+ return {
345+ relationshipType: 'PREDICTS_RESPONSE_TO',
346+ total: total(rows),
347+ links: rows.map((r) => {
348+ const sens = num(r.sens);
349+ const res = num(r.res);
350+ return {
351+ node: { type: 'drug', id: r.drug_id, ref: r.slug, label: r.name, sublabel: r.kind, href: href('drug', r.slug) },
352+ edge: { relationshipType: 'PREDICTS_RESPONSE_TO', outgoing: true, direction: sens && res ? 'mixed' : sens ? 'sensitivity' : res ? 'resistance' : null, evidenceLevel: r.best, evidenceCategory: 'curated_evidence', cancerContext: (r.context_ids ?? []).map((id) => ({ id, name: id, slug: '' })), supportCount: num(r.items), sourceIds: [SRC.civic], provenanceIds: r.provenance_ids ?? [], derived: true, detail: `${num(r.items)} accepted predictive items aggregated from CIViC (no knowledge edge yet)` } as Edge,
353+ };
354+ }),
355+ };
356+}
357+
358+async function drugTrialLinks(db: Database, focus: Node, limit: number): Promise<Derived> {
359+ type Row = { id: string; nct_id: string; brief_title: string; overall_status: string | null; phases: string[]; last_update_posted_date: string | null; context_ids: string[] | null; total: string; active: string };
360+ const rows = await db.execute<Row>(sql`
361+ SELECT t.id, t.nct_id, t.brief_title, t.overall_status, t.phases, t.last_update_posted_date,
362+ (SELECT (array_agg(DISTINCT tc.cancer_id))[1:5] FROM trial_conditions tc WHERE tc.trial_id = t.id AND tc.cancer_id IS NOT NULL) AS context_ids,
363+ count(*) OVER() AS total, count(*) FILTER (WHERE t.overall_status IN ${activeList()}) OVER() AS active
364+ FROM (SELECT DISTINCT trial_id FROM trial_interventions WHERE drug_id = ${focus.id}) ti JOIN clinical_trials t ON t.id = ti.trial_id
365+ ORDER BY t.last_update_posted_date DESC NULLS LAST, t.nct_id LIMIT ${limit}`);
366+ const tot = total(rows);
367+ const active = rows.length ? num(rows[0]!.active) : 0;
368+ return {
369+ relationshipType: 'STUDIED_IN',
370+ total: tot,
371+ links: rows.map((r) => ({
372+ node: trialNode(r),
373+ edge: { relationshipType: 'STUDIED_IN', outgoing: true, direction: null, evidenceLevel: null, evidenceCategory: 'observed_data', cancerContext: (r.context_ids ?? []).map((id) => ({ id, name: id, slug: '' })), supportCount: 1, sourceIds: [SRC.clinicaltrials], provenanceIds: [], derived: true, detail: r.brief_title, date: r.last_update_posted_date, trialsTotal: tot, trialsActive: active } as Edge,
374+ })),
375+ };
376+}
377+
378+async function drugCancerTrialLinks(db: Database, focus: Node, limit: number): Promise<Derived> {
379+ type Row = { cancer_id: string; slug: string; name: string; trials: string; active: string; last: string | null; total: string };
380+ const rows = await db.execute<Row>(sql`
381+ SELECT tc.cancer_id, c.slug, c.canonical_name AS name, count(DISTINCT tc.trial_id) AS trials, count(DISTINCT tc.trial_id) FILTER (WHERE t.overall_status IN ${activeList()}) AS active, max(t.last_update_posted_date) AS last, count(*) OVER() AS total
382+ FROM trial_interventions ti JOIN trial_conditions tc ON tc.trial_id = ti.trial_id AND tc.cancer_id IS NOT NULL JOIN clinical_trials t ON t.id = ti.trial_id JOIN cancers c ON c.id = tc.cancer_id
383+ WHERE ti.drug_id = ${focus.id} GROUP BY tc.cancer_id, c.slug, c.canonical_name ORDER BY trials DESC, c.canonical_name LIMIT ${limit}`);
384+ return {
385+ relationshipType: 'INVESTIGATED_IN_TRIALS',
386+ total: total(rows),
387+ links: rows.map((r) => ({
388+ node: { type: 'cancer', id: r.cancer_id, ref: r.slug, label: r.name, href: href('cancer', r.slug) },
389+ edge: { relationshipType: 'INVESTIGATED_IN_TRIALS', outgoing: true, direction: null, evidenceLevel: null, evidenceCategory: 'observed_data', cancerContext: [{ id: r.cancer_id, name: r.name, slug: r.slug }], supportCount: num(r.trials), sourceIds: [SRC.clinicaltrials], provenanceIds: [], derived: true, detail: `${num(r.trials)} trials (${num(r.active)} active) · conditions mapped to this cancer only`, date: r.last, trialsTotal: num(r.trials), trialsActive: num(r.active) } as Edge,
390+ })),
391+ };
392+}
393+
394+async function trialLinks(db: Database, focus: Node, limit: number): Promise<Derived[]> {
395+ type CRow = { cancer_id: string; slug: string; name: string; match_type: string; condition_text: string; total: string };
396+ type DRow = { drug_id: string; slug: string; name: string; kind: string | null; match_type: string; intervention_type: string | null; iname: string; total: string };
397+ const [conds, ints] = await Promise.all([
398+ db.execute<CRow>(sql`SELECT tc.cancer_id, c.slug, c.canonical_name AS name, tc.match_type, tc.condition_text, count(*) OVER() AS total FROM trial_conditions tc JOIN cancers c ON c.id = tc.cancer_id WHERE tc.trial_id = ${focus.id} ORDER BY c.canonical_name LIMIT ${limit}`),
399+ db.execute<DRow>(sql`SELECT ti.drug_id, d.slug, d.name, d.kind, ti.match_type, ti.intervention_type, ti.name AS iname, count(*) OVER() AS total FROM trial_interventions ti JOIN drugs d ON d.id = ti.drug_id WHERE ti.trial_id = ${focus.id} ORDER BY d.name LIMIT ${limit}`),
400+ ]);
401+ return [
402+ {
403+ relationshipType: 'CONDITION_OF',
404+ total: total(conds),
405+ links: conds.map((r) => ({ node: { type: 'cancer', id: r.cancer_id, ref: r.slug, label: r.name, href: href('cancer', r.slug) }, edge: { relationshipType: 'CONDITION_OF', outgoing: false, direction: null, evidenceLevel: null, evidenceCategory: 'observed_data', cancerContext: [{ id: r.cancer_id, name: r.name, slug: r.slug }], supportCount: 1, sourceIds: [SRC.clinicaltrials], provenanceIds: [], derived: true, detail: `registry condition "${r.condition_text}" mapped ${r.match_type}`, matchType: r.match_type } as Edge })),
406+ },
407+ {
408+ relationshipType: 'INTERVENTION_OF',
409+ total: total(ints),
410+ links: ints.map((r) => ({ node: { type: 'drug', id: r.drug_id, ref: r.slug, label: r.name, sublabel: r.kind, href: href('drug', r.slug) }, edge: { relationshipType: 'INTERVENTION_OF', outgoing: false, direction: null, evidenceLevel: null, evidenceCategory: 'observed_data', cancerContext: [], supportCount: 1, sourceIds: [SRC.clinicaltrials], provenanceIds: [], derived: true, detail: `registry intervention "${r.iname}" (${r.intervention_type ?? 'type not stated'}) mapped ${r.match_type}`, matchType: r.match_type } as Edge })),
411+ },
412+ ];
413+}
414+
415+async function cancerNames(db: Database, ids: Iterable<string>): Promise<Map<string, { id: string; name: string; slug: string }>> {
416+ const uniq = [...new Set(ids)].filter(Boolean);
417+ if (uniq.length === 0) return new Map();
418+ const rows = await db.execute<{ id: string; slug: string; name: string }>(sql`SELECT id, slug, canonical_name AS name FROM cancers WHERE id IN ${inList(uniq)}`);
419+ return new Map(rows.map((r) => [r.id, { id: r.id, slug: r.slug, name: r.name }]));
420+}
421+
422+async function focusIds(db: Database, focus: Node): Promise<string[]> {
423+ if (focus.type !== 'cancer') return [];
424+ const ids = await descendantIds(db, focus.id);
425+ return ids.length > MAX_DESCENDANTS ? [focus.id, ...ids.filter((i) => i !== focus.id).slice(0, MAX_DESCENDANTS - 1)] : ids;
426+}
427+
428+// ------------------------------------------------------------------ routes
429+
430+export const graphRoutes: FastifyPluginAsyncZod = async (app) => {
431+ const params = z.object({ type: z.enum(TYPES).describe('Entity type'), id: z.string().min(1).max(200).describe('CI id, slug, HGNC symbol or NCT id') });
432+
433+ app.get(
434+ '/graph/:type/:id',
435+ {
436+ schema: {
437+ tags: ['graph'],
438+ summary: 'Contextual neighbourhood of one entity: source-native knowledge edges (with cancer context, direction, evidence level, provenance) plus derived registry links',
439+ params,
440+ querystring: z.object({
441+ limit: z.coerce.number().int().min(1).max(200).default(25).describe('Edges per relationship type (trials default 10)'),
442+ rel: z.string().trim().toUpperCase().max(40).optional().describe('Only this relationship type (e.g. PREDICTS_RESPONSE_TO)'),
443+ context: z.string().trim().max(200).optional().describe('Only knowledge edges whose cancer context includes this cancer (CI id or slug)'),
444+ includeDerived: boolQuery.describe('Include derived registry links (default true)'),
445+ }),
446+ response: ok(AnyRecord),
447+ },
448+ },
449+ async (req) => {
450+ const db = app.db;
451+ const focus = await resolveFocus(db, req.params.type, req.params.id);
452+ const q = req.query;
453+ const includeDerived = q.includeDerived ?? true;
454+ const rel = q.rel || null;
455+ const contextId = q.context ? (await resolveCancer(db, q.context)).id : null;
456+ const ids = await focusIds(db, focus);
457+ const trialLimit = q.limit === 25 ? TRIAL_LIMIT : q.limit;
458+
459+ const tasks: Array<Promise<Derived | Derived[]>> = [];
460+ if (includeDerived) {
461+ switch (focus.type) {
462+ case 'cancer':
463+ tasks.push(cancerTrialLinks(db, focus, ids, trialLimit), frequencyLinks(db, 'cancer', focus, ids, q.limit), approvalLinks(db, 'cancer', focus, ids, q.limit), cancerDrugTrialLinks(db, focus, ids, q.limit));
464+ break;
465+ case 'gene':
466+ tasks.push(geneVariantLinks(db, focus, q.limit), frequencyLinks(db, 'gene', focus, [], q.limit));
467+ break;
468+ case 'variant':
469+ tasks.push(variantEvidenceLinks(db, focus, q.limit), variantDrugCivicLinks(db, focus, q.limit));
470+ break;
471+ case 'drug':
472+ tasks.push(drugTrialLinks(db, focus, trialLimit), drugCancerTrialLinks(db, focus, q.limit), approvalLinks(db, 'drug', focus, [], q.limit));
473+ break;
474+ case 'trial':
475+ tasks.push(trialLinks(db, focus, q.limit));
476+ break;
477+ }
478+ }
479+ const [ke, ...derivedRaw] = await Promise.all([focus.type === 'trial' ? Promise.resolve([] as KeRow[]) : knowledgeEdges(db, focus, q.limit, rel, contextId), ...tasks]);
480+ let derived = derivedRaw.flat();
481+ if (rel) derived = derived.filter((d) => d.relationshipType === rel);
482+ if (contextId) derived = derived.map((d) => ({ ...d, links: d.links.filter((l) => l.edge.cancerContext.some((c) => c.id === contextId)) })).filter((d) => d.links.length);
483+
484+ const ctxIds = new Set<string>();
485+ for (const r of ke) for (const c of r.context_ids ?? []) ctxIds.add(c);
486+ for (const d of derived) for (const l of d.links) for (const c of l.edge.cancerContext) if (!c.slug) ctxIds.add(c.id);
487+ const names = await cancerNames(db, ctxIds);
488+ const ctx = (list: string[] | null | undefined) => (list ?? []).map((id) => names.get(id) ?? { id, name: id, slug: '' }).sort((a, b) => a.name.localeCompare(b.name));
489+
490+ const neighbors = new Map<string, { node: Node; edges: Edge[] }>();
491+ const groups: Record<string, number> = {};
492+ const sources = new Set<string>();
493+ let truncated = false;
494+ const push = (node: Node, edge: Edge, total: number) => {
495+ const k = `${node.type}:${node.id}`;
496+ const cur = neighbors.get(k) ?? { node, edges: [] };
497+ cur.edges.push(edge);
498+ neighbors.set(k, cur);
499+ groups[edge.relationshipType] = Math.max(groups[edge.relationshipType] ?? 0, total);
500+ for (const s of edge.sourceIds) sources.add(s);
501+ };
502+ for (const r of ke) {
503+ if (!r.n_ref || !r.n_label) continue;
504+ push(
505+ { type: r.n_type, id: r.n_id, ref: r.n_ref, label: r.n_label, sublabel: r.n_sublabel, href: href(r.n_type, r.n_ref) },
506+ {
507+ relationshipType: r.relationship_type,
508+ outgoing: r.outgoing,
509+ direction: r.direction,
510+ evidenceLevel: r.evidence_level,
511+ evidenceCategory: r.evidence_category,
512+ cancerContext: ctx(r.context_ids),
513+ supportCount: num(r.support),
514+ sourceIds: [r.source_id],
515+ provenanceIds: (r.provenance_ids ?? []).map(Number),
516+ derived: false,
517+ detail: (r.edge_ids?.length ?? 1) > 1 ? `${r.edge_ids.length} source records aggregated` : null,
518+ date: r.last_seen ? new Date(r.last_seen).toISOString().slice(0, 10) : null,
519+ via: r.ctx_only && r.via_type && r.via_id && r.via_ref && r.via_label ? { type: r.via_type, id: r.via_id, label: r.via_label, href: href(r.via_type, r.via_ref) } : null,
520+ knowledgeEdgeIds: r.edge_ids,
521+ } as Edge,
522+ num(r.total),
523+ );
524+ }
525+ for (const d of derived) {
526+ for (const l of d.links) {
527+ l.edge.cancerContext = l.edge.cancerContext.map((c) => (c.slug ? c : (names.get(c.id) ?? c)));
528+ push(l.node, l.edge, d.total);
529+ }
530+ }
531+ const shown: Record<string, number> = {};
532+ for (const nb of neighbors.values()) for (const e of nb.edges) shown[e.relationshipType] = (shown[e.relationshipType] ?? 0) + 1;
533+ for (const [k, tot] of Object.entries(groups)) if ((shown[k] ?? 0) < tot) truncated = true;
534+
535+ const data = {
536+ node: focus,
537+ neighbors: [...neighbors.values()],
538+ groups,
539+ truncated,
540+ limits: { perRelationship: q.limit, trials: trialLimit, descendantsRolledUp: ids.length },
541+ thresholds: { cohortFrequencyMin: FREQ_MIN, cohortCasesAffectedMin: CASES_MIN },
542+ note: 'Edges are source-native (never inferred by CancerIndex) and keep their native evidence level; rows with derived=true are counts read from registries (ClinicalTrials.gov, GDC/cBioPortal cohorts, approval records).',
543+ };
544+ return respond(app, data, sources);
545+ },
546+ );
547+
548+ app.get(
549+ '/graph/:type/:id/paths',
550+ {
551+ schema: {
552+ tags: ['graph'],
553+ summary: 'Strongest cancer → gene → variant → drug → approval → trials chains (cancer focus only), ranked by evidence level then support',
554+ params,
555+ querystring: z.object({ limit: z.coerce.number().int().min(1).max(50).default(8) }),
556+ response: ok(AnyRecord),
557+ },
558+ },
559+ async (req) => {
560+ if (req.params.type !== 'cancer') throw new BadRequest('paths are built for cancer foci only');
561+ const db = app.db;
562+ const focus = await resolveFocus(db, 'cancer', req.params.id);
563+ const ids = await focusIds(db, focus);
564+ type Row = {
565+ variant_id: string; variant_slug: string; variant_name: string; gene_id: string; symbol: string; drug_id: string; drug_slug: string; drug_name: string;
566+ evidence_level: string | null; direction: string | null; support: string; source_ids: string[]; provenance_ids: number[]; context_ids: string[];
567+ frequency: number | null; cases_affected: number | null; cases_profiled: number | null; cohorts: string | null;
568+ approval_id: number | null; jurisdiction: string | null; authority: string | null; approval_date: string | null; approval_status: string | null; approval_cancer_id: string | null; approval_cancer_name: string | null; tumor_agnostic: boolean | null; approvals: string | null;
569+ trials: string | null; active_trials: string | null;
570+ };
571+ const rows = await db.execute<Row>(sql`
572+ WITH ids AS (SELECT unnest(ARRAY[${sql.join(ids.map((i) => sql`${i}`), sql`, `)}]::varchar[]) AS id),
573+ ed AS (
574+ SELECT ke.source_entity_id AS variant_id, ke.target_entity_id AS drug_id, min(${LEVEL_RANK}) AS lvl, min(ke.evidence_level) AS evidence_level, min(ke.direction) AS direction,
575+ sum(ke.support_count) AS support, array_agg(DISTINCT ke.source_id) AS source_ids,
576+ (SELECT (array_agg(DISTINCT x::int ORDER BY x::int))[1:50] FROM unnest(string_to_array(string_agg(array_to_string(ke.provenance_ids, ','), ','), ',')) x WHERE x <> '') AS provenance_ids,
577+ (SELECT array_agg(DISTINCT x ORDER BY x) FROM unnest(string_to_array(string_agg(array_to_string(ke.cancer_context_ids, ','), ','), ',')) x WHERE x <> '' AND x IN (SELECT id FROM ids)) AS context_ids
578+ FROM knowledge_edges ke
579+ WHERE ke.status = 'active' AND ke.relationship_type = 'PREDICTS_RESPONSE_TO' AND ke.direction = 'sensitivity' AND ke.source_entity_type = 'variant' AND ke.target_entity_type = 'drug'
580+ AND ke.cancer_context_ids && (SELECT array_agg(id)::text[] FROM ids)
581+ GROUP BY ke.source_entity_id, ke.target_entity_id
582+ ),
583+ fq AS (
584+ SELECT DISTINCT ON (f.gene_id) f.gene_id, f.frequency, f.cases_affected, f.cases_profiled, count(*) OVER (PARTITION BY f.gene_id) AS cohorts
585+ FROM cancer_gene_frequencies f WHERE f.cancer_id IN (SELECT id FROM ids) AND f.gene_id IS NOT NULL AND f.cases_affected >= ${CASES_MIN}
586+ ORDER BY f.gene_id, f.cases_profiled DESC, f.frequency DESC
587+ )
588+ SELECT ed.variant_id, v.slug AS variant_slug, v.name AS variant_name, g.id AS gene_id, g.symbol, ed.drug_id, d.slug AS drug_slug, d.name AS drug_name,
589+ ed.evidence_level, ed.direction, ed.support, ed.source_ids, ed.provenance_ids, ed.context_ids,
590+ fq.frequency, fq.cases_affected, fq.cases_profiled, fq.cohorts,
591+ ap.id AS approval_id, ap.jurisdiction, ap.authority, ap.approval_date, ap.status AS approval_status, ap.cancer_id AS approval_cancer_id, ac.canonical_name AS approval_cancer_name, ap.tumor_agnostic, ap.approvals,
592+ tr.trials, tr.active_trials
593+ FROM ed JOIN variants v ON v.id = ed.variant_id JOIN genes g ON g.id = v.gene_id JOIN drugs d ON d.id = ed.drug_id
594+ LEFT JOIN fq ON fq.gene_id = g.id
595+ LEFT JOIN LATERAL (
596+ SELECT a.id, a.jurisdiction, a.authority, a.approval_date, a.status, a.cancer_id, a.tumor_agnostic, count(*) OVER() AS approvals
597+ FROM drug_approvals a WHERE a.drug_id = ed.drug_id AND (a.cancer_id IN (SELECT id FROM ids) OR a.tumor_agnostic)
598+ ORDER BY (a.cancer_id IS NOT NULL) DESC, a.approval_date ASC NULLS LAST, a.id LIMIT 1
599+ ) ap ON true
600+ LEFT JOIN cancers ac ON ac.id = ap.cancer_id
601+ LEFT JOIN LATERAL (
602+ SELECT count(DISTINCT ti.trial_id) AS trials, count(DISTINCT ti.trial_id) FILTER (WHERE t.overall_status IN ${activeList()}) AS active_trials
603+ FROM trial_interventions ti JOIN trial_conditions tc ON tc.trial_id = ti.trial_id AND tc.cancer_id IN (SELECT id FROM ids) JOIN clinical_trials t ON t.id = ti.trial_id
604+ WHERE ti.drug_id = ed.drug_id
605+ ) tr ON true
606+ ORDER BY ed.lvl, ed.support DESC, fq.frequency DESC NULLS LAST, g.symbol, v.name, d.name
607+ LIMIT ${req.query.limit}`);
608+ const names = await cancerNames(db, rows.flatMap((r) => r.context_ids ?? []));
609+ const sources = new Set<string>(['clinicaltrials', 'openfda']);
610+ const chains = rows.map((r) => {
611+ for (const s of r.source_ids ?? []) sources.add(s);
612+ return {
613+ cancer: { id: focus.id, slug: focus.ref, name: focus.label },
614+ gene: { id: r.gene_id, symbol: r.symbol, frequency: r.frequency, casesAffected: r.cases_affected, casesProfiled: r.cases_profiled, cohorts: num(r.cohorts), claim: 'observed_data' },
615+ variant: { id: r.variant_id, slug: r.variant_slug, name: r.variant_name },
616+ drug: { id: r.drug_id, slug: r.drug_slug, name: r.drug_name },
617+ edge: { relationshipType: 'PREDICTS_RESPONSE_TO', evidenceLevel: r.evidence_level, direction: r.direction, supportCount: num(r.support), sourceIds: r.source_ids ?? [], provenanceIds: (r.provenance_ids ?? []).map(Number), cancerContext: (r.context_ids ?? []).map((id) => names.get(id) ?? { id, name: id, slug: '' }), claim: 'curated_evidence' },
618+ approval: r.approval_id ? { id: Number(r.approval_id), jurisdiction: r.jurisdiction, authority: r.authority, approvalDate: r.approval_date, status: r.approval_status, cancerId: r.approval_cancer_id, cancerName: r.approval_cancer_name, tumorAgnostic: !!r.tumor_agnostic, total: num(r.approvals), claim: 'regulatory_status' } : null,
619+ trials: r.trials !== null && r.trials !== undefined ? { total: num(r.trials), active: num(r.active_trials), claim: 'observed_data' } : null,
620+ };
621+ });
622+ return respond(app, { node: focus, chains, descendantsRolledUp: ids.length, ranking: 'evidence level (native CIViC A–E), then support count, then cohort frequency', note: 'Each hop keeps its own claim category; a missing hop is null, never filled in. Not treatment guidance.' }, sources);
623+ },
624+ );
10 }; 625 };
added apps/web/src/app/graph/loading.tsx +5 −0
@@ -0,0 +1,5 @@
1+import { PageSkeleton } from '@/components/ui/skeleton';
2+
3+export default function Loading() {
4+ return <PageSkeleton title="Loading the knowledge graph" />;
5+}
added apps/web/src/app/graph/page.tsx +339 −0
@@ -0,0 +1,339 @@
1+import type { Metadata } from 'next';
2+import Link from 'next/link';
3+import { PageHeader, Section, KV, Note } from '@/components/ui/section';
4+import { Badge, ClaimBadge, claimKindFromCategory } from '@/components/ui/badge';
5+import { EmptyState } from '@/components/ui/empty-state';
6+import { Freshness } from '@/components/ui/freshness';
7+import { SourceBadge } from '@/components/ui/source-badge';
8+import { RadialGraph } from '@/components/graph/radial-graph';
9+import { PathChainView } from '@/components/graph/path-chain';
10+import { defaultFocus, edgesFreshness, loadNeighborhood, loadPaths, resolveFocus, suggestedFoci, DEFAULT_GROUP_LIMIT, EXPANDED_GROUP_LIMIT, TRIAL_GROUP_LIMIT, FREQ_MIN, CASES_MIN, type FocusSuggestion } from '@/lib/queries/graph';
11+import { loadProvenance, toInfo } from '@/lib/queries/provenance';
12+import { type GraphEdge, type GraphNode, NODE_TYPE_LABEL, NODE_TYPE_ORDER, focusHref, nodeKey, parseFocus, relationshipLabel } from '@/lib/graph-model';
13+import { fmtInt } from '@/lib/format';
14+import { str, withParams, type SP } from '@/lib/search-params';
15+
16+export const dynamic = 'force-dynamic';
17+
18+const MAX_DRAWN = 60;
19+
20+export async function generateMetadata({ searchParams }: { searchParams: Promise<SP> }): Promise<Metadata> {
21+ const sp = await searchParams;
22+ const f = parseFocus(str(sp, 'focus'));
23+ const node = f ? await resolveFocus(f) : null;
24+ const title = node ? `${node.label} — knowledge graph` : 'Knowledge graph';
25+ return {
26+ title,
27+ description: node ? `Contextual knowledge graph around ${node.label}: source-native cancer–gene–variant–drug edges with evidence level and cancer context, plus derived registry counts (trials, cohort frequencies, approvals).` : 'Cancer–gene–variant–drug–trial knowledge graph: every edge carries its cancer context, direction, evidence level and provenance.',
28+ robots: { index: !str(sp, 'more') },
29+ alternates: { canonical: node ? `/graph?focus=${node.type}:${encodeURIComponent(node.ref)}` : '/graph' },
30+ };
31+}
32+
33+function FocusForm({ focus, suggestions }: { focus: string; suggestions: FocusSuggestion[] }) {
34+ return (
35+ <div className="mt-3">
36+ <form method="get" action="/graph" className="flex max-w-2xl flex-wrap gap-2">
37+ <label htmlFor="focus" className="sr-only">
38+ Focus entity (type:reference)
39+ </label>
40+ <input id="focus" name="focus" defaultValue={focus} placeholder="cancer:melanoma · gene:EGFR · variant:braf-v600e · drug:osimertinib · trial:NCT04487080" className="min-w-0 flex-1 border border-rule-strong bg-white px-3 py-2 text-[14px] outline-none focus:border-accent" spellCheck={false} />
41+ <button type="submit" className="border border-ink bg-ink px-4 py-2 text-[14px] text-paper hover:bg-ink-2">
42+ Focus
43+ </button>
44+ </form>
45+ <p className="mt-1.5 text-[12px] text-ink-3">
46+ Accepted: <code className="ci-mono">cancer:&lt;slug&gt;</code>, <code className="ci-mono">gene:&lt;symbol&gt;</code>, <code className="ci-mono">variant:&lt;slug&gt;</code>, <code className="ci-mono">drug:&lt;slug&gt;</code>, <code className="ci-mono">trial:&lt;NCT id&gt;</code>, or a bare CI id. Find slugs with{' '}
47+ <Link href="/search" className="ci-link">
48+ search
49+ </Link>
50+ .
51+ </p>
52+ {suggestions.length ? (
53+ <p className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-[12.5px] text-ink-3">
54+ <span className="ci-kicker">Most connected</span>
55+ {suggestions.map((s) => (
56+ <Link key={`${s.type}:${s.ref}`} href={`/graph?focus=${s.type}:${encodeURIComponent(s.ref)}`} className="ci-link" title={`${fmtInt(s.edges)} knowledge edges`}>
57+ {s.type === 'gene' ? <span className="ci-mono">{s.label}</span> : s.label} <span className="ci-num text-ink-4">{fmtInt(s.edges)}</span>
58+ </Link>
59+ ))}
60+ </p>
61+ ) : null}
62+ </div>
63+ );
64+}
65+
66+function DirectionCell({ e }: { e: GraphEdge }) {
67+ if (!e.direction) return <span className="text-ink-4">—</span>;
68+ const tone = e.direction === 'resistance' ? 'warn' : e.direction === 'sensitivity' ? 'ok' : e.direction === 'mixed' ? 'warn' : 'neutral';
69+ return <Badge tone={tone}>{e.direction}</Badge>;
70+}
71+
72+function ContextCell({ e }: { e: GraphEdge }) {
73+ if (e.cancerContext.length === 0) return <span className="text-ink-4">{e.derived ? 'not mapped' : '—'}</span>;
74+ const shown = e.cancerContext.slice(0, 3);
75+ return (
76+ <span className="inline-flex flex-wrap gap-x-1.5 gap-y-0.5">
77+ {shown.map((c, i) => (
78+ <span key={c.id}>
79+ {c.slug ? (
80+ <Link href={`/graph?focus=cancer:${c.slug}`} className="ci-link" title={`Explore ${c.name} in the graph`}>
81+ {c.name}
82+ </Link>
83+ ) : (
84+ <span className="ci-mono">{c.id}</span>
85+ )}
86+ {i < shown.length - 1 ? ',' : ''}
87+ </span>
88+ ))}
89+ {e.cancerContext.length > 3 ? <span className="text-ink-3">+{e.cancerContext.length - 3}</span> : null}
90+ </span>
91+ );
92+}
93+
94+export default async function GraphPage({ searchParams }: { searchParams: Promise<SP> }) {
95+ const sp = await searchParams;
96+ const focusParam = str(sp, 'focus').slice(0, 200);
97+ const more = str(sp, 'more').slice(0, 40).toUpperCase() || null;
98+ const suggestions = await suggestedFoci();
99+ const requested = parseFocus(focusParam);
100+ const focusRef = requested ?? (await defaultFocus());
101+ const focus = focusRef ? await resolveFocus(focusRef) : null;
102+
103+ if (!focus) {
104+ return (
105+ <div>
106+ <PageHeader kicker="Knowledge graph" title="Knowledge graph" lede="Cancer–gene–variant–drug–trial relationships as the sources state them: every edge carries its cancer context, direction, evidence level and provenance. CancerIndex never infers an edge." />
107+ <FocusForm focus={focusParam} suggestions={suggestions} />
108+ <div className="mt-6">
109+ <EmptyState title={requested ? `No ${requested.type} matches “${requested.ref}”` : 'Data not yet available'} knows={[...suggestions.map((s) => ({ label: `${s.label} (${s.type})`, href: `/graph?focus=${s.type}:${encodeURIComponent(s.ref)}` })), { label: 'Search entities', href: '/search' }]}>
110+ {requested ? 'The reference must be the entity slug (cancer, variant, drug), the HGNC symbol (gene) or the NCT id (trial). Use search to find it, or pick a suggested focus.' : 'No knowledge edges are loaded on this environment yet.'}
111+ </EmptyState>
112+ </div>
113+ </div>
114+ );
115+ }
116+
117+ const nb = await loadNeighborhood(focus, { more });
118+ const [paths, freshAt] = await Promise.all([focus.type === 'cancer' ? loadPaths(focus, nb.cancerIds) : Promise.resolve([]), edgesFreshness(focus)]);
119+ const allEdges = nb.groups.flatMap((g) => g.edges);
120+ const prov = await loadProvenance(allEdges.map((e) => e.provenanceIds[0]).filter((x): x is number => typeof x === 'number'));
121+ const nodeByKey = new Map<string, GraphNode>(nb.nodes.map((n) => [nodeKey(n), n]));
122+ const totalEdges = nb.groups.reduce((a, g) => a + g.total, 0);
123+ const current = { focus: `${focus.type}:${focus.ref}`, more: more ?? '' };
124+ const hrefMore = (rel: string | null) => `/graph${withParams(current, { more: rel ?? '' })}#edges`;
125+ const typeOrder = NODE_TYPE_ORDER.filter((t) => nb.degreeByType[t] > 0);
126+
127+ return (
128+ <article>
129+ <PageHeader kicker={`Knowledge graph · ${NODE_TYPE_LABEL[focus.type].replace(/s$/, '')}`} title={focus.type === 'gene' ? <span className="ci-mono font-sans">{focus.label}</span> : focus.label} lede={focus.sublabel ? focus.sublabel.replace(/_/g, ' ') : undefined}>
130+ <div className="mt-3 grid gap-4 sm:grid-cols-2">
131+ <KV
132+ items={[
133+ { k: 'Identifier', v: <span className="ci-mono">{focus.id}</span> },
134+ {
135+ k: 'Entity page',
136+ v: (
137+ <Link href={focus.href} className="ci-link">
138+ {focus.href} ↗
139+ </Link>
140+ ),
141+ },
142+ { k: 'Edges', v: <span className="ci-num">{fmtInt(totalEdges)}</span> },
143+ ]}
144+ />
145+ <KV
146+ items={[
147+ {
148+ k: 'Neighbours',
149+ v: typeOrder.length ? (
150+ <span className="flex flex-wrap gap-x-3 gap-y-0.5">
151+ {typeOrder.map((t) => (
152+ <span key={t}>
153+ <span className="ci-num">{fmtInt(nb.degreeByType[t])}</span> {NODE_TYPE_LABEL[t].toLowerCase()}
154+ </span>
155+ ))}
156+ </span>
157+ ) : (
158+ 'none'
159+ ),
160+ },
161+ { k: 'Drawn', v: `${fmtInt(Math.min(nb.nodes.length, MAX_DRAWN))} of ${fmtInt(nb.nodes.length)} neighbours (cap ${MAX_DRAWN}); table lists every fetched edge` },
162+ ]}
163+ />
164+ </div>
165+ <FocusForm focus={`${focus.type}:${focus.ref}`} suggestions={suggestions} />
166+ </PageHeader>
167+
168+ {nb.nodes.length === 0 ? (
169+ <EmptyState knows={[{ label: `Open ${focus.label}`, href: focus.href }, ...suggestions.slice(0, 4).map((s) => ({ label: s.label, href: `/graph?focus=${s.type}:${encodeURIComponent(s.ref)}` }))]}>
170+ No knowledge edge or registry link touches this {focus.type} on this environment. Edges appear once a source (CIViC, ChEMBL, openFDA, ClinicalTrials.gov, GDC) states one — CancerIndex does not infer them.
171+ </EmptyState>
172+ ) : (
173+ <div className="grid gap-8 lg:grid-cols-[minmax(0,3fr)_minmax(0,2fr)]">
174+ <Section id="graph" kicker="Neighbourhood" title="Radial view" description="Focus at the centre; neighbours grouped by entity type. Click a node to re-centre the graph on it; ↗ opens the entity page. Hover a spoke for relationship, evidence level, cancer context and source." level={3}>
175+ <RadialGraph focus={nb.focus} nodes={nb.nodes} edges={allEdges} maxNodes={MAX_DRAWN} />
176+ </Section>
177+
178+ <Section id="paths" kicker="Paths" title={focus.type === 'cancer' ? 'Strongest chains' : 'Paths'} description={focus.type === 'cancer' ? 'Gene → variant → drug chains anchored in this cancer (or a descendant), ranked by source-native evidence level then support, with the approval and trial registry hops when they exist.' : 'Chains are built for cancer foci only.'} level={3}>
179+ {focus.type !== 'cancer' ? (
180+ <p className="text-[13px] text-ink-3">
181+ Pick a cancer in the graph or from the neighbour table to see gene → variant → drug → approval → trial chains.
182+ {nb.nodes.some((n) => n.type === 'cancer') ? (
183+ <>
184+ {' '}
185+ Cancers here:{' '}
186+ {nb.nodes
187+ .filter((n) => n.type === 'cancer')
188+ .slice(0, 5)
189+ .map((n, i) => (
190+ <span key={n.id}>
191+ {i > 0 ? ', ' : ''}
192+ <Link href={focusHref(n.type, n.ref) ?? n.href} className="ci-link">
193+ {n.label}
194+ </Link>
195+ </span>
196+ ))}
197+ .
198+ </>
199+ ) : null}
200+ </p>
201+ ) : paths.length === 0 ? (
202+ <EmptyState compact>No PREDICTS_RESPONSE_TO edge with direction “sensitivity” names this cancer (or a descendant) in its context, so no chain can be assembled. The neighbour table still lists what the sources state.</EmptyState>
203+ ) : (
204+ <>
205+ <ol className="m-0 list-none p-0">
206+ {paths.map((c) => (
207+ <PathChainView key={`${c.variant.id}:${c.drug.id}`} chain={c} />
208+ ))}
209+ </ol>
210+ <p className="mt-2 text-[12px] text-ink-3">
211+ Each hop keeps its own claim category: cohort alteration frequency (observed, cases ≥ {CASES_MIN}), CIViC predictive edge (curated, level as stated by CIViC), regulatory approval (authority, jurisdiction, date as published), trial count (registry). A missing hop is shown as missing — never filled in. Not treatment guidance.
212+ </p>
213+ </>
214+ )}
215+ </Section>
216+ </div>
217+ )}
218+
219+ {nb.groups.length ? (
220+ <Section id="edges" kicker="Edges" title="Every edge, grouped by relationship" description={`Source-native edges first (aggregated per neighbour, direction, evidence level and source), then derived registry links. Up to ${DEFAULT_GROUP_LIMIT} per relationship (${TRIAL_GROUP_LIMIT} trials); “show all” raises one group to ${EXPANDED_GROUP_LIMIT}.`}>
221+ <div className="ci-table-wrap">
222+ <table className="ci-table ci-evidence">
223+ <thead>
224+ <tr>
225+ <th>Relationship</th>
226+ <th>Neighbour</th>
227+ <th>Direction</th>
228+ <th>Evidence level</th>
229+ <th>Cancer context</th>
230+ <th className="num">Support</th>
231+ <th>Claim</th>
232+ <th>Source</th>
233+ <th>Expand</th>
234+ </tr>
235+ </thead>
236+ {nb.groups.map((g) => {
237+ const expanded = more === g.relationshipType;
238+ return (
239+ <tbody key={g.relationshipType}>
240+ <tr className="ci-group">
241+ <th colSpan={9} scope="colgroup">
242+ <span className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
243+ <span>
244+ {focus.label} <span className="text-ink-2">{relationshipLabel(g.relationshipType)}</span> …
245+ </span>
246+ <span className="ci-mono text-[11px] text-ink-3">{g.relationshipType}</span>
247+ {g.derived ? <Badge tone="outline">derived</Badge> : null}
248+ <span className="ci-num text-[12px] text-ink-3">
249+ {fmtInt(g.edges.length)} of {fmtInt(g.total)}
250+ </span>
251+ {g.total > g.edges.length ? (
252+ <Link href={hrefMore(g.relationshipType)} className="ci-link text-[12.5px]">
253+ show all (up to {EXPANDED_GROUP_LIMIT})
254+ </Link>
255+ ) : expanded ? (
256+ <Link href={hrefMore(null)} className="ci-link text-[12.5px]">
257+ show fewer
258+ </Link>
259+ ) : null}
260+ </span>
261+ </th>
262+ </tr>
263+ {g.edges.map((e) => {
264+ const nnode = nodeByKey.get(e.neighborKey);
265+ if (!nnode) return null;
266+ const p = toInfo(prov.get(e.provenanceIds[0] ?? -1));
267+ const expand = focusHref(nnode.type, nnode.ref);
268+ return (
269+ <tr key={e.key}>
270+ <td className="whitespace-nowrap text-[12.5px] text-ink-2">
271+ {e.via ? (
272+ <span title="The focus is the cancer context of this edge (the neighbour and the third entity are the edge's ends)">in context · </span>
273+ ) : (
274+ <span aria-label={e.outgoing ? 'focus to neighbour' : 'neighbour to focus'}>{e.outgoing ? '→' : '←'} </span>
275+ )}
276+ {relationshipLabel(e.relationshipType)}
277+ </td>
278+ <td className="w-t">
279+ <Link href={nnode.href} className="ci-link">
280+ {nnode.type === 'gene' ? <span className="ci-mono">{nnode.label}</span> : nnode.label}
281+ </Link>
282+ {nnode.sublabel && nnode.type !== 'gene' ? <span className="ml-1.5 text-[12px] text-ink-3">{nnode.sublabel}</span> : null}
283+ {e.via ? (
284+ <span className="text-[12.5px] text-ink-2">
285+ {' '}
286+ → {relationshipLabel(e.relationshipType)}{' '}
287+ <Link href={e.via.href} className="ci-link">
288+ {e.via.label}
289+ </Link>
290+ </span>
291+ ) : null}
292+ {e.detail ? <div className="mt-0.5 text-[12px] text-ink-3">{e.detail}</div> : null}
293+ </td>
294+ <td>
295+ <DirectionCell e={e} />
296+ </td>
297+ <td>{e.evidenceLevel ? <Badge tone="accent" mono title="Source-native scale (CIViC A–E, ChEMBL max phase, FDA application type, approval status) — never re-scaled">{e.evidenceLevel}</Badge> : <span className="text-ink-4">—</span>}</td>
298+ <td className="text-[12.5px]">
299+ <ContextCell e={e} />
300+ </td>
301+ <td className="num">{fmtInt(e.supportCount)}</td>
302+ <td>
303+ <ClaimBadge kind={claimKindFromCategory(e.evidenceCategory)} />
304+ </td>
305+ <td>
306+ {e.sourceSlugs.map((s) => (
307+ <SourceBadge key={s} compact p={p ?? { sourceSlug: s }} title={p ? undefined : e.derived ? 'Derived by CancerIndex from registry rows of this source' : null} />
308+ ))}
309+ </td>
310+ <td className="whitespace-nowrap">
311+ {expand ? (
312+ <Link href={expand} className="ci-link text-[12.5px]">
313+ graph →
314+ </Link>
315+ ) : (
316+ <Link href={nnode.href} className="ci-link text-[12.5px]">
317+ open ↗
318+ </Link>
319+ )}
320+ </td>
321+ </tr>
322+ );
323+ })}
324+ </tbody>
325+ );
326+ })}
327+ </table>
328+ </div>
329+ <div className="mt-3 space-y-2">
330+ <Note>
331+ An <strong>edge</strong> is a relationship stated by a source (CIViC evidence item, ChEMBL indication or mechanism, openFDA approval) and kept with its native evidence level, direction and cancer context — CancerIndex never infers, merges or re-scales it. Rows marked <em>derived</em> are counts and measurements read from registries (ClinicalTrials.gov conditions and interventions, GDC/cBioPortal cohort frequencies ≥ {Math.round(FREQ_MIN * 100)} % with ≥ {CASES_MIN} cases affected, regulatory approval records): they say how often two entities co-occur in a registry, not that a source asserted a biological or clinical link. Trials are rolled up over the cancer and its descendants; approvals list authority, jurisdiction and date as published.
332+ </Note>
333+ <Freshness dataUpdatedAt={freshAt} extra={`${fmtInt(totalEdges)} edges in the database for this focus`} />
334+ </div>
335+ </Section>
336+ ) : null}
337+ </article>
338+ );
339+}
added apps/web/src/components/graph/graph-link.tsx +14 −0
@@ -0,0 +1,14 @@
1+import Link from 'next/link';
2+import type { FocusType } from '@/lib/graph-model';
3+
4+/**
5+ * "Explore in graph →" link for entity pages. `ref` is the public reference used by `/graph?focus=`:
6+ * cancer slug, gene symbol, variant slug, drug slug or NCT id.
7+ */
8+export function GraphLink({ type, ref, label = 'Explore in graph →', className = '' }: { type: FocusType; ref: string; label?: string; className?: string }) {
9+ return (
10+ <Link href={`/graph?focus=${type}:${encodeURIComponent(ref)}`} className={`ci-link inline-flex items-center gap-1 text-[13px]${className ? ` ${className}` : ''}`} title="Open the contextual knowledge graph around this entity">
11+ {label}
12+ </Link>
13+ );
14+}
added apps/web/src/components/graph/path-chain.tsx +116 −0
@@ -0,0 +1,116 @@
1+import Link from 'next/link';
2+import { Badge, ClaimBadge } from '@/components/ui/badge';
3+import { SourceBadge } from '@/components/ui/source-badge';
4+import { fmtInt, fmtPct } from '@/lib/format';
5+import type { PathChain } from '@/lib/graph-model';
6+
7+const SOURCE_SLUG_BY_ID: Record<string, string> = { 'CI-SOURCE-00000006': 'civic', 'CI-SOURCE-00000015': 'chembl', 'CI-SOURCE-00000016': 'openfda', 'CI-SOURCE-00000004': 'clinicaltrials', 'CI-SOURCE-00000008': 'gdc', 'CI-SOURCE-00000017': 'cbioportal' };
8+
9+function Arrow() {
10+ return (
11+ <span aria-hidden className="mx-1 text-ink-4">
12+ →
13+ </span>
14+ );
15+}
16+
17+/**
18+ * One cancer → gene → variant → drug → approval → trials chain, readable as a sentence. Each hop
19+ * keeps its own claim category: cohort frequency (observed), CIViC predictive edge (curated),
20+ * approval (regulatory), trial count (observed). Missing hops say so — nothing is filled in.
21+ */
22+export function PathChainView({ chain, compact = false, sourceSlugs }: { chain: PathChain; compact?: boolean; sourceSlugs?: Map<string, string> }) {
23+ const slugOf = (id: string) => sourceSlugs?.get(id) ?? SOURCE_SLUG_BY_ID[id] ?? id;
24+ const g = chain.gene;
25+ const freq = g.frequency != null && g.casesAffected != null && g.casesProfiled != null ? `${fmtPct(g.frequency, g.frequency >= 0.1 ? 0 : 1)} (${fmtInt(g.casesAffected)} / ${fmtInt(g.casesProfiled)} cases${g.cohorts > 1 ? `, largest of ${g.cohorts} cohorts` : ''})` : null;
26+ return (
27+ <li className={`ci-rule ${compact ? 'py-2' : 'py-3'} text-[13.5px] leading-relaxed`}>
28+ <p className="m-0 flex flex-wrap items-baseline">
29+ <Link href={`/gene/${g.symbol}`} className="ci-link ci-mono font-sans font-medium">
30+ {g.symbol}
31+ </Link>
32+ <Arrow />
33+ <Link href={`/variant/${chain.variant.slug}`} className="ci-link">
34+ {chain.variant.name}
35+ </Link>
36+ <Arrow />
37+ <Link href={`/drug/${chain.drug.slug}`} className="ci-link font-medium">
38+ {chain.drug.name}
39+ </Link>
40+ {chain.approval ? (
41+ <>
42+ <Arrow />
43+ <span>
44+ {chain.approval.authority} <Badge tone="outline" mono>{chain.approval.jurisdiction}</Badge>
45+ {chain.approval.approvalDate ? <span className="ci-num text-ink-2"> {chain.approval.approvalDate}</span> : <span className="text-ink-3"> (date not published)</span>}
46+ {chain.approval.tumorAgnostic && !chain.approval.cancerId ? <span className="text-ink-3"> · tumour-agnostic</span> : null}
47+ </span>
48+ </>
49+ ) : null}
50+ {chain.trials ? (
51+ <>
52+ <Arrow />
53+ <span>
54+ <span className="ci-num">{fmtInt(chain.trials.active)}</span> active / <span className="ci-num">{fmtInt(chain.trials.total)}</span> trials
55+ </span>
56+ </>
57+ ) : null}
58+ </p>
59+ <p className="m-0 mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-[12px] text-ink-3">
60+ <span className="inline-flex items-center gap-1">
61+ <ClaimBadge kind="curated" />
62+ {chain.edge.evidenceLevel ? (
63+ <Badge tone="accent" mono title="Source-native evidence level (CIViC A–E)">
64+ level {chain.edge.evidenceLevel}
65+ </Badge>
66+ ) : (
67+ <Badge tone="outline">level not stated</Badge>
68+ )}
69+ <span>{chain.edge.direction ?? 'direction unknown'}</span>
70+ <span>
71+ · support <span className="ci-num">{fmtInt(chain.edge.supportCount)}</span>
72+ </span>
73+ {chain.edge.sourceIds.map((s) => (
74+ <SourceBadge key={s} compact p={{ sourceSlug: slugOf(s) }} title={null} />
75+ ))}
76+ </span>
77+ {!compact ? (
78+ <>
79+ <span aria-hidden>·</span>
80+ <span className="inline-flex items-center gap-1">
81+ <ClaimBadge kind="observed" />
82+ {freq ? <span>{g.symbol} altered in {freq}</span> : <span>cohort frequency not yet available for {g.symbol} in this cancer</span>}
83+ </span>
84+ {chain.approval ? (
85+ <>
86+ <span aria-hidden>·</span>
87+ <span className="inline-flex items-center gap-1">
88+ <ClaimBadge kind="regulatory" />
89+ <span>
90+ {chain.approval.status}
91+ {chain.approval.cancerName ? ` for ${chain.approval.cancerName}` : ''}
92+ {chain.approval.total > 1 ? ` (${fmtInt(chain.approval.total)} approval records)` : ''}
93+ </span>
94+ </span>
95+ </>
96+ ) : (
97+ <>
98+ <span aria-hidden>·</span>
99+ <span>no approval record in this cancer</span>
100+ </>
101+ )}
102+ {chain.edge.contextNames.length ? (
103+ <>
104+ <span aria-hidden>·</span>
105+ <span>
106+ context: {chain.edge.contextNames.slice(0, 3).join(', ')}
107+ {chain.edge.contextNames.length > 3 ? ` +${chain.edge.contextNames.length - 3}` : ''}
108+ </span>
109+ </>
110+ ) : null}
111+ </>
112+ ) : null}
113+ </p>
114+ </li>
115+ );
116+}
added apps/web/src/components/graph/radial-graph.tsx +196 −0
@@ -0,0 +1,196 @@
1+import Link from 'next/link';
2+import { type GraphEdge, type GraphNode, type NodeType, type PlacedNode, NODE_TYPE_LABEL, NODE_TYPE_ORDER, edgeStroke, focusHref, labelPlacement, layoutRadial, nodeKey, parallelOffsets, relationshipLabel, shortLabel } from '@/lib/graph-model';
3+
4+/**
5+ * Server-rendered radial SVG of one neighbourhood. No client graph library: positions come from
6+ * `layoutRadial` (pure). Entity type is encoded three ways — sector position + sector caption,
7+ * mark shape and a muted fill — so the picture is readable without colour. Edge stroke: solid for
8+ * source-native curated / regulatory claims, dashed for derived or observed registry counts.
9+ * Every node is a link to `/graph?focus=…` (contextual expansion) with a small ↗ to the entity page;
10+ * `<title>` elements carry the full label and the edge context for hover and assistive technology.
11+ */
12+
13+const FILL: Record<NodeType, string> = {
14+ cancer: 'var(--color-series-1)',
15+ gene: 'var(--color-series-7)',
16+ variant: 'var(--color-series-8)',
17+ drug: 'var(--color-series-2)',
18+ trial: 'var(--color-series-3)',
19+ approval: 'var(--color-series-4)',
20+};
21+
22+/** Mark shape per entity type (secondary encoding, independent of colour). */
23+function Mark({ type, x, y, r, fill }: { type: NodeType; x: number; y: number; r: number; fill: string }) {
24+ const common = { fill, stroke: 'var(--color-paper)', strokeWidth: 1.5 } as const;
25+ switch (type) {
26+ case 'gene':
27+ return <rect x={x - r} y={y - r} width={2 * r} height={2 * r} {...common} />;
28+ case 'variant':
29+ return <polygon points={`${x},${y - r * 1.15} ${x + r * 1.1},${y + r * 0.8} ${x - r * 1.1},${y + r * 0.8}`} {...common} />;
30+ case 'drug':
31+ return <rect x={x - r} y={y - r} width={2 * r} height={2 * r} rx={r * 0.45} {...common} />;
32+ case 'trial':
33+ return <polygon points={hexagon(x, y, r * 1.1)} {...common} />;
34+ case 'approval':
35+ return <polygon points={`${x},${y - r * 1.2} ${x + r * 1.2},${y} ${x},${y + r * 1.2} ${x - r * 1.2},${y}`} {...common} />;
36+ default:
37+ return <circle cx={x} cy={y} r={r} {...common} />;
38+ }
39+}
40+
41+function hexagon(cx: number, cy: number, r: number): string {
42+ const pts: string[] = [];
43+ for (let i = 0; i < 6; i++) {
44+ const a = (Math.PI / 3) * i - Math.PI / 6;
45+ pts.push(`${(cx + r * Math.cos(a)).toFixed(1)},${(cy + r * Math.sin(a)).toFixed(1)}`);
46+ }
47+ return pts.join(' ');
48+}
49+
50+function edgeTitle(e: GraphEdge, focus: GraphNode, neighbor: GraphNode): string {
51+ const from = e.outgoing ? focus.label : neighbor.label;
52+ const to = e.outgoing ? neighbor.label : focus.label;
53+ const bits = [e.via ? `${neighbor.label} ${relationshipLabel(e.relationshipType)} ${e.via.label} — in ${focus.label}` : `${from} ${relationshipLabel(e.relationshipType)} ${to}`];
54+ if (e.direction) bits.push(`direction: ${e.direction}`);
55+ if (e.evidenceLevel) bits.push(`evidence level: ${e.evidenceLevel}`);
56+ if (e.cancerContext.length) bits.push(`context: ${e.cancerContext.slice(0, 3).map((c) => c.name).join(', ')}${e.cancerContext.length > 3 ? ` +${e.cancerContext.length - 3}` : ''}`);
57+ bits.push(`${e.derived ? 'derived count' : e.evidenceCategory.replace(/_/g, ' ')} · source: ${e.sourceSlugs.join(', ')}`);
58+ if (e.detail) bits.push(e.detail);
59+ return bits.join(' · ');
60+}
61+
62+export function RadialGraph({ focus, nodes, edges, size = 760, maxNodes = 60, className = '' }: { focus: GraphNode; nodes: GraphNode[]; edges: GraphEdge[]; size?: number; maxNodes?: number; className?: string }) {
63+ const layout = layoutRadial(nodes, { size, maxNodes });
64+ const placed = new Map<string, PlacedNode>(layout.nodes.map((p) => [nodeKey(p.node), p]));
65+ const byNeighbor = new Map<string, GraphEdge[]>();
66+ for (const e of edges) {
67+ if (!placed.has(e.neighborKey)) continue;
68+ byNeighbor.set(e.neighborKey, [...(byNeighbor.get(e.neighborKey) ?? []), e]);
69+ }
70+ const { cx, cy } = layout;
71+ const focusR = layout.focus.r;
72+
73+ return (
74+ <figure className={`ci-graph ${className}`}>
75+ <div className="overflow-x-auto">
76+ <svg viewBox={`0 0 ${size} ${size}`} role="img" aria-labelledby="ci-graph-title ci-graph-desc" className="block h-auto w-full min-w-[560px] max-w-[820px] mx-auto" style={{ fontFamily: 'var(--font-sans)' }}>
77+ <title id="ci-graph-title">{`Knowledge graph around ${focus.label}`}</title>
78+ <desc id="ci-graph-desc">{`${layout.nodes.length} neighbours drawn in sectors by entity type: ${layout.sectors.map((s) => `${s.count} ${NODE_TYPE_LABEL[s.type].toLowerCase()}`).join(', ')}. Solid spokes are source-native curated or regulatory edges; dashed spokes are derived registry counts. The table below lists every edge with its context and provenance.`}</desc>
79+
80+ {/* Sector captions: inside the ring, on the sector's mid-angle, with a paper halo so they stay legible over the spokes */}
81+ {layout.sectors.map((s) => {
82+ const mid = (s.start + s.end) / 2;
83+ const capR = s.ring - 34;
84+ const capX = cx + capR * Math.cos(mid);
85+ const capY = cy + capR * Math.sin(mid);
86+ return (
87+ <text key={s.type} aria-hidden x={capX.toFixed(1)} y={capY.toFixed(1)} textAnchor="middle" dominantBaseline="middle" fontSize="10" letterSpacing="0.08em" fill="var(--color-ink-3)" stroke="var(--color-paper)" strokeWidth="3" paintOrder="stroke" style={{ textTransform: 'uppercase' }}>
88+ {`${NODE_TYPE_LABEL[s.type]} · ${s.count}`}
89+ </text>
90+ );
91+ })}
92+
93+ {/* Edges: one spoke per (neighbour, relationship), parallel offsets when several */}
94+ <g>
95+ {layout.nodes.map((p) => {
96+ const list = byNeighbor.get(nodeKey(p.node)) ?? [];
97+ const shown = list.slice(0, 3);
98+ const offsets = parallelOffsets(shown.length);
99+ const ux = p.x - cx;
100+ const uy = p.y - cy;
101+ const len = Math.hypot(ux, uy) || 1;
102+ const nx = -uy / len; // unit normal
103+ const ny = ux / len;
104+ const sx = cx + (ux / len) * (focusR + 3);
105+ const sy = cy + (uy / len) * (focusR + 3);
106+ const ex = p.x - (ux / len) * (p.r + 2);
107+ const ey = p.y - (uy / len) * (p.r + 2);
108+ return shown.map((e, i) => {
109+ const o = offsets[i]!;
110+ const dashed = edgeStroke(e) === 'dashed';
111+ return (
112+ <line key={e.key} x1={(sx + nx * o).toFixed(1)} y1={(sy + ny * o).toFixed(1)} x2={(ex + nx * o).toFixed(1)} y2={(ey + ny * o).toFixed(1)} stroke={dashed ? 'var(--color-ink-4)' : 'var(--color-ink-3)'} strokeWidth={dashed ? 1 : 1.2} strokeDasharray={dashed ? '3 3' : undefined} strokeOpacity={0.9}>
113+ <title>{edgeTitle(e, focus, p.node)}</title>
114+ </line>
115+ );
116+ });
117+ })}
118+ </g>
119+
120+ {/* Neighbour nodes */}
121+ {layout.nodes.map((p) => {
122+ const lp = labelPlacement(p);
123+ const href = focusHref(p.node.type, p.node.ref);
124+ const label = `${p.node.label}${p.node.sublabel ? ` — ${p.node.sublabel}` : ''} · ${p.node.degree} edge${p.node.degree === 1 ? '' : 's'} here`;
125+ const body = (
126+ <>
127+ <title>{label}</title>
128+ <Mark type={p.node.type} x={p.x} y={p.y} r={p.r} fill={FILL[p.node.type]} />
129+ <text transform={`rotate(${lp.rotate.toFixed(2)} ${lp.x.toFixed(1)} ${lp.y.toFixed(1)})`} x={lp.x} y={lp.y} textAnchor={lp.anchor} dominantBaseline="middle" fontSize="11" fill="var(--color-ink)">
130+ {shortLabel(p.node.label)}
131+ </text>
132+ </>
133+ );
134+ return (
135+ <g key={nodeKey(p.node)} className="ci-graph-node">
136+ {href ? (
137+ <a href={href} aria-label={`Explore ${p.node.label} in the graph`}>
138+ {body}
139+ </a>
140+ ) : (
141+ <a href={p.node.href} aria-label={`Open ${p.node.label}`}>
142+ {body}
143+ </a>
144+ )}
145+ </g>
146+ );
147+ })}
148+
149+ {/* Focus */}
150+ <g>
151+ <title>{`${focus.label} · ${NODE_TYPE_LABEL[focus.type].replace(/s$/, '')} · focus`}</title>
152+ <a href={focus.href} aria-label={`Open the ${focus.type} page for ${focus.label}`}>
153+ <Mark type={focus.type} x={cx} y={cy} r={focusR} fill={FILL[focus.type]} />
154+ </a>
155+ <text x={cx} y={cy + focusR + 14} textAnchor="middle" fontSize="12" fontWeight={600} fill="var(--color-ink)">
156+ {shortLabel(focus.label, 34)}
157+ </text>
158+ </g>
159+ </svg>
160+ </div>
161+
162+ <figcaption className="mt-2 flex flex-wrap items-center gap-x-4 gap-y-1.5 text-[12px] text-ink-3">
163+ <span className="ci-kicker">Legend</span>
164+ {NODE_TYPE_ORDER.map((t) => (
165+ <span key={t} className="inline-flex items-center gap-1.5">
166+ <svg width="14" height="14" viewBox="-8 -8 16 16" aria-hidden>
167+ <Mark type={t} x={0} y={0} r={5} fill={FILL[t]} />
168+ </svg>
169+ {NODE_TYPE_LABEL[t]}
170+ </span>
171+ ))}
172+ <span className="inline-flex items-center gap-1.5">
173+ <svg width="26" height="8" viewBox="0 0 26 8" aria-hidden>
174+ <line x1="0" y1="4" x2="26" y2="4" stroke="var(--color-ink-3)" strokeWidth="1.4" />
175+ </svg>
176+ source-native edge (curated / regulatory)
177+ </span>
178+ <span className="inline-flex items-center gap-1.5">
179+ <svg width="26" height="8" viewBox="0 0 26 8" aria-hidden>
180+ <line x1="0" y1="4" x2="26" y2="4" stroke="var(--color-ink-4)" strokeWidth="1.2" strokeDasharray="3 3" />
181+ </svg>
182+ derived count (registries)
183+ </span>
184+ <span>mark size = log of edges at the node</span>
185+ {layout.hidden > 0 ? (
186+ <span>
187+ {layout.hidden} more neighbour{layout.hidden === 1 ? '' : 's'} not drawn (cap {maxNodes}) — all listed in the table
188+ </span>
189+ ) : null}
190+ <Link href={focus.href} className="ci-link ml-auto">
191+ Entity page ↗
192+ </Link>
193+ </figcaption>
194+ </figure>
195+ );
196+}
added apps/web/src/components/home/graph-module.tsx +46 −0
@@ -0,0 +1,46 @@
1+import Link from 'next/link';
2+import { Section } from '@/components/ui/section';
3+import { EmptyState } from '@/components/ui/empty-state';
4+import { PathChainView } from '@/components/graph/path-chain';
5+import { defaultFocus, focusCancerIds, loadPaths, resolveFocus } from '@/lib/queries/graph';
6+import { fmtInt } from '@/lib/format';
7+
8+/**
9+ * Home teaser for the knowledge graph: three strongest gene → variant → drug chains for the
10+ * most-connected cancer (chosen from the data at request time, never hardcoded) and a link to /graph.
11+ */
12+export async function GraphModule({ limit = 3 }: { limit?: number }) {
13+ const ref = await defaultFocus();
14+ const focus = ref ? await resolveFocus(ref) : null;
15+ const chains = focus ? await loadPaths(focus, await focusCancerIds(focus), limit) : [];
16+ return (
17+ <Section
18+ id="graph"
19+ kicker="Knowledge graph"
20+ title={focus ? `Paths through ${focus.label}` : 'Knowledge graph'}
21+ description="Cancer → gene → variant → drug → approval → trials, as the sources state each hop: CIViC evidence level and direction, regulatory authority and date, registry trial counts. No edge is inferred by CancerIndex."
22+ actions={
23+ <Link href={focus ? `/graph?focus=cancer:${encodeURIComponent(focus.ref)}` : '/graph'} className="ci-link">
24+ Explore the graph →
25+ </Link>
26+ }
27+ >
28+ {!focus || chains.length === 0 ? (
29+ <EmptyState compact knows={[{ label: 'Open the graph', href: '/graph' }]}>
30+ No knowledge edge with a sensitivity direction is loaded yet, so no chain can be shown.
31+ </EmptyState>
32+ ) : (
33+ <>
34+ <ol className="m-0 list-none p-0">
35+ {chains.map((c) => (
36+ <PathChainView key={`${c.variant.id}:${c.drug.id}`} chain={c} compact />
37+ ))}
38+ </ol>
39+ <p className="mt-2 text-[12px] text-ink-3">
40+ {fmtInt(chains.length)} of the strongest chains for the cancer with the most knowledge edges ({focus.label}); ranked by source-native evidence level, then support. Not treatment guidance.
41+ </p>
42+ </>
43+ )}
44+ </Section>
45+ );
46+}
added apps/web/src/lib/graph-model.ts +379 −0
@@ -0,0 +1,379 @@
1+/**
2+ * Knowledge-graph model + radial layout math (pure: no database, no React). Shared by the web
3+ * queries (`lib/queries/graph.ts`), the SVG renderer and the unit tests.
4+ *
5+ * Every edge carries its cancer context, direction, evidence level, claim category and provenance
6+ * (CLAUDE.md §7). `derived: true` marks links that CancerIndex computes from registry tables
7+ * (trial_conditions, trial_interventions, cancer_gene_frequencies, drug_approvals,
8+ * civic_evidence_items) as opposed to source-native `knowledge_edges` rows. Nothing here is inferred.
9+ */
10+
11+export type NodeType = 'cancer' | 'gene' | 'variant' | 'drug' | 'trial' | 'approval';
12+
13+/** Fixed angular order of the sectors (never re-sorted by count so the picture stays stable). */
14+export const NODE_TYPE_ORDER: readonly NodeType[] = ['cancer', 'gene', 'variant', 'drug', 'trial', 'approval'];
15+
16+export const NODE_TYPE_LABEL: Record<NodeType, string> = {
17+ cancer: 'Cancers',
18+ gene: 'Genes',
19+ variant: 'Variants',
20+ drug: 'Drugs',
21+ trial: 'Trials',
22+ approval: 'Approvals',
23+};
24+
25+/** Focus reference forms accepted by `/graph?focus=<type>:<ref>` (approvals are not focusable). */
26+export type FocusType = Exclude<NodeType, 'approval'>;
27+export const FOCUS_TYPES: readonly FocusType[] = ['cancer', 'gene', 'variant', 'drug', 'trial'];
28+
29+export interface GraphNode {
30+ type: NodeType;
31+ /** Public CI id (CI-CAN-…, CI-GENE-…) or `approval:<row id>` for approval nodes. */
32+ id: string;
33+ /** Reference used in `focus=` (slug / symbol / NCT id). Null for approval nodes. */
34+ ref: string | null;
35+ label: string;
36+ /** Secondary line (gene symbol for a variant, phase/status for a trial, jurisdiction for an approval). */
37+ sublabel?: string | null;
38+ /** Entity page. */
39+ href: string;
40+ /** Number of edges touching this node in the current neighbourhood. */
41+ degree: number;
42+}
43+
44+export interface CancerContext {
45+ id: string;
46+ name: string;
47+ slug: string;
48+}
49+
50+export interface GraphEdge {
51+ /** Stable key (`ke:<id>` for knowledge_edges rows, `dv:<kind>:<ids>` for derived links). */
52+ key: string;
53+ relationshipType: string;
54+ /** `${type}:${id}` of the neighbour node. */
55+ neighborKey: string;
56+ /** True when the focus is the source of the relationship (focus → neighbour). */
57+ outgoing: boolean;
58+ direction: string | null;
59+ /** Source-native evidence level (CIViC A–E, ChEMBL phase 1–4, "FDA ORIG"…), never re-scaled. */
60+ evidenceLevel: string | null;
61+ /** observed_data | published_evidence | curated_evidence | regulatory_status | clinical_guideline | computed_metric */
62+ evidenceCategory: string;
63+ cancerContext: CancerContext[];
64+ supportCount: number;
65+ sourceIds: string[];
66+ sourceSlugs: string[];
67+ provenanceIds: number[];
68+ derived: boolean;
69+ /** Human-readable measurement behind a derived link ("183 / 186 cases (98.4 %)", "417 trials (197 active)"). */
70+ detail?: string | null;
71+ /** ISO date attached to the edge (approval date, trial last update). */
72+ date?: string | null;
73+ /** Third party when the focus is only the *context* of the edge (e.g. variant → drug in this cancer). */
74+ via?: { type: NodeType; id: string; label: string; href: string } | null;
75+}
76+
77+export interface EdgeGroup {
78+ relationshipType: string;
79+ /** Total edges of this type in the database for the focus (before LIMIT). */
80+ total: number;
81+ edges: GraphEdge[];
82+ derived: boolean;
83+}
84+
85+export interface Neighborhood {
86+ focus: GraphNode;
87+ /** Neighbour nodes (focus excluded), unique by `${type}:${id}`. */
88+ nodes: GraphNode[];
89+ groups: EdgeGroup[];
90+ degreeByType: Record<NodeType, number>;
91+}
92+
93+export const nodeKey = (n: Pick<GraphNode, 'type' | 'id'>): string => `${n.type}:${n.id}`;
94+
95+export interface FocusRef {
96+ type: FocusType;
97+ ref: string;
98+}
99+
100+/** Parse `focus=<type>:<ref>` (also accepts a bare NCT id, a bare CI id or a bare gene symbol in upper case). */
101+export function parseFocus(raw: string | null | undefined): FocusRef | null {
102+ const s = (raw ?? '').trim();
103+ if (!s) return null;
104+ const m = /^([a-z]+)\s*:\s*(.*)$/i.exec(s);
105+ if (m) {
106+ const type = m[1]!.toLowerCase();
107+ const ref = m[2]!.trim().slice(0, 200);
108+ if (!ref) return null;
109+ if ((FOCUS_TYPES as readonly string[]).includes(type)) return { type: type as FocusType, ref };
110+ return null;
111+ }
112+ if (/^NCT\d{8}$/i.test(s)) return { type: 'trial', ref: s.toUpperCase() };
113+ const ci = /^CI-(CAN|GENE|VAR|DRUG|TRIAL)-\d+$/i.exec(s);
114+ if (ci) {
115+ const ns = ci[1]!.toUpperCase();
116+ const type: FocusType = ns === 'CAN' ? 'cancer' : ns === 'GENE' ? 'gene' : ns === 'VAR' ? 'variant' : ns === 'DRUG' ? 'drug' : 'trial';
117+ return { type, ref: s.toUpperCase() };
118+ }
119+ if (/^[A-Z][A-Z0-9-]{1,14}$/.test(s)) return { type: 'gene', ref: s };
120+ return { type: 'cancer', ref: s.toLowerCase() };
121+}
122+
123+export const focusHref = (type: NodeType, ref: string | null): string | null => (type === 'approval' || !ref ? null : `/graph?focus=${type}:${encodeURIComponent(ref)}`);
124+
125+/** Relationship labels in editorial English (the raw type stays in `title` / tables). */
126+export const RELATIONSHIP_LABEL: Record<string, string> = {
127+ ASSOCIATED_WITH: 'associated with',
128+ PREDICTS_RESPONSE_TO: 'predicts response to',
129+ CONFERS_RESISTANCE_TO: 'confers resistance to',
130+ PROGNOSTIC_IN: 'prognostic in',
131+ DIAGNOSTIC_OF: 'diagnostic of',
132+ PREDISPOSES_TO: 'predisposes to',
133+ TARGETS: 'targets',
134+ APPROVED_FOR: 'approved for',
135+ INVESTIGATED_FOR: 'investigated for',
136+ INVESTIGATED_IN_TRIALS: 'investigated in trials for',
137+ STUDIED_IN: 'studied in',
138+ ALTERED_IN: 'altered in cohorts of',
139+ HAS_VARIANT: 'has variant',
140+ HAS_EVIDENCE_IN: 'has curated evidence in',
141+ CONDITION_OF: 'condition of',
142+ INTERVENTION_OF: 'intervention of',
143+};
144+
145+export const relationshipLabel = (t: string): string => RELATIONSHIP_LABEL[t] ?? t.toLowerCase().replace(/_/g, ' ');
146+
147+/** Display order of relationship groups: regulatory first, then curated, then derived counts. */
148+export const RELATIONSHIP_ORDER: readonly string[] = [
149+ 'APPROVED_FOR',
150+ 'PREDICTS_RESPONSE_TO',
151+ 'CONFERS_RESISTANCE_TO',
152+ 'PROGNOSTIC_IN',
153+ 'DIAGNOSTIC_OF',
154+ 'PREDISPOSES_TO',
155+ 'ASSOCIATED_WITH',
156+ 'TARGETS',
157+ 'INVESTIGATED_FOR',
158+ 'HAS_VARIANT',
159+ 'HAS_EVIDENCE_IN',
160+ 'ALTERED_IN',
161+ 'INVESTIGATED_IN_TRIALS',
162+ 'STUDIED_IN',
163+ 'CONDITION_OF',
164+ 'INTERVENTION_OF',
165+];
166+
167+export function sortGroups<T extends { relationshipType: string }>(groups: T[]): T[] {
168+ const rank = (t: string) => {
169+ const i = RELATIONSHIP_ORDER.indexOf(t);
170+ return i === -1 ? RELATIONSHIP_ORDER.length : i;
171+ };
172+ return [...groups].sort((a, b) => rank(a.relationshipType) - rank(b.relationshipType) || a.relationshipType.localeCompare(b.relationshipType));
173+}
174+
175+/** CIViC A–E rank (lower is stronger); other native scales rank after A–E, unknown last. */
176+export function evidenceLevelRank(level: string | null | undefined): number {
177+ if (!level) return 99;
178+ const l = level.trim().toUpperCase();
179+ const civic = ['A', 'B', 'C', 'D', 'E'].indexOf(l);
180+ if (civic !== -1) return civic;
181+ if (l === 'FDA ORIG' || l.startsWith('FDA')) return 0;
182+ const phase = Number(l);
183+ if (Number.isFinite(phase)) return phase >= 4 ? 1 : phase >= 3 ? 2 : phase >= 2 ? 3 : 4; // ChEMBL max phase
184+ return 50;
185+}
186+
187+/** Edge stroke family: `solid` for source-native curated/regulatory/published claims, `dashed` for derived or observed counts. */
188+export function edgeStroke(e: Pick<GraphEdge, 'derived' | 'evidenceCategory'>): 'solid' | 'dashed' {
189+ if (e.derived) return 'dashed';
190+ return e.evidenceCategory === 'observed_data' || e.evidenceCategory === 'computed_metric' ? 'dashed' : 'solid';
191+}
192+
193+// ---------------------------------------------------------------------------------------------
194+// Layout
195+// ---------------------------------------------------------------------------------------------
196+
197+export interface LayoutOptions {
198+ /** Square viewBox side (default 760). */
199+ size?: number;
200+ /** Hard cap on drawn neighbours (default 60); the table below the graph still lists everything. */
201+ maxNodes?: number;
202+ /** Angular gap between sectors, radians (default 0.16 ≈ 9°). */
203+ sectorGap?: number;
204+ /** Outer margin reserved for radial labels (default 140 ≈ an 18-character label at 11 px plus the mark). */
205+ labelMargin?: number;
206+}
207+
208+export interface PlacedNode {
209+ node: GraphNode;
210+ x: number;
211+ y: number;
212+ /** Mark radius (log of degree). */
213+ r: number;
214+ /** Polar angle in radians (0 = east, clockwise positive in SVG space). */
215+ angle: number;
216+ /** Ring radius from the centre. */
217+ ring: number;
218+}
219+
220+export interface Sector {
221+ type: NodeType;
222+ start: number;
223+ end: number;
224+ count: number;
225+ /** Ring radius used by this sector. */
226+ ring: number;
227+}
228+
229+export interface RadialLayout {
230+ size: number;
231+ cx: number;
232+ cy: number;
233+ focus: { x: number; y: number; r: number };
234+ nodes: PlacedNode[];
235+ sectors: Sector[];
236+ /** Neighbours that exist but were not drawn because of `maxNodes`. */
237+ hidden: number;
238+}
239+
240+const TAU = Math.PI * 2;
241+
242+export function nodeRadius(degree: number): number {
243+ const d = Math.max(0, degree);
244+ return Math.min(13, 4 + 2.2 * Math.log2(d + 1));
245+}
246+
247+/** Deterministic node ordering: degree desc, then label, then id. */
248+export function compareNodes(a: GraphNode, b: GraphNode): number {
249+ return b.degree - a.degree || a.label.localeCompare(b.label) || a.id.localeCompare(b.id);
250+}
251+
252+/**
253+ * Pick at most `max` nodes while keeping every entity type represented: round-robin over the types
254+ * (in fixed order), each type contributing its highest-degree node first. Deterministic.
255+ */
256+export function selectNodes(nodes: GraphNode[], max: number): { drawn: GraphNode[]; hidden: number } {
257+ const byType = new Map<NodeType, GraphNode[]>();
258+ for (const t of NODE_TYPE_ORDER) byType.set(t, []);
259+ for (const n of nodes) byType.get(n.type)?.push(n);
260+ for (const list of byType.values()) list.sort(compareNodes);
261+ const drawn: GraphNode[] = [];
262+ let progressed = true;
263+ while (drawn.length < max && progressed) {
264+ progressed = false;
265+ for (const t of NODE_TYPE_ORDER) {
266+ if (drawn.length >= max) break;
267+ const next = byType.get(t)!.shift();
268+ if (next) {
269+ drawn.push(next);
270+ progressed = true;
271+ }
272+ }
273+ }
274+ return { drawn, hidden: Math.max(0, nodes.length - drawn.length) };
275+}
276+
277+/**
278+ * Radial layout: focus at the centre, neighbours on sector arcs grouped by entity type in the fixed
279+ * order cancer → gene → variant → drug → trial → approval (clockwise from the top). Sector width is
280+ * proportional to node count with a minimum so small groups stay legible; sectors never overlap;
281+ * each sector sits on its own ring radius (alternating three radii) so labels at sector borders do
282+ * not collide. Positions are a pure function of the input.
283+ */
284+export function layoutRadial(nodesIn: GraphNode[], opts: LayoutOptions = {}): RadialLayout {
285+ const size = opts.size ?? 760;
286+ const maxNodes = opts.maxNodes ?? 60;
287+ const gap = opts.sectorGap ?? 0.16;
288+ const labelMargin = opts.labelMargin ?? 140;
289+ const cx = size / 2;
290+ const cy = size / 2;
291+ const { drawn, hidden } = selectNodes(nodesIn, maxNodes);
292+
293+ const counts = new Map<NodeType, GraphNode[]>();
294+ for (const n of drawn) counts.set(n.type, [...(counts.get(n.type) ?? []), n]);
295+ const present = NODE_TYPE_ORDER.filter((t) => (counts.get(t)?.length ?? 0) > 0);
296+ const total = drawn.length;
297+
298+ const outerRing = size / 2 - labelMargin;
299+ const rings = [outerRing, outerRing * 0.84, outerRing * 0.92];
300+
301+ const sectors: Sector[] = [];
302+ const nodes: PlacedNode[] = [];
303+ if (present.length === 0) return { size, cx, cy, focus: { x: cx, y: cy, r: 16 }, nodes, sectors, hidden };
304+
305+ // Angular budget: full circle minus one gap per sector; each sector gets a share proportional to
306+ // its count, floored at `minShare` so a single node still gets breathing room.
307+ const usable = TAU - gap * present.length;
308+ const minShare = Math.min(0.35, usable / present.length / 2);
309+ const rawShares = present.map((t) => Math.max(minShare, (usable * counts.get(t)!.length) / total));
310+ const shareSum = rawShares.reduce((a, b) => a + b, 0);
311+ const shares = rawShares.map((s) => (s * usable) / shareSum);
312+
313+ let cursor = -Math.PI / 2 + gap / 2; // start at the top, clockwise
314+ present.forEach((t, i) => {
315+ const list = counts.get(t)!.sort(compareNodes);
316+ const span = shares[i]!;
317+ const ring = rings[i % rings.length]!;
318+ const start = cursor;
319+ const end = cursor + span;
320+ sectors.push({ type: t, start, end, count: list.length, ring });
321+ // Nodes are spread over the sector interior; a lone node sits at the sector's centre.
322+ const n = list.length;
323+ list.forEach((node, k) => {
324+ const frac = n === 1 ? 0.5 : (k + 0.5) / n;
325+ const angle = start + frac * span;
326+ nodes.push({ node, angle, ring, r: nodeRadius(node.degree), x: cx + ring * Math.cos(angle), y: cy + ring * Math.sin(angle) });
327+ });
328+ cursor = end + gap;
329+ });
330+
331+ return { size, cx, cy, focus: { x: cx, y: cy, r: 16 }, nodes, sectors, hidden };
332+}
333+
334+/** Radial label transform: text drawn along the spoke, flipped on the left half so it never reads upside down. */
335+export function labelPlacement(p: PlacedNode, offset = 6): { x: number; y: number; rotate: number; anchor: 'start' | 'end' } {
336+ const deg = (p.angle * 180) / Math.PI;
337+ const left = Math.cos(p.angle) < 0;
338+ const dist = p.r + offset;
339+ return { x: p.x + dist * Math.cos(p.angle), y: p.y + dist * Math.sin(p.angle), rotate: left ? deg + 180 : deg, anchor: left ? 'end' : 'start' };
340+}
341+
342+/** Truncate a label for the SVG (full text goes in `<title>`). */
343+export function shortLabel(s: string, max = 18): string {
344+ const t = s.trim();
345+ return t.length <= max ? t : `${t.slice(0, max - 1).trimEnd()}…`;
346+}
347+
348+/** Perpendicular offsets so several relationships between the same pair render as distinct parallel spokes. */
349+export function parallelOffsets(n: number, step = 3): number[] {
350+ const out: number[] = [];
351+ for (let i = 0; i < n; i++) out.push((i - (n - 1) / 2) * step);
352+ return out;
353+}
354+
355+// ---------------------------------------------------------------------------------------------
356+// Paths (cancer focus): cancer → gene → variant → drug → approval → trials
357+// ---------------------------------------------------------------------------------------------
358+
359+export interface PathChain {
360+ cancer: { id: string; slug: string; name: string };
361+ gene: { id: string; symbol: string; frequency: number | null; casesAffected: number | null; casesProfiled: number | null; cohorts: number };
362+ variant: { id: string; slug: string; name: string };
363+ drug: { id: string; slug: string; name: string };
364+ edge: { evidenceLevel: string | null; direction: string | null; supportCount: number; sourceIds: string[]; provenanceIds: number[]; contextIds: string[]; contextNames: string[] };
365+ approval: { id: number; jurisdiction: string; authority: string; approvalDate: string | null; status: string; cancerId: string | null; cancerName: string | null; tumorAgnostic: boolean; total: number } | null;
366+ trials: { total: number; active: number } | null;
367+}
368+
369+/** Rank chains: strongest evidence level, then support, then alteration frequency, then names (stable). */
370+export function compareChains(a: PathChain, b: PathChain): number {
371+ return (
372+ evidenceLevelRank(a.edge.evidenceLevel) - evidenceLevelRank(b.edge.evidenceLevel) ||
373+ b.edge.supportCount - a.edge.supportCount ||
374+ (b.gene.frequency ?? -1) - (a.gene.frequency ?? -1) ||
375+ a.gene.symbol.localeCompare(b.gene.symbol) ||
376+ a.variant.name.localeCompare(b.variant.name) ||
377+ a.drug.name.localeCompare(b.drug.name)
378+ );
379+}
added apps/web/src/lib/queries/graph.ts +852 −0
Large diff (852 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/test/graph-layout.test.ts +184 −0
Large diff (184 lines) is not loaded by default to keep the page fast.Load diff
added docs/methodology/knowledge-graph.md +153 −0
Large diff (154 lines) is not loaded by default to keep the page fast.Load diff
Menu

Theme