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>
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 | 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 | |
| 5 | − * entity (cancer | gene | variant | drug | biomarker | trial) with relationship type, cancer context, | |
| 6 | − * evidence level and provenance on every edge. Filled by the Knowledge Graph work package. | |
| 12 | + * Knowledge-graph routes (SPEC §18, §80). | |
| 13 | + * | |
| 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) => { | |
| 9 | − /* routes added by the knowledge-graph work package */ | |
| 27 | + | |
| 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:<slug></code>, <code className="ci-mono">gene:<symbol></code>, <code className="ci-mono">variant:<slug></code>, <code className="ci-mono">drug:<slug></code>, <code className="ci-mono">trial:<NCT id></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
@@ -0,0 +1,852 @@ | ||
| 1 | +import 'server-only'; | |
| 2 | +import { run, sql, safe } from '@/lib/db'; | |
| 3 | +import type { SQL } from 'drizzle-orm'; | |
| 4 | +import { getDescendantIds } from '@/lib/queries/cancers'; | |
| 5 | +import { ACTIVE_STATUSES } from '@/lib/queries/trials'; | |
| 6 | +import { type CancerContext, type EdgeGroup, type FocusRef, type GraphEdge, type GraphNode, type Neighborhood, type NodeType, type PathChain, compareChains, nodeKey, sortGroups } from '@/lib/graph-model'; | |
| 7 | + | |
| 8 | +/** | |
| 9 | + * Knowledge-graph neighbourhood queries. Two families of links: | |
| 10 | + * - source-native `knowledge_edges` rows (CIViC, ChEMBL, openFDA…) — never inferred by CancerIndex, | |
| 11 | + * aggregated for display per (neighbour, relationship, direction, evidence level, source); | |
| 12 | + * - derived relational links computed at query time from registry tables (trial_conditions, | |
| 13 | + * trial_interventions, cancer_gene_frequencies, drug_approvals, civic_evidence_items) — always | |
| 14 | + * flagged `derived: true` with the count / measurement that backs them. | |
| 15 | + * Schema is frozen: everything is derived at query time with per-group LIMITs. | |
| 16 | + * | |
| 17 | + * The public API (`apps/api/src/routes/graph.ts`) duplicates this SQL: this module is `server-only` | |
| 18 | + * and depends on the web `@/lib/db` helpers, so it cannot be imported from the Fastify app. | |
| 19 | + */ | |
| 20 | + | |
| 21 | +export const DEFAULT_GROUP_LIMIT = 25; | |
| 22 | +export const EXPANDED_GROUP_LIMIT = 200; | |
| 23 | +export const TRIAL_GROUP_LIMIT = 10; | |
| 24 | +/** Cohort thresholds for the derived gene ↔ cancer link (frequency and cases affected). */ | |
| 25 | +export const FREQ_MIN = 0.05; | |
| 26 | +export const CASES_MIN = 20; | |
| 27 | +export const PATHS_LIMIT = 8; | |
| 28 | +/** Cap on descendant ids rolled into a cancer focus (very broad families are truncated to their first N ids). */ | |
| 29 | +export const MAX_DESCENDANTS = 600; | |
| 30 | + | |
| 31 | +const inList = (ids: string[]): SQL => sql`(${sql.join(ids.map((i) => sql`${i}`), sql`, `)})`; | |
| 32 | +const activeList = (): SQL => sql`(${sql.join(ACTIVE_STATUSES.map((s) => sql`${s}`), sql`, `)})`; | |
| 33 | +const n = (v: unknown): number => (v === null || v === undefined ? 0 : Number(v)); | |
| 34 | +const groupLimit = (rel: string, more: string | null | undefined, base = DEFAULT_GROUP_LIMIT): number => (more && more.toUpperCase() === rel ? EXPANDED_GROUP_LIMIT : base); | |
| 35 | + | |
| 36 | +/** Native-scale rank used only for ORDER BY (never shown; the native level is what the UI displays). */ | |
| 37 | +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`; | |
| 38 | +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`; | |
| 39 | + | |
| 40 | +const hrefFor = (type: NodeType, ref: string): string => { | |
| 41 | + switch (type) { | |
| 42 | + case 'cancer': | |
| 43 | + return `/cancer/${ref}`; | |
| 44 | + case 'gene': | |
| 45 | + return `/gene/${ref}`; | |
| 46 | + case 'variant': | |
| 47 | + return `/variant/${ref}`; | |
| 48 | + case 'drug': | |
| 49 | + return `/drug/${ref}`; | |
| 50 | + case 'trial': | |
| 51 | + return `/trial/${ref}`; | |
| 52 | + default: | |
| 53 | + return ref; | |
| 54 | + } | |
| 55 | +}; | |
| 56 | + | |
| 57 | +// --------------------------------------------------------------------------------------------- | |
| 58 | +// Focus resolution | |
| 59 | +// --------------------------------------------------------------------------------------------- | |
| 60 | + | |
| 61 | +export interface FocusNode extends GraphNode { | |
| 62 | + type: Exclude<NodeType, 'approval'>; | |
| 63 | + ref: string; | |
| 64 | +} | |
| 65 | + | |
| 66 | +export async function resolveFocus(f: FocusRef): Promise<FocusNode | null> { | |
| 67 | + const ref = f.ref.trim(); | |
| 68 | + const isCi = /^CI-[A-Z]+-\d+$/i.test(ref); | |
| 69 | + type Row = { id: string; ref: string; label: string; sublabel: string | null }; | |
| 70 | + let rows: Row[] = []; | |
| 71 | + switch (f.type) { | |
| 72 | + case 'cancer': | |
| 73 | + rows = await safe(() => run<Row>(sql`SELECT id, slug AS ref, canonical_name AS label, entity_type AS sublabel FROM cancers WHERE ${isCi ? sql`id = ${ref.toUpperCase()}` : sql`slug = ${ref.toLowerCase()}`} LIMIT 1`), []); | |
| 74 | + break; | |
| 75 | + case 'gene': | |
| 76 | + rows = await safe( | |
| 77 | + () => | |
| 78 | + run<Row>(sql`SELECT g.id, g.symbol AS ref, g.symbol AS label, g.name AS sublabel FROM genes g WHERE ${isCi ? sql`g.id = ${ref.toUpperCase()}` : sql`upper(g.symbol) = upper(${ref}) OR g.hgnc_id = ${ref}`} | |
| 79 | + UNION ALL SELECT g.id, g.symbol, g.symbol, g.name FROM genes g JOIN gene_aliases a ON a.gene_id = g.id WHERE upper(a.alias) = upper(${ref}) LIMIT 1`), | |
| 80 | + [], | |
| 81 | + ); | |
| 82 | + break; | |
| 83 | + case 'variant': | |
| 84 | + rows = await safe(() => run<Row>(sql`SELECT id, slug AS ref, coalesce(gene_symbol || ' ', '') || name AS label, variant_type AS sublabel FROM variants WHERE ${isCi ? sql`id = ${ref.toUpperCase()}` : sql`slug = ${ref.toLowerCase()}`} LIMIT 1`), []); | |
| 85 | + break; | |
| 86 | + case 'drug': | |
| 87 | + rows = await safe(() => run<Row>(sql`SELECT id, slug AS ref, name AS label, kind AS sublabel FROM drugs WHERE ${isCi ? sql`id = ${ref.toUpperCase()}` : sql`slug = ${ref.toLowerCase()}`} LIMIT 1`), []); | |
| 88 | + break; | |
| 89 | + case 'trial': | |
| 90 | + rows = await safe(() => run<Row>(sql`SELECT id, nct_id AS ref, brief_title AS label, overall_status AS sublabel FROM clinical_trials WHERE ${isCi ? sql`id = ${ref.toUpperCase()}` : sql`upper(nct_id) = upper(${ref})`} LIMIT 1`), []); | |
| 91 | + break; | |
| 92 | + } | |
| 93 | + const r = rows[0]; | |
| 94 | + if (!r) return null; | |
| 95 | + return { type: f.type, id: r.id, ref: r.ref, label: r.label, sublabel: r.sublabel, href: hrefFor(f.type, r.ref), degree: 0 }; | |
| 96 | +} | |
| 97 | + | |
| 98 | +// --------------------------------------------------------------------------------------------- | |
| 99 | +// Source-native knowledge edges | |
| 100 | +// --------------------------------------------------------------------------------------------- | |
| 101 | + | |
| 102 | +interface KeRow { | |
| 103 | + relationship_type: string; | |
| 104 | + outgoing: boolean; | |
| 105 | + ctx_only: boolean; | |
| 106 | + n_type: NodeType; | |
| 107 | + n_id: string; | |
| 108 | + n_ref: string | null; | |
| 109 | + n_label: string | null; | |
| 110 | + n_sublabel: string | null; | |
| 111 | + via_type: NodeType | null; | |
| 112 | + via_id: string | null; | |
| 113 | + via_ref: string | null; | |
| 114 | + via_label: string | null; | |
| 115 | + direction: string | null; | |
| 116 | + evidence_level: string | null; | |
| 117 | + evidence_category: string; | |
| 118 | + source_id: string; | |
| 119 | + source_slug: string; | |
| 120 | + support: string; | |
| 121 | + edge_ids: number[]; | |
| 122 | + provenance_ids: number[]; | |
| 123 | + context_ids: string[]; | |
| 124 | + last_seen: Date | null; | |
| 125 | + total: string; | |
| 126 | + rn: string; | |
| 127 | +} | |
| 128 | + | |
| 129 | +/** | |
| 130 | + * Edges where the focus is the source or the target (both directions), plus — for a cancer focus — | |
| 131 | + * edges where the cancer is only the *context* (variant → drug in this cancer), aggregated per | |
| 132 | + * (neighbour, relationship, direction, level, source). Per-relationship LIMIT via row_number(). | |
| 133 | + */ | |
| 134 | +async function knowledgeEdges(focus: FocusNode, more: string | null): Promise<KeRow[]> { | |
| 135 | + const t = focus.type; | |
| 136 | + const id = focus.id; | |
| 137 | + 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``; | |
| 138 | + return safe( | |
| 139 | + () => | |
| 140 | + run<KeRow>(sql` | |
| 141 | + WITH e AS ( | |
| 142 | + SELECT ke.relationship_type, | |
| 143 | + (ke.source_entity_type = ${t} AND ke.source_entity_id = ${id}) AS outgoing, | |
| 144 | + (ke.source_entity_id <> ${id} AND ke.target_entity_id <> ${id}) AS ctx_only, | |
| 145 | + CASE WHEN ke.source_entity_id = ${id} THEN ke.target_entity_type ELSE ke.source_entity_type END AS n_type, | |
| 146 | + CASE WHEN ke.source_entity_id = ${id} THEN ke.target_entity_id ELSE ke.source_entity_id END AS n_id, | |
| 147 | + CASE WHEN ke.source_entity_id <> ${id} AND ke.target_entity_id <> ${id} THEN ke.target_entity_type END AS via_type, | |
| 148 | + CASE WHEN ke.source_entity_id <> ${id} AND ke.target_entity_id <> ${id} THEN ke.target_entity_id END AS via_id, | |
| 149 | + 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, | |
| 150 | + ${LEVEL_RANK} AS lvl | |
| 151 | + FROM knowledge_edges ke | |
| 152 | + 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}) | |
| 153 | + ), a AS ( | |
| 154 | + 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, | |
| 155 | + sum(support_count) AS support, array_agg(id ORDER BY id) AS edge_ids, | |
| 156 | + (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, | |
| 157 | + (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, | |
| 158 | + max(last_seen_at) AS last_seen | |
| 159 | + FROM e GROUP BY 1,2,3,4,5,6,7,8,9,10,11 | |
| 160 | + ), r AS ( | |
| 161 | + 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, | |
| 162 | + count(*) OVER (PARTITION BY a.relationship_type) AS total | |
| 163 | + FROM a | |
| 164 | + ) | |
| 165 | + 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, s.slug AS source_slug, | |
| 166 | + r.support, r.edge_ids, r.provenance_ids, r.context_ids, r.last_seen, r.total, r.rn, | |
| 167 | + coalesce(c.slug, g.symbol, v.slug, d.slug) AS n_ref, | |
| 168 | + coalesce(c.canonical_name, g.symbol, coalesce(v.gene_symbol || ' ', '') || v.name, d.name) AS n_label, | |
| 169 | + coalesce(c.entity_type, g.name, v.variant_type, d.kind) AS n_sublabel, | |
| 170 | + coalesce(vc.slug, vg.symbol, vv.slug, vd.slug) AS via_ref, | |
| 171 | + coalesce(vc.canonical_name, vg.symbol, coalesce(vv.gene_symbol || ' ', '') || vv.name, vd.name) AS via_label | |
| 172 | + FROM r | |
| 173 | + JOIN sources s ON s.id = r.source_id | |
| 174 | + LEFT JOIN cancers c ON r.n_type = 'cancer' AND c.id = r.n_id | |
| 175 | + LEFT JOIN genes g ON r.n_type = 'gene' AND g.id = r.n_id | |
| 176 | + LEFT JOIN variants v ON r.n_type = 'variant' AND v.id = r.n_id | |
| 177 | + LEFT JOIN drugs d ON r.n_type = 'drug' AND d.id = r.n_id | |
| 178 | + LEFT JOIN cancers vc ON r.via_type = 'cancer' AND vc.id = r.via_id | |
| 179 | + LEFT JOIN genes vg ON r.via_type = 'gene' AND vg.id = r.via_id | |
| 180 | + LEFT JOIN variants vv ON r.via_type = 'variant' AND vv.id = r.via_id | |
| 181 | + LEFT JOIN drugs vd ON r.via_type = 'drug' AND vd.id = r.via_id | |
| 182 | + WHERE r.rn <= CASE WHEN r.relationship_type = ${(more ?? '').toUpperCase()}::text THEN ${EXPANDED_GROUP_LIMIT}::int ELSE ${DEFAULT_GROUP_LIMIT}::int END | |
| 183 | + ORDER BY r.relationship_type, r.rn`), | |
| 184 | + [] as KeRow[], | |
| 185 | + ); | |
| 186 | +} | |
| 187 | + | |
| 188 | +// --------------------------------------------------------------------------------------------- | |
| 189 | +// Derived links (registry counts) — each returns ready-made edges + nodes | |
| 190 | +// --------------------------------------------------------------------------------------------- | |
| 191 | + | |
| 192 | +interface Derived { | |
| 193 | + relationshipType: string; | |
| 194 | + total: number; | |
| 195 | + edges: GraphEdge[]; | |
| 196 | + nodes: GraphNode[]; | |
| 197 | +} | |
| 198 | + | |
| 199 | +const SRC = { clinicaltrials: 'CI-SOURCE-00000004', civic: 'CI-SOURCE-00000006', gdc: 'CI-SOURCE-00000008', openfda: 'CI-SOURCE-00000016', cbioportal: 'CI-SOURCE-00000017' } as const; | |
| 200 | + | |
| 201 | +function pct(v: number): string { | |
| 202 | + return `${(v * 100).toFixed(v >= 0.1 ? 0 : 1)} %`; | |
| 203 | +} | |
| 204 | + | |
| 205 | +/** cancer ⇄ gene through cohort alteration frequencies (largest cohort per pair — biggest denominator, not highest frequency; thresholds applied to that cohort row). */ | |
| 206 | +async function frequencyLinks(side: 'cancer' | 'gene', focus: FocusNode, ids: string[], limit: number): Promise<Derived> { | |
| 207 | + 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; source_slug: string; provenance_id: number; cohorts: string; total: string }; | |
| 208 | + 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`; | |
| 209 | + const part = side === 'cancer' ? sql`f.gene_id` : sql`f.cancer_id`; | |
| 210 | + const order = side === 'cancer' ? sql`g.is_cancer_gene DESC, f.frequency DESC, g.symbol` : sql`f.frequency DESC, c.canonical_name`; | |
| 211 | + const rows = await safe( | |
| 212 | + () => | |
| 213 | + run<Row>(sql` | |
| 214 | + WITH f AS ( | |
| 215 | + 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, | |
| 216 | + count(*) OVER (PARTITION BY ${part}) AS cohorts, | |
| 217 | + row_number() OVER (PARTITION BY ${part} ORDER BY f.cases_profiled DESC, f.frequency DESC, f.id) AS rn | |
| 218 | + FROM cancer_gene_frequencies f JOIN genomic_cohorts co ON co.id = f.cohort_id | |
| 219 | + WHERE ${where} AND f.frequency >= ${FREQ_MIN} AND f.cases_affected >= ${CASES_MIN} | |
| 220 | + ) | |
| 221 | + SELECT f.*, g.symbol, g.is_cancer_gene, c.slug AS cancer_slug, c.canonical_name AS cancer_name, s.slug AS source_slug, count(*) OVER() AS total | |
| 222 | + FROM f JOIN genes g ON g.id = f.gene_id JOIN cancers c ON c.id = f.cancer_id JOIN sources s ON s.id = f.source_id | |
| 223 | + WHERE f.rn = 1 ORDER BY ${order} LIMIT ${limit}`), | |
| 224 | + [] as Row[], | |
| 225 | + ); | |
| 226 | + const edges: GraphEdge[] = []; | |
| 227 | + const nodes: GraphNode[] = []; | |
| 228 | + for (const r of rows) { | |
| 229 | + const neighbor: GraphNode = | |
| 230 | + side === 'cancer' | |
| 231 | + ? { type: 'gene', id: r.gene_id, ref: r.symbol, label: r.symbol, sublabel: r.is_cancer_gene ? 'cancer gene' : null, href: hrefFor('gene', r.symbol), degree: 0 } | |
| 232 | + : { type: 'cancer', id: r.cancer_id, ref: r.cancer_slug, label: r.cancer_name, sublabel: null, href: hrefFor('cancer', r.cancer_slug), degree: 0 }; | |
| 233 | + nodes.push(neighbor); | |
| 234 | + edges.push({ | |
| 235 | + key: `dv:freq:${r.gene_id}:${r.cancer_id}`, | |
| 236 | + relationshipType: 'ALTERED_IN', | |
| 237 | + neighborKey: nodeKey(neighbor), | |
| 238 | + outgoing: side === 'gene', | |
| 239 | + direction: null, | |
| 240 | + evidenceLevel: null, | |
| 241 | + evidenceCategory: 'observed_data', | |
| 242 | + cancerContext: [{ id: r.cancer_id, name: r.cancer_name, slug: r.cancer_slug }], | |
| 243 | + supportCount: n(r.cohorts), | |
| 244 | + sourceIds: [r.source_id], | |
| 245 | + sourceSlugs: [r.source_slug], | |
| 246 | + provenanceIds: [r.provenance_id], | |
| 247 | + derived: true, | |
| 248 | + detail: `${r.cases_affected.toLocaleString('en-US')} / ${r.cases_profiled.toLocaleString('en-US')} cases (${pct(r.frequency)}) · ${r.alteration_type} · ${r.study_id}${n(r.cohorts) > 1 ? ` · largest of ${n(r.cohorts)} cohorts` : ''}`, | |
| 249 | + }); | |
| 250 | + } | |
| 251 | + return { relationshipType: 'ALTERED_IN', total: rows.length ? n(rows[0]!.total) : 0, edges, nodes }; | |
| 252 | +} | |
| 253 | + | |
| 254 | +/** cancer → trials (registry, deduplicated per trial; most recently updated first). */ | |
| 255 | +async function cancerTrialLinks(focus: FocusNode, ids: string[], limit: number): Promise<Derived> { | |
| 256 | + 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 }; | |
| 257 | + const rows = await safe( | |
| 258 | + () => | |
| 259 | + run<Row>(sql` | |
| 260 | + WITH m AS ( | |
| 261 | + 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 | |
| 262 | + ) | |
| 263 | + 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, | |
| 264 | + count(*) OVER() AS total, count(*) FILTER (WHERE t.overall_status IN ${activeList()}) OVER() AS active | |
| 265 | + FROM m JOIN clinical_trials t ON t.id = m.trial_id JOIN cancers c ON c.id = m.cancer_id | |
| 266 | + ORDER BY t.last_update_posted_date DESC NULLS LAST, t.nct_id LIMIT ${limit}`), | |
| 267 | + [] as Row[], | |
| 268 | + ); | |
| 269 | + const edges: GraphEdge[] = []; | |
| 270 | + const nodes: GraphNode[] = []; | |
| 271 | + for (const r of rows) { | |
| 272 | + const node: GraphNode = { type: 'trial', id: r.id, ref: r.nct_id, label: r.nct_id, sublabel: [r.phases.map((p) => p.replace('PHASE', 'Phase ').replace('EARLY_', 'early ')).join('/'), r.overall_status?.toLowerCase().replace(/_/g, ' ')].filter(Boolean).join(' · ') || null, href: hrefFor('trial', r.nct_id), degree: 0 }; | |
| 273 | + nodes.push(node); | |
| 274 | + edges.push({ | |
| 275 | + key: `dv:trial:${r.id}`, | |
| 276 | + relationshipType: 'STUDIED_IN', | |
| 277 | + neighborKey: nodeKey(node), | |
| 278 | + outgoing: true, | |
| 279 | + direction: null, | |
| 280 | + evidenceLevel: null, | |
| 281 | + evidenceCategory: 'observed_data', | |
| 282 | + cancerContext: [{ id: r.cancer_id, name: r.cancer_name, slug: r.cancer_slug }], | |
| 283 | + supportCount: 1, | |
| 284 | + sourceIds: [SRC.clinicaltrials], | |
| 285 | + sourceSlugs: ['clinicaltrials'], | |
| 286 | + provenanceIds: [], | |
| 287 | + derived: true, | |
| 288 | + detail: `${r.brief_title} · condition mapped ${r.match_type}`, | |
| 289 | + date: r.last_update_posted_date, | |
| 290 | + }); | |
| 291 | + } | |
| 292 | + const total = rows.length ? n(rows[0]!.total) : 0; | |
| 293 | + const active = rows.length ? n(rows[0]!.active) : 0; | |
| 294 | + for (const e of edges) e.detail = `${e.detail} · ${total.toLocaleString('en-US')} trials mapped (${active.toLocaleString('en-US')} active)`; | |
| 295 | + return { relationshipType: 'STUDIED_IN', total, edges, nodes }; | |
| 296 | +} | |
| 297 | + | |
| 298 | +/** cancer → drugs through registered trials (trial_interventions.drug_id × trial_conditions.cancer_id). */ | |
| 299 | +async function cancerDrugTrialLinks(focus: FocusNode, ids: string[], limit: number): Promise<Derived> { | |
| 300 | + type Row = { drug_id: string; slug: string; name: string; kind: string | null; trials: string; active: string; last: string | null; total: string }; | |
| 301 | + const rows = await safe( | |
| 302 | + () => | |
| 303 | + run<Row>(sql` | |
| 304 | + SELECT ti.drug_id, d.slug, d.name, d.kind, count(DISTINCT ti.trial_id) AS trials, | |
| 305 | + 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 | |
| 306 | + FROM trial_conditions tc JOIN trial_interventions ti ON ti.trial_id = tc.trial_id AND ti.drug_id IS NOT NULL | |
| 307 | + JOIN clinical_trials t ON t.id = tc.trial_id JOIN drugs d ON d.id = ti.drug_id | |
| 308 | + WHERE tc.cancer_id IN ${inList(ids)} | |
| 309 | + GROUP BY ti.drug_id, d.slug, d.name, d.kind ORDER BY trials DESC, d.name LIMIT ${limit}`), | |
| 310 | + [] as Row[], | |
| 311 | + ); | |
| 312 | + return drugTrialRows(focus, rows, 'INVESTIGATED_IN_TRIALS', true); | |
| 313 | +} | |
| 314 | + | |
| 315 | +function drugTrialRows(focus: FocusNode, rows: Array<{ drug_id: string; slug: string; name: string; kind: string | null; trials: string; active: string; last: string | null; total: string }>, rel: string, outgoing: boolean): Derived { | |
| 316 | + const edges: GraphEdge[] = []; | |
| 317 | + const nodes: GraphNode[] = []; | |
| 318 | + for (const r of rows) { | |
| 319 | + const node: GraphNode = { type: 'drug', id: r.drug_id, ref: r.slug, label: r.name, sublabel: r.kind?.replace(/_/g, ' ') ?? null, href: hrefFor('drug', r.slug), degree: 0 }; | |
| 320 | + nodes.push(node); | |
| 321 | + edges.push({ | |
| 322 | + key: `dv:drugtrials:${r.drug_id}:${focus.id}`, | |
| 323 | + relationshipType: rel, | |
| 324 | + neighborKey: nodeKey(node), | |
| 325 | + outgoing, | |
| 326 | + direction: null, | |
| 327 | + evidenceLevel: null, | |
| 328 | + evidenceCategory: 'observed_data', | |
| 329 | + cancerContext: focus.type === 'cancer' ? [{ id: focus.id, name: focus.label, slug: focus.ref }] : [], | |
| 330 | + supportCount: n(r.trials), | |
| 331 | + sourceIds: [SRC.clinicaltrials], | |
| 332 | + sourceSlugs: ['clinicaltrials'], | |
| 333 | + provenanceIds: [], | |
| 334 | + derived: true, | |
| 335 | + detail: `${n(r.trials).toLocaleString('en-US')} trials (${n(r.active).toLocaleString('en-US')} active)${focus.type === 'cancer' ? ' · roll-up of the cancer and its descendants' : ''}`, | |
| 336 | + date: r.last, | |
| 337 | + }); | |
| 338 | + } | |
| 339 | + return { relationshipType: rel, total: rows.length ? n(rows[0]!.total) : 0, edges, nodes }; | |
| 340 | +} | |
| 341 | + | |
| 342 | +/** cancer ← drug through regulatory approvals (jurisdiction-aware, dated); rows already present as APPROVED_FOR knowledge edges are skipped. */ | |
| 343 | +async function approvalLinks(side: 'cancer' | 'drug', focus: FocusNode, ids: string[], limit: number): Promise<Derived> { | |
| 344 | + 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; source_slug: string; provenance_id: number; total: string }; | |
| 345 | + 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))`; | |
| 346 | + const rows = await safe( | |
| 347 | + () => | |
| 348 | + run<Row>(sql` | |
| 349 | + 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, | |
| 350 | + a.source_id, s.slug AS source_slug, a.provenance_id, count(*) OVER() AS total | |
| 351 | + FROM drug_approvals a JOIN drugs d ON d.id = a.drug_id LEFT JOIN cancers c ON c.id = a.cancer_id JOIN sources s ON s.id = a.source_id | |
| 352 | + WHERE ${where} ORDER BY a.approval_date DESC NULLS LAST, a.id LIMIT ${limit}`), | |
| 353 | + [] as Row[], | |
| 354 | + ); | |
| 355 | + const edges: GraphEdge[] = []; | |
| 356 | + const nodes: GraphNode[] = []; | |
| 357 | + for (const r of rows) { | |
| 358 | + let node: GraphNode; | |
| 359 | + if (side === 'cancer') node = { type: 'drug', id: r.drug_id, ref: r.drug_slug, label: r.drug_name, sublabel: r.kind?.replace(/_/g, ' ') ?? null, href: hrefFor('drug', r.drug_slug), degree: 0 }; | |
| 360 | + else if (r.cancer_id && r.cancer_slug && r.cancer_name) node = { type: 'cancer', id: r.cancer_id, ref: r.cancer_slug, label: r.cancer_name, sublabel: null, href: hrefFor('cancer', r.cancer_slug), degree: 0 }; | |
| 361 | + else node = { 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`, degree: 0 }; | |
| 362 | + nodes.push(node); | |
| 363 | + edges.push({ | |
| 364 | + key: `dv:approval:${r.id}`, | |
| 365 | + relationshipType: 'APPROVED_FOR', | |
| 366 | + neighborKey: nodeKey(node), | |
| 367 | + outgoing: side === 'drug', | |
| 368 | + direction: null, | |
| 369 | + evidenceLevel: r.status, | |
| 370 | + evidenceCategory: 'regulatory_status', | |
| 371 | + cancerContext: r.cancer_id && r.cancer_slug && r.cancer_name ? [{ id: r.cancer_id, name: r.cancer_name, slug: r.cancer_slug }] : [], | |
| 372 | + supportCount: 1, | |
| 373 | + sourceIds: [r.source_id], | |
| 374 | + sourceSlugs: [r.source_slug], | |
| 375 | + provenanceIds: [r.provenance_id], | |
| 376 | + derived: true, | |
| 377 | + detail: `${r.authority} (${r.jurisdiction}) · ${r.status}${r.tumor_agnostic ? ' · tumour-agnostic' : ''}${r.approval_date ? ` · ${r.approval_date}` : ' · date not published'} · ${r.indication.length > 140 ? `${r.indication.slice(0, 139)}…` : r.indication}`, | |
| 378 | + date: r.approval_date, | |
| 379 | + }); | |
| 380 | + } | |
| 381 | + return { relationshipType: 'APPROVED_FOR', total: rows.length ? n(rows[0]!.total) : 0, edges, nodes }; | |
| 382 | +} | |
| 383 | + | |
| 384 | +/** gene → variants (top by CIViC evidence count; the variant list itself is structural HGNC/ClinVar/CIViC data). */ | |
| 385 | +async function geneVariantLinks(focus: FocusNode, limit: number): Promise<Derived> { | |
| 386 | + type Row = { id: string; slug: string; name: string; variant_type: string | null; ev: string; context_ids: string[] | null; provenance_ids: number[] | null; total: string }; | |
| 387 | + const rows = await safe( | |
| 388 | + () => | |
| 389 | + run<Row>(sql` | |
| 390 | + WITH ev AS ( | |
| 391 | + 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 | |
| 392 | + FROM civic_evidence_items e CROSS JOIN LATERAL unnest(e.variant_ids) vid | |
| 393 | + WHERE e.status = 'ACCEPTED' AND (${focus.id} = ANY(e.gene_ids) OR ${focus.label} = ANY(e.gene_symbols)) GROUP BY vid | |
| 394 | + ) | |
| 395 | + 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 | |
| 396 | + FROM variants v LEFT JOIN ev ON ev.vid = v.id WHERE v.gene_id = ${focus.id} | |
| 397 | + ORDER BY coalesce(ev.ev, 0) DESC, v.name LIMIT ${limit}`), | |
| 398 | + [] as Row[], | |
| 399 | + ); | |
| 400 | + const edges: GraphEdge[] = []; | |
| 401 | + const nodes: GraphNode[] = []; | |
| 402 | + for (const r of rows) { | |
| 403 | + const node: GraphNode = { type: 'variant', id: r.id, ref: r.slug, label: r.name, sublabel: r.variant_type, href: hrefFor('variant', r.slug), degree: 0 }; | |
| 404 | + nodes.push(node); | |
| 405 | + const ev = n(r.ev); | |
| 406 | + edges.push({ | |
| 407 | + key: `dv:variant:${r.id}`, | |
| 408 | + relationshipType: 'HAS_VARIANT', | |
| 409 | + neighborKey: nodeKey(node), | |
| 410 | + outgoing: true, | |
| 411 | + direction: null, | |
| 412 | + evidenceLevel: null, | |
| 413 | + evidenceCategory: ev > 0 ? 'curated_evidence' : 'observed_data', | |
| 414 | + cancerContext: (r.context_ids ?? []).map((id) => ({ id, name: id, slug: '' })), | |
| 415 | + supportCount: ev, | |
| 416 | + sourceIds: [SRC.civic], | |
| 417 | + sourceSlugs: ['civic'], | |
| 418 | + provenanceIds: r.provenance_ids ?? [], | |
| 419 | + derived: true, | |
| 420 | + detail: ev > 0 ? `${ev.toLocaleString('en-US')} accepted CIViC evidence items` : 'no accepted CIViC evidence item (variant record only)', | |
| 421 | + }); | |
| 422 | + } | |
| 423 | + return { relationshipType: 'HAS_VARIANT', total: rows.length ? n(rows[0]!.total) : 0, edges, nodes }; | |
| 424 | +} | |
| 425 | + | |
| 426 | +/** variant → cancers with accepted CIViC evidence, aggregated by level (A–E) and direction. */ | |
| 427 | +async function variantEvidenceLinks(focus: FocusNode, limit: number): Promise<Derived> { | |
| 428 | + type Row = { cancer_id: string; slug: string; name: string; items: string; levels: string[]; best: string | null; sens: string; res: string; supports: string; does_not: string; provenance_ids: number[]; total: string }; | |
| 429 | + const rows = await safe( | |
| 430 | + () => | |
| 431 | + run<Row>(sql` | |
| 432 | + SELECT e.cancer_id, c.slug, c.canonical_name AS name, count(*) AS items, | |
| 433 | + array_agg(DISTINCT e.evidence_level ORDER BY e.evidence_level) FILTER (WHERE e.evidence_level IS NOT NULL) AS levels, | |
| 434 | + min(e.evidence_level) AS best, | |
| 435 | + count(*) FILTER (WHERE e.significance ILIKE '%SENSITIV%') AS sens, count(*) FILTER (WHERE e.significance ILIKE '%RESIST%') AS res, | |
| 436 | + count(*) FILTER (WHERE e.evidence_direction = 'SUPPORTS') AS supports, count(*) FILTER (WHERE e.evidence_direction = 'DOES_NOT_SUPPORT') AS does_not, | |
| 437 | + (array_agg(DISTINCT e.provenance_id))[1:50] AS provenance_ids, count(*) OVER() AS total | |
| 438 | + FROM civic_evidence_items e JOIN cancers c ON c.id = e.cancer_id | |
| 439 | + WHERE e.status = 'ACCEPTED' AND ${focus.id} = ANY(e.variant_ids) | |
| 440 | + GROUP BY e.cancer_id, c.slug, c.canonical_name ORDER BY min(${CIVIC_LEVEL_RANK}), items DESC, c.canonical_name LIMIT ${limit}`), | |
| 441 | + [] as Row[], | |
| 442 | + ); | |
| 443 | + const edges: GraphEdge[] = []; | |
| 444 | + const nodes: GraphNode[] = []; | |
| 445 | + for (const r of rows) { | |
| 446 | + const node: GraphNode = { type: 'cancer', id: r.cancer_id, ref: r.slug, label: r.name, sublabel: null, href: hrefFor('cancer', r.slug), degree: 0 }; | |
| 447 | + nodes.push(node); | |
| 448 | + const sens = n(r.sens); | |
| 449 | + const res = n(r.res); | |
| 450 | + edges.push({ | |
| 451 | + key: `dv:civic:${focus.id}:${r.cancer_id}`, | |
| 452 | + relationshipType: 'HAS_EVIDENCE_IN', | |
| 453 | + neighborKey: nodeKey(node), | |
| 454 | + outgoing: true, | |
| 455 | + direction: sens && res ? 'mixed' : sens ? 'sensitivity' : res ? 'resistance' : n(r.supports) && !n(r.does_not) ? 'supports' : n(r.does_not) ? 'does not support' : null, | |
| 456 | + evidenceLevel: r.best, | |
| 457 | + evidenceCategory: 'curated_evidence', | |
| 458 | + cancerContext: [{ id: r.cancer_id, name: r.name, slug: r.slug }], | |
| 459 | + supportCount: n(r.items), | |
| 460 | + sourceIds: [SRC.civic], | |
| 461 | + sourceSlugs: ['civic'], | |
| 462 | + provenanceIds: r.provenance_ids ?? [], | |
| 463 | + derived: true, | |
| 464 | + detail: `${n(r.items).toLocaleString('en-US')} accepted items · levels ${(r.levels ?? []).join(', ') || '—'} · ${sens} sensitivity / ${res} resistance · ${n(r.supports)} supports / ${n(r.does_not)} does not support`, | |
| 465 | + }); | |
| 466 | + } | |
| 467 | + return { relationshipType: 'HAS_EVIDENCE_IN', total: rows.length ? n(rows[0]!.total) : 0, edges, nodes }; | |
| 468 | +} | |
| 469 | + | |
| 470 | +/** variant → drugs from CIViC predictive items that have no PREDICTS_RESPONSE_TO knowledge edge yet (gap filler, flagged derived). */ | |
| 471 | +async function variantDrugCivicLinks(focus: FocusNode, limit: number): Promise<Derived> { | |
| 472 | + type Row = { drug_id: string; slug: string; name: string; kind: string | null; items: string; best: string | null; sens: string; res: string; context_ids: string[]; provenance_ids: number[]; total: string }; | |
| 473 | + const rows = await safe( | |
| 474 | + () => | |
| 475 | + run<Row>(sql` | |
| 476 | + SELECT tid AS drug_id, d.slug, d.name, d.kind, count(*) AS items, min(e.evidence_level) AS best, | |
| 477 | + count(*) FILTER (WHERE e.significance ILIKE '%SENSITIV%') AS sens, count(*) FILTER (WHERE e.significance ILIKE '%RESIST%') AS res, | |
| 478 | + (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 | |
| 479 | + FROM civic_evidence_items e CROSS JOIN LATERAL unnest(e.therapy_ids) tid JOIN drugs d ON d.id = tid | |
| 480 | + WHERE e.status = 'ACCEPTED' AND e.evidence_type = 'PREDICTIVE' AND ${focus.id} = ANY(e.variant_ids) | |
| 481 | + 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) | |
| 482 | + GROUP BY tid, d.slug, d.name, d.kind ORDER BY min(${CIVIC_LEVEL_RANK}), items DESC, d.name LIMIT ${limit}`), | |
| 483 | + [] as Row[], | |
| 484 | + ); | |
| 485 | + const edges: GraphEdge[] = []; | |
| 486 | + const nodes: GraphNode[] = []; | |
| 487 | + for (const r of rows) { | |
| 488 | + const node: GraphNode = { type: 'drug', id: r.drug_id, ref: r.slug, label: r.name, sublabel: r.kind?.replace(/_/g, ' ') ?? null, href: hrefFor('drug', r.slug), degree: 0 }; | |
| 489 | + nodes.push(node); | |
| 490 | + const sens = n(r.sens); | |
| 491 | + const res = n(r.res); | |
| 492 | + edges.push({ | |
| 493 | + key: `dv:civicdrug:${focus.id}:${r.drug_id}`, | |
| 494 | + relationshipType: 'PREDICTS_RESPONSE_TO', | |
| 495 | + neighborKey: nodeKey(node), | |
| 496 | + outgoing: true, | |
| 497 | + direction: sens && res ? 'mixed' : sens ? 'sensitivity' : res ? 'resistance' : null, | |
| 498 | + evidenceLevel: r.best, | |
| 499 | + evidenceCategory: 'curated_evidence', | |
| 500 | + cancerContext: (r.context_ids ?? []).map((id) => ({ id, name: id, slug: '' })), | |
| 501 | + supportCount: n(r.items), | |
| 502 | + sourceIds: [SRC.civic], | |
| 503 | + sourceSlugs: ['civic'], | |
| 504 | + provenanceIds: r.provenance_ids ?? [], | |
| 505 | + derived: true, | |
| 506 | + detail: `${n(r.items)} accepted predictive items (aggregated from CIViC, no knowledge edge yet)`, | |
| 507 | + }); | |
| 508 | + } | |
| 509 | + return { relationshipType: 'PREDICTS_RESPONSE_TO', total: rows.length ? n(rows[0]!.total) : 0, edges, nodes }; | |
| 510 | +} | |
| 511 | + | |
| 512 | +/** drug → trials (registry, most recently updated first) with the trial's mapped cancers as context. */ | |
| 513 | +async function drugTrialLinks(focus: FocusNode, limit: number): Promise<Derived> { | |
| 514 | + 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 }; | |
| 515 | + const rows = await safe( | |
| 516 | + () => | |
| 517 | + run<Row>(sql` | |
| 518 | + SELECT t.id, t.nct_id, t.brief_title, t.overall_status, t.phases, t.last_update_posted_date, | |
| 519 | + (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, | |
| 520 | + count(*) OVER() AS total, count(*) FILTER (WHERE t.overall_status IN ${activeList()}) OVER() AS active | |
| 521 | + FROM (SELECT DISTINCT trial_id FROM trial_interventions WHERE drug_id = ${focus.id}) ti JOIN clinical_trials t ON t.id = ti.trial_id | |
| 522 | + ORDER BY t.last_update_posted_date DESC NULLS LAST, t.nct_id LIMIT ${limit}`), | |
| 523 | + [] as Row[], | |
| 524 | + ); | |
| 525 | + const edges: GraphEdge[] = []; | |
| 526 | + const nodes: GraphNode[] = []; | |
| 527 | + const total = rows.length ? n(rows[0]!.total) : 0; | |
| 528 | + const active = rows.length ? n(rows[0]!.active) : 0; | |
| 529 | + for (const r of rows) { | |
| 530 | + const node: GraphNode = { type: 'trial', id: r.id, ref: r.nct_id, label: r.nct_id, sublabel: [r.phases.map((p) => p.replace('PHASE', 'Phase ').replace('EARLY_', 'early ')).join('/'), r.overall_status?.toLowerCase().replace(/_/g, ' ')].filter(Boolean).join(' · ') || null, href: hrefFor('trial', r.nct_id), degree: 0 }; | |
| 531 | + nodes.push(node); | |
| 532 | + edges.push({ | |
| 533 | + key: `dv:drugtrial:${r.id}`, | |
| 534 | + relationshipType: 'STUDIED_IN', | |
| 535 | + neighborKey: nodeKey(node), | |
| 536 | + outgoing: true, | |
| 537 | + direction: null, | |
| 538 | + evidenceLevel: null, | |
| 539 | + evidenceCategory: 'observed_data', | |
| 540 | + cancerContext: (r.context_ids ?? []).map((id) => ({ id, name: id, slug: '' })), | |
| 541 | + supportCount: 1, | |
| 542 | + sourceIds: [SRC.clinicaltrials], | |
| 543 | + sourceSlugs: ['clinicaltrials'], | |
| 544 | + provenanceIds: [], | |
| 545 | + derived: true, | |
| 546 | + detail: `${r.brief_title} · ${total.toLocaleString('en-US')} trials list this drug (${active.toLocaleString('en-US')} active)`, | |
| 547 | + date: r.last_update_posted_date, | |
| 548 | + }); | |
| 549 | + } | |
| 550 | + return { relationshipType: 'STUDIED_IN', total, edges, nodes }; | |
| 551 | +} | |
| 552 | + | |
| 553 | +/** drug → cancers through registered trials. */ | |
| 554 | +async function drugCancerTrialLinks(focus: FocusNode, limit: number): Promise<Derived> { | |
| 555 | + type Row = { cancer_id: string; slug: string; name: string; trials: string; active: string; last: string | null; total: string }; | |
| 556 | + const rows = await safe( | |
| 557 | + () => | |
| 558 | + run<Row>(sql` | |
| 559 | + SELECT tc.cancer_id, c.slug, c.canonical_name AS name, count(DISTINCT tc.trial_id) AS trials, | |
| 560 | + 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 | |
| 561 | + FROM trial_interventions ti JOIN trial_conditions tc ON tc.trial_id = ti.trial_id AND tc.cancer_id IS NOT NULL | |
| 562 | + JOIN clinical_trials t ON t.id = ti.trial_id JOIN cancers c ON c.id = tc.cancer_id | |
| 563 | + WHERE ti.drug_id = ${focus.id} | |
| 564 | + GROUP BY tc.cancer_id, c.slug, c.canonical_name ORDER BY trials DESC, c.canonical_name LIMIT ${limit}`), | |
| 565 | + [] as Row[], | |
| 566 | + ); | |
| 567 | + const edges: GraphEdge[] = []; | |
| 568 | + const nodes: GraphNode[] = []; | |
| 569 | + for (const r of rows) { | |
| 570 | + const node: GraphNode = { type: 'cancer', id: r.cancer_id, ref: r.slug, label: r.name, sublabel: null, href: hrefFor('cancer', r.slug), degree: 0 }; | |
| 571 | + nodes.push(node); | |
| 572 | + edges.push({ | |
| 573 | + key: `dv:drugtrials:${focus.id}:${r.cancer_id}`, | |
| 574 | + relationshipType: 'INVESTIGATED_IN_TRIALS', | |
| 575 | + neighborKey: nodeKey(node), | |
| 576 | + outgoing: true, | |
| 577 | + direction: null, | |
| 578 | + evidenceLevel: null, | |
| 579 | + evidenceCategory: 'observed_data', | |
| 580 | + cancerContext: [{ id: r.cancer_id, name: r.name, slug: r.slug }], | |
| 581 | + supportCount: n(r.trials), | |
| 582 | + sourceIds: [SRC.clinicaltrials], | |
| 583 | + sourceSlugs: ['clinicaltrials'], | |
| 584 | + provenanceIds: [], | |
| 585 | + derived: true, | |
| 586 | + detail: `${n(r.trials).toLocaleString('en-US')} trials (${n(r.active).toLocaleString('en-US')} active) · conditions mapped to this cancer only (no hierarchy roll-up)`, | |
| 587 | + date: r.last, | |
| 588 | + }); | |
| 589 | + } | |
| 590 | + return { relationshipType: 'INVESTIGATED_IN_TRIALS', total: rows.length ? n(rows[0]!.total) : 0, edges, nodes }; | |
| 591 | +} | |
| 592 | + | |
| 593 | +/** trial → mapped conditions (cancers) and interventions (drugs). */ | |
| 594 | +async function trialLinks(focus: FocusNode, limit: number): Promise<Derived[]> { | |
| 595 | + type CRow = { cancer_id: string; slug: string; name: string; match_type: string; condition_text: string; total: string }; | |
| 596 | + type DRow = { drug_id: string; slug: string; name: string; kind: string | null; match_type: string; intervention_type: string | null; iname: string; total: string }; | |
| 597 | + const [conds, ints] = await Promise.all([ | |
| 598 | + safe(() => run<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}`), [] as CRow[]), | |
| 599 | + safe(() => run<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}`), [] as DRow[]), | |
| 600 | + ]); | |
| 601 | + const c: Derived = { relationshipType: 'CONDITION_OF', total: conds.length ? n(conds[0]!.total) : 0, edges: [], nodes: [] }; | |
| 602 | + for (const r of conds) { | |
| 603 | + const node: GraphNode = { type: 'cancer', id: r.cancer_id, ref: r.slug, label: r.name, sublabel: null, href: hrefFor('cancer', r.slug), degree: 0 }; | |
| 604 | + c.nodes.push(node); | |
| 605 | + c.edges.push({ key: `dv:cond:${focus.id}:${r.cancer_id}`, relationshipType: 'CONDITION_OF', neighborKey: nodeKey(node), 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], sourceSlugs: ['clinicaltrials'], provenanceIds: [], derived: true, detail: `registry condition “${r.condition_text}” mapped ${r.match_type}` }); | |
| 606 | + } | |
| 607 | + const d: Derived = { relationshipType: 'INTERVENTION_OF', total: ints.length ? n(ints[0]!.total) : 0, edges: [], nodes: [] }; | |
| 608 | + for (const r of ints) { | |
| 609 | + const node: GraphNode = { type: 'drug', id: r.drug_id, ref: r.slug, label: r.name, sublabel: r.kind?.replace(/_/g, ' ') ?? null, href: hrefFor('drug', r.slug), degree: 0 }; | |
| 610 | + d.nodes.push(node); | |
| 611 | + d.edges.push({ key: `dv:int:${focus.id}:${r.drug_id}`, relationshipType: 'INTERVENTION_OF', neighborKey: nodeKey(node), outgoing: false, direction: null, evidenceLevel: null, evidenceCategory: 'observed_data', cancerContext: [], supportCount: 1, sourceIds: [SRC.clinicaltrials], sourceSlugs: ['clinicaltrials'], provenanceIds: [], derived: true, detail: `registry intervention “${r.iname}” (${r.intervention_type ?? 'type not stated'}) mapped ${r.match_type}` }); | |
| 612 | + } | |
| 613 | + return [c, d]; | |
| 614 | +} | |
| 615 | + | |
| 616 | +// --------------------------------------------------------------------------------------------- | |
| 617 | +// Assembly | |
| 618 | +// --------------------------------------------------------------------------------------------- | |
| 619 | + | |
| 620 | +async function cancerNames(ids: Iterable<string>): Promise<Map<string, CancerContext>> { | |
| 621 | + const uniq = [...new Set(ids)].filter(Boolean); | |
| 622 | + if (uniq.length === 0) return new Map(); | |
| 623 | + const rows = await safe(() => run<{ id: string; slug: string; name: string }>(sql`SELECT id, slug, canonical_name AS name FROM cancers WHERE id IN ${inList(uniq)}`), [] as Array<{ id: string; slug: string; name: string }>); | |
| 624 | + return new Map(rows.map((r) => [r.id, { id: r.id, slug: r.slug, name: r.name }])); | |
| 625 | +} | |
| 626 | + | |
| 627 | +/** Descendant ids (inclusive) for a cancer focus, capped so very broad families stay bounded. */ | |
| 628 | +export async function focusCancerIds(focus: FocusNode): Promise<string[]> { | |
| 629 | + if (focus.type !== 'cancer') return []; | |
| 630 | + const ids = await getDescendantIds(focus.id); | |
| 631 | + return ids.length > MAX_DESCENDANTS ? [focus.id, ...ids.filter((i) => i !== focus.id).slice(0, MAX_DESCENDANTS - 1)] : ids; | |
| 632 | +} | |
| 633 | + | |
| 634 | +export interface NeighborhoodOptions { | |
| 635 | + /** Relationship type whose group is expanded to EXPANDED_GROUP_LIMIT. */ | |
| 636 | + more?: string | null; | |
| 637 | + includeDerived?: boolean; | |
| 638 | +} | |
| 639 | + | |
| 640 | +export async function loadNeighborhood(focus: FocusNode, opts: NeighborhoodOptions = {}): Promise<Neighborhood & { cancerIds: string[] }> { | |
| 641 | + const more = opts.more ?? null; | |
| 642 | + const includeDerived = opts.includeDerived ?? true; | |
| 643 | + const cancerIds = await focusCancerIds(focus); | |
| 644 | + const lim = (rel: string, base = DEFAULT_GROUP_LIMIT) => groupLimit(rel, more, base); | |
| 645 | + | |
| 646 | + const derivedTasks: Array<Promise<Derived | Derived[]>> = []; | |
| 647 | + if (includeDerived) { | |
| 648 | + switch (focus.type) { | |
| 649 | + case 'cancer': | |
| 650 | + derivedTasks.push(cancerTrialLinks(focus, cancerIds, lim('STUDIED_IN', TRIAL_GROUP_LIMIT)), frequencyLinks('cancer', focus, cancerIds, lim('ALTERED_IN')), approvalLinks('cancer', focus, cancerIds, lim('APPROVED_FOR')), cancerDrugTrialLinks(focus, cancerIds, lim('INVESTIGATED_IN_TRIALS'))); | |
| 651 | + break; | |
| 652 | + case 'gene': | |
| 653 | + derivedTasks.push(geneVariantLinks(focus, lim('HAS_VARIANT')), frequencyLinks('gene', focus, [], lim('ALTERED_IN'))); | |
| 654 | + break; | |
| 655 | + case 'variant': | |
| 656 | + derivedTasks.push(variantEvidenceLinks(focus, lim('HAS_EVIDENCE_IN')), variantDrugCivicLinks(focus, lim('PREDICTS_RESPONSE_TO'))); | |
| 657 | + break; | |
| 658 | + case 'drug': | |
| 659 | + derivedTasks.push(drugTrialLinks(focus, lim('STUDIED_IN', TRIAL_GROUP_LIMIT)), drugCancerTrialLinks(focus, lim('INVESTIGATED_IN_TRIALS')), approvalLinks('drug', focus, [], lim('APPROVED_FOR'))); | |
| 660 | + break; | |
| 661 | + case 'trial': | |
| 662 | + derivedTasks.push(trialLinks(focus, lim('CONDITION_OF'))); | |
| 663 | + break; | |
| 664 | + } | |
| 665 | + } | |
| 666 | + const [ke, ...derivedRaw] = await Promise.all([focus.type === 'trial' ? Promise.resolve([] as KeRow[]) : knowledgeEdges(focus, more), ...derivedTasks]); | |
| 667 | + const derived = derivedRaw.flat(); | |
| 668 | + | |
| 669 | + // Source-native edges → GraphEdge + nodes | |
| 670 | + const nodes = new Map<string, GraphNode>(); | |
| 671 | + const groups = new Map<string, EdgeGroup>(); | |
| 672 | + const ctxIds = new Set<string>(); | |
| 673 | + for (const r of ke) for (const c of r.context_ids ?? []) ctxIds.add(c); | |
| 674 | + for (const d of derived) for (const e of d.edges) for (const c of e.cancerContext) if (!c.slug) ctxIds.add(c.id); | |
| 675 | + const names = await cancerNames(ctxIds); | |
| 676 | + const ctx = (ids: string[] | null | undefined): CancerContext[] => (ids ?? []).map((id) => names.get(id) ?? { id, name: id, slug: '' }).sort((a, b) => a.name.localeCompare(b.name)); | |
| 677 | + | |
| 678 | + const addNode = (node: GraphNode) => { | |
| 679 | + const k = nodeKey(node); | |
| 680 | + const cur = nodes.get(k); | |
| 681 | + if (cur) cur.degree += 1; | |
| 682 | + else nodes.set(k, { ...node, degree: 1 }); | |
| 683 | + }; | |
| 684 | + const addEdge = (e: GraphEdge, total: number, derivedGroup: boolean) => { | |
| 685 | + const g = groups.get(e.relationshipType) ?? { relationshipType: e.relationshipType, total: 0, edges: [], derived: derivedGroup }; | |
| 686 | + g.edges.push(e); | |
| 687 | + g.total = Math.max(g.total, total); | |
| 688 | + if (!derivedGroup) g.derived = false; | |
| 689 | + groups.set(e.relationshipType, g); | |
| 690 | + }; | |
| 691 | + | |
| 692 | + for (const r of ke) { | |
| 693 | + if (!r.n_ref || !r.n_label) continue; // dangling target (entity not loaded on this environment) | |
| 694 | + const node: GraphNode = { type: r.n_type, id: r.n_id, ref: r.n_ref, label: r.n_label, sublabel: r.n_sublabel, href: hrefFor(r.n_type, r.n_ref), degree: 0 }; | |
| 695 | + addNode(node); | |
| 696 | + addEdge( | |
| 697 | + { | |
| 698 | + key: `ke:${(r.edge_ids ?? []).join('.')}`, | |
| 699 | + relationshipType: r.relationship_type, | |
| 700 | + neighborKey: nodeKey(node), | |
| 701 | + outgoing: r.outgoing, | |
| 702 | + direction: r.direction, | |
| 703 | + evidenceLevel: r.evidence_level, | |
| 704 | + evidenceCategory: r.evidence_category, | |
| 705 | + cancerContext: ctx(r.context_ids), | |
| 706 | + supportCount: n(r.support), | |
| 707 | + sourceIds: [r.source_id], | |
| 708 | + sourceSlugs: [r.source_slug], | |
| 709 | + provenanceIds: (r.provenance_ids ?? []).map(Number), | |
| 710 | + derived: false, | |
| 711 | + detail: (r.edge_ids?.length ?? 1) > 1 ? `${r.edge_ids.length} source records aggregated` : null, | |
| 712 | + date: r.last_seen ? new Date(r.last_seen).toISOString().slice(0, 10) : null, | |
| 713 | + 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: hrefFor(r.via_type, r.via_ref) } : null, | |
| 714 | + }, | |
| 715 | + n(r.total), | |
| 716 | + false, | |
| 717 | + ); | |
| 718 | + } | |
| 719 | + for (const d of derived) { | |
| 720 | + for (let i = 0; i < d.edges.length; i++) { | |
| 721 | + const e = d.edges[i]!; | |
| 722 | + const node = d.nodes[i]!; | |
| 723 | + addNode(node); | |
| 724 | + e.cancerContext = e.cancerContext.map((c) => (c.slug ? c : (names.get(c.id) ?? c))); | |
| 725 | + addEdge(e, d.total, true); | |
| 726 | + } | |
| 727 | + } | |
| 728 | + | |
| 729 | + const degreeByType: Record<NodeType, number> = { cancer: 0, gene: 0, variant: 0, drug: 0, trial: 0, approval: 0 }; | |
| 730 | + for (const node of nodes.values()) degreeByType[node.type] += 1; | |
| 731 | + const focusOut: GraphNode = { ...focus, degree: [...groups.values()].reduce((a, g) => a + g.total, 0) }; | |
| 732 | + return { focus: focusOut, nodes: [...nodes.values()], groups: sortGroups([...groups.values()]), degreeByType, cancerIds }; | |
| 733 | +} | |
| 734 | + | |
| 735 | +// --------------------------------------------------------------------------------------------- | |
| 736 | +// Paths (cancer focus) | |
| 737 | +// --------------------------------------------------------------------------------------------- | |
| 738 | + | |
| 739 | +/** | |
| 740 | + * Strongest cancer → gene → variant → drug → approval → trials chains. The variant → drug hop is a | |
| 741 | + * source-native PREDICTS_RESPONSE_TO edge (direction sensitivity) whose context includes the cancer | |
| 742 | + * or one of its descendants; the gene hop is the variant's gene with its top cohort frequency in | |
| 743 | + * the cancer — the cohort with the largest denominator (null when no cohort covers it — shown as "not yet available", never invented); the | |
| 744 | + * approval hop is the earliest drug_approvals row in the cancer (or tumour-agnostic); the trial | |
| 745 | + * hop counts registry trials listing the drug for the cancer. Ranked by level, then support. | |
| 746 | + */ | |
| 747 | +export async function loadPaths(focus: FocusNode, cancerIds: string[], limit = PATHS_LIMIT): Promise<PathChain[]> { | |
| 748 | + if (focus.type !== 'cancer' || cancerIds.length === 0) return []; | |
| 749 | + type Row = { | |
| 750 | + variant_id: string; variant_slug: string; variant_name: string; gene_id: string; symbol: string; drug_id: string; drug_slug: string; drug_name: string; | |
| 751 | + evidence_level: string | null; direction: string | null; support: string; source_ids: string[]; provenance_ids: number[]; context_ids: string[]; | |
| 752 | + frequency: number | null; cases_affected: number | null; cases_profiled: number | null; cohorts: string | null; | |
| 753 | + 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; | |
| 754 | + trials: string | null; active_trials: string | null; | |
| 755 | + }; | |
| 756 | + const rows = await safe( | |
| 757 | + () => | |
| 758 | + run<Row>(sql` | |
| 759 | + WITH ids AS (SELECT unnest(ARRAY[${sql.join(cancerIds.map((i) => sql`${i}`), sql`, `)}]::varchar[]) AS id), | |
| 760 | + ed AS ( | |
| 761 | + 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, | |
| 762 | + sum(ke.support_count) AS support, array_agg(DISTINCT ke.source_id) AS source_ids, | |
| 763 | + (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, | |
| 764 | + (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 | |
| 765 | + FROM knowledge_edges ke | |
| 766 | + 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' | |
| 767 | + AND ke.cancer_context_ids && (SELECT array_agg(id)::text[] FROM ids) | |
| 768 | + GROUP BY ke.source_entity_id, ke.target_entity_id | |
| 769 | + ), | |
| 770 | + fq AS ( | |
| 771 | + 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 | |
| 772 | + 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} | |
| 773 | + ORDER BY f.gene_id, f.cases_profiled DESC, f.frequency DESC | |
| 774 | + ) | |
| 775 | + 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, | |
| 776 | + ed.evidence_level, ed.direction, ed.support, ed.source_ids, ed.provenance_ids, ed.context_ids, | |
| 777 | + fq.frequency, fq.cases_affected, fq.cases_profiled, fq.cohorts, | |
| 778 | + 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, | |
| 779 | + tr.trials, tr.active_trials | |
| 780 | + 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 | |
| 781 | + LEFT JOIN fq ON fq.gene_id = g.id | |
| 782 | + LEFT JOIN LATERAL ( | |
| 783 | + SELECT a.id, a.jurisdiction, a.authority, a.approval_date, a.status, a.cancer_id, a.tumor_agnostic, count(*) OVER() AS approvals | |
| 784 | + FROM drug_approvals a WHERE a.drug_id = ed.drug_id AND (a.cancer_id IN (SELECT id FROM ids) OR a.tumor_agnostic) | |
| 785 | + ORDER BY (a.cancer_id IS NOT NULL) DESC, a.approval_date ASC NULLS LAST, a.id LIMIT 1 | |
| 786 | + ) ap ON true | |
| 787 | + LEFT JOIN cancers ac ON ac.id = ap.cancer_id | |
| 788 | + LEFT JOIN LATERAL ( | |
| 789 | + SELECT count(DISTINCT ti.trial_id) AS trials, count(DISTINCT ti.trial_id) FILTER (WHERE t.overall_status IN ${activeList()}) AS active_trials | |
| 790 | + 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 | |
| 791 | + WHERE ti.drug_id = ed.drug_id | |
| 792 | + ) tr ON true | |
| 793 | + ORDER BY ed.lvl, ed.support DESC, fq.frequency DESC NULLS LAST, g.symbol, v.name, d.name | |
| 794 | + LIMIT ${limit}`), | |
| 795 | + [] as Row[], | |
| 796 | + ); | |
| 797 | + const names = await cancerNames(rows.flatMap((r) => r.context_ids ?? [])); | |
| 798 | + const chains: PathChain[] = rows.map((r) => ({ | |
| 799 | + cancer: { id: focus.id, slug: focus.ref, name: focus.label }, | |
| 800 | + gene: { id: r.gene_id, symbol: r.symbol, frequency: r.frequency, casesAffected: r.cases_affected, casesProfiled: r.cases_profiled, cohorts: n(r.cohorts) }, | |
| 801 | + variant: { id: r.variant_id, slug: r.variant_slug, name: r.variant_name }, | |
| 802 | + drug: { id: r.drug_id, slug: r.drug_slug, name: r.drug_name }, | |
| 803 | + edge: { evidenceLevel: r.evidence_level, direction: r.direction, supportCount: n(r.support), sourceIds: r.source_ids ?? [], provenanceIds: (r.provenance_ids ?? []).map(Number), contextIds: r.context_ids ?? [], contextNames: (r.context_ids ?? []).map((id) => names.get(id)?.name ?? id) }, | |
| 804 | + 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: n(r.approvals) } : null, | |
| 805 | + trials: r.trials !== null && r.trials !== undefined ? { total: n(r.trials), active: n(r.active_trials) } : null, | |
| 806 | + })); | |
| 807 | + return chains.sort(compareChains); | |
| 808 | +} | |
| 809 | + | |
| 810 | +// --------------------------------------------------------------------------------------------- | |
| 811 | +// Default focus + example foci (data-driven, never hardcoded) | |
| 812 | +// --------------------------------------------------------------------------------------------- | |
| 813 | + | |
| 814 | +export interface FocusSuggestion { | |
| 815 | + type: Exclude<NodeType, 'approval' | 'trial' | 'variant'>; | |
| 816 | + ref: string; | |
| 817 | + label: string; | |
| 818 | + edges: number; | |
| 819 | +} | |
| 820 | + | |
| 821 | +/** Most-connected cancers (2), genes (2) and drugs (2) by knowledge-edge count; the first cancer is the default focus. */ | |
| 822 | +export async function suggestedFoci(): Promise<FocusSuggestion[]> { | |
| 823 | + type Row = { type: FocusSuggestion['type']; ref: string; label: string; edges: string }; | |
| 824 | + const rows = await safe( | |
| 825 | + () => | |
| 826 | + run<Row>(sql` | |
| 827 | + WITH k AS ( | |
| 828 | + SELECT eid, count(*) AS n FROM ( | |
| 829 | + SELECT source_entity_id AS eid FROM knowledge_edges WHERE status = 'active' | |
| 830 | + UNION ALL SELECT target_entity_id FROM knowledge_edges WHERE status = 'active' | |
| 831 | + UNION ALL SELECT unnest(cancer_context_ids) FROM knowledge_edges WHERE status = 'active' | |
| 832 | + ) x GROUP BY eid | |
| 833 | + ) | |
| 834 | + (SELECT 'cancer' AS type, c.slug AS ref, c.canonical_name AS label, k.n AS edges FROM k JOIN cancers c ON c.id = k.eid WHERE c.status = 'active' ORDER BY k.n DESC, c.slug LIMIT 2) | |
| 835 | + UNION ALL (SELECT 'gene', g.symbol, g.symbol, k.n FROM k JOIN genes g ON g.id = k.eid ORDER BY k.n DESC, g.symbol LIMIT 2) | |
| 836 | + UNION ALL (SELECT 'drug', d.slug, d.name, k.n FROM k JOIN drugs d ON d.id = k.eid ORDER BY k.n DESC, d.slug LIMIT 2)`), | |
| 837 | + [] as Row[], | |
| 838 | + ); | |
| 839 | + return rows.map((r) => ({ type: r.type, ref: r.ref, label: r.label, edges: n(r.edges) })); | |
| 840 | +} | |
| 841 | + | |
| 842 | +export async function defaultFocus(): Promise<FocusRef | null> { | |
| 843 | + const s = (await suggestedFoci()).find((x) => x.type === 'cancer'); | |
| 844 | + return s ? { type: 'cancer', ref: s.ref } : null; | |
| 845 | +} | |
| 846 | + | |
| 847 | +/** Freshness: latest last_seen_at across the focus' knowledge edges. */ | |
| 848 | +export async function edgesFreshness(focus: FocusNode): Promise<Date | null> { | |
| 849 | + const rows = await safe(() => run<{ t: Date | null }>(sql`SELECT max(last_seen_at) AS t FROM knowledge_edges WHERE source_entity_id = ${focus.id} OR target_entity_id = ${focus.id} OR ${focus.id} = ANY(cancer_context_ids)`), [{ t: null }]); | |
| 850 | + return rows[0]?.t ?? null; | |
| 851 | +} | |
| 852 | + | |
added
apps/web/test/graph-layout.test.ts
+184 −0
@@ -0,0 +1,184 @@ | ||
| 1 | +import { describe, it, expect } from 'vitest'; | |
| 2 | +import { layoutRadial, selectNodes, nodeRadius, labelPlacement, parallelOffsets, shortLabel, parseFocus, evidenceLevelRank, compareChains, edgeStroke, sortGroups, NODE_TYPE_ORDER, type GraphNode, type NodeType, type PathChain } from '@/lib/graph-model'; | |
| 3 | + | |
| 4 | +const mk = (type: NodeType, i: number, degree = 1): GraphNode => ({ type, id: `${type}-${i}`, ref: `${type}-${i}`, label: `${type} ${i}`, href: `/${type}/${i}`, degree }); | |
| 5 | + | |
| 6 | +function many(counts: Partial<Record<NodeType, number>>, degreeOf = (i: number) => 1 + (i % 7)): GraphNode[] { | |
| 7 | + const out: GraphNode[] = []; | |
| 8 | + for (const [t, n] of Object.entries(counts) as Array<[NodeType, number]>) for (let i = 0; i < n; i++) out.push(mk(t, i, degreeOf(i))); | |
| 9 | + return out; | |
| 10 | +} | |
| 11 | + | |
| 12 | +const TAU = Math.PI * 2; | |
| 13 | + | |
| 14 | +describe('layoutRadial', () => { | |
| 15 | + it('places every drawn node inside the viewBox with room for its label', () => { | |
| 16 | + const nodes = many({ cancer: 20, gene: 15, variant: 10, drug: 12, trial: 10, approval: 3 }); | |
| 17 | + const l = layoutRadial(nodes, { size: 760, maxNodes: 60 }); | |
| 18 | + expect(l.nodes.length).toBe(60); | |
| 19 | + for (const p of l.nodes) { | |
| 20 | + expect(p.x - p.r).toBeGreaterThanOrEqual(0); | |
| 21 | + expect(p.y - p.r).toBeGreaterThanOrEqual(0); | |
| 22 | + expect(p.x + p.r).toBeLessThanOrEqual(760); | |
| 23 | + expect(p.y + p.r).toBeLessThanOrEqual(760); | |
| 24 | + // radial label margin: the label anchor stays inside the box too | |
| 25 | + const lp = labelPlacement(p); | |
| 26 | + expect(lp.x).toBeGreaterThanOrEqual(0); | |
| 27 | + expect(lp.x).toBeLessThanOrEqual(760); | |
| 28 | + expect(lp.y).toBeGreaterThanOrEqual(0); | |
| 29 | + expect(lp.y).toBeLessThanOrEqual(760); | |
| 30 | + // the ring leaves the configured label margin free | |
| 31 | + expect(Math.hypot(p.x - l.cx, p.y - l.cy)).toBeLessThanOrEqual(760 / 2 - 140 + 0.001); | |
| 32 | + } | |
| 33 | + }); | |
| 34 | + | |
| 35 | + it('keeps sectors in the fixed entity-type order and never overlapping', () => { | |
| 36 | + const nodes = many({ drug: 9, cancer: 4, trial: 6, gene: 11, approval: 2, variant: 7 }); | |
| 37 | + const l = layoutRadial(nodes); | |
| 38 | + const types = l.sectors.map((s) => s.type); | |
| 39 | + expect(types).toEqual(NODE_TYPE_ORDER.filter((t) => types.includes(t))); | |
| 40 | + for (let i = 1; i < l.sectors.length; i++) { | |
| 41 | + expect(l.sectors[i]!.start).toBeGreaterThan(l.sectors[i - 1]!.end); | |
| 42 | + } | |
| 43 | + const span = l.sectors[l.sectors.length - 1]!.end - l.sectors[0]!.start; | |
| 44 | + expect(span).toBeLessThan(TAU); | |
| 45 | + // every node's angle lies inside its own sector | |
| 46 | + for (const p of l.nodes) { | |
| 47 | + const s = l.sectors.find((x) => x.type === p.node.type)!; | |
| 48 | + expect(p.angle).toBeGreaterThanOrEqual(s.start); | |
| 49 | + expect(p.angle).toBeLessThanOrEqual(s.end); | |
| 50 | + } | |
| 51 | + }); | |
| 52 | + | |
| 53 | + it('gives adjacent nodes enough angular spacing for an 11 px radial label at 60 nodes', () => { | |
| 54 | + const nodes = many({ cancer: 10, gene: 10, variant: 10, drug: 10, trial: 10, approval: 10 }); | |
| 55 | + const l = layoutRadial(nodes, { size: 760, maxNodes: 60 }); | |
| 56 | + const sorted = [...l.nodes].sort((a, b) => a.angle - b.angle); | |
| 57 | + for (let i = 1; i < sorted.length; i++) { | |
| 58 | + const a = sorted[i - 1]!; | |
| 59 | + const b = sorted[i]!; | |
| 60 | + if (a.node.type !== b.node.type) continue; // sectors are separated by gaps | |
| 61 | + const arc = (b.angle - a.angle) * a.ring; | |
| 62 | + expect(arc).toBeGreaterThan(12); | |
| 63 | + } | |
| 64 | + }); | |
| 65 | + | |
| 66 | + it('is deterministic and independent of input order', () => { | |
| 67 | + const nodes = many({ cancer: 7, gene: 5, drug: 6 }); | |
| 68 | + const a = layoutRadial(nodes); | |
| 69 | + const b = layoutRadial([...nodes].reverse()); | |
| 70 | + expect(a.nodes.map((p) => [p.node.id, p.x.toFixed(6), p.y.toFixed(6)])).toEqual(b.nodes.map((p) => [p.node.id, p.x.toFixed(6), p.y.toFixed(6)])); | |
| 71 | + }); | |
| 72 | + | |
| 73 | + it('orders nodes inside a sector by degree then label', () => { | |
| 74 | + const nodes = [mk('gene', 1, 2), mk('gene', 2, 9), mk('gene', 3, 2)]; | |
| 75 | + const l = layoutRadial(nodes); | |
| 76 | + expect(l.nodes.map((p) => p.node.id)).toEqual(['gene-2', 'gene-1', 'gene-3']); | |
| 77 | + }); | |
| 78 | + | |
| 79 | + it('handles an empty neighbourhood and a single node', () => { | |
| 80 | + const empty = layoutRadial([]); | |
| 81 | + expect(empty.nodes).toEqual([]); | |
| 82 | + expect(empty.sectors).toEqual([]); | |
| 83 | + const one = layoutRadial([mk('drug', 1)]); | |
| 84 | + expect(one.nodes.length).toBe(1); | |
| 85 | + expect(one.sectors.length).toBe(1); | |
| 86 | + // a lone node sits at the centre of its sector | |
| 87 | + const s = one.sectors[0]!; | |
| 88 | + expect(one.nodes[0]!.angle).toBeCloseTo((s.start + s.end) / 2, 9); | |
| 89 | + }); | |
| 90 | + | |
| 91 | + it('reports hidden nodes when the cap is exceeded', () => { | |
| 92 | + const l = layoutRadial(many({ cancer: 50, gene: 50 }), { maxNodes: 60 }); | |
| 93 | + expect(l.nodes.length).toBe(60); | |
| 94 | + expect(l.hidden).toBe(40); | |
| 95 | + }); | |
| 96 | +}); | |
| 97 | + | |
| 98 | +describe('selectNodes', () => { | |
| 99 | + it('keeps every type represented (round-robin by degree) and is deterministic', () => { | |
| 100 | + const nodes = many({ cancer: 40, gene: 40, approval: 1, trial: 2 }); | |
| 101 | + const { drawn, hidden } = selectNodes(nodes, 10); | |
| 102 | + expect(drawn.length).toBe(10); | |
| 103 | + expect(hidden).toBe(73); | |
| 104 | + expect(drawn.some((n) => n.type === 'approval')).toBe(true); | |
| 105 | + expect(drawn.filter((n) => n.type === 'trial').length).toBe(2); | |
| 106 | + const again = selectNodes([...nodes].reverse(), 10); | |
| 107 | + expect(again.drawn.map((n) => n.id)).toEqual(drawn.map((n) => n.id)); | |
| 108 | + }); | |
| 109 | +}); | |
| 110 | + | |
| 111 | +describe('helpers', () => { | |
| 112 | + it('nodeRadius grows with the log of the degree and is capped', () => { | |
| 113 | + expect(nodeRadius(0)).toBe(4); | |
| 114 | + expect(nodeRadius(1)).toBeGreaterThan(nodeRadius(0)); | |
| 115 | + expect(nodeRadius(1000)).toBeLessThanOrEqual(13); | |
| 116 | + }); | |
| 117 | + it('labelPlacement flips text on the left half so it never reads upside down', () => { | |
| 118 | + const right = labelPlacement({ node: mk('gene', 1), x: 500, y: 380, r: 5, angle: 0, ring: 120 }); | |
| 119 | + const left = labelPlacement({ node: mk('gene', 1), x: 260, y: 380, r: 5, angle: Math.PI, ring: 120 }); | |
| 120 | + expect(right.anchor).toBe('start'); | |
| 121 | + expect(left.anchor).toBe('end'); | |
| 122 | + expect(Math.abs(left.rotate)).toBeCloseTo(360, 5); | |
| 123 | + }); | |
| 124 | + it('parallelOffsets are centred and symmetric', () => { | |
| 125 | + expect(parallelOffsets(1)).toEqual([0]); | |
| 126 | + expect(parallelOffsets(3)).toEqual([-3, 0, 3]); | |
| 127 | + expect(parallelOffsets(2)).toEqual([-1.5, 1.5]); | |
| 128 | + }); | |
| 129 | + it('shortLabel truncates with an ellipsis', () => { | |
| 130 | + expect(shortLabel('Lung Non-Small Cell Carcinoma', 12)).toBe('Lung Non-Sm…'); | |
| 131 | + expect(shortLabel('EGFR')).toBe('EGFR'); | |
| 132 | + }); | |
| 133 | + it('edgeStroke is dashed for derived and observed links, solid for curated / regulatory', () => { | |
| 134 | + expect(edgeStroke({ derived: true, evidenceCategory: 'regulatory_status' })).toBe('dashed'); | |
| 135 | + expect(edgeStroke({ derived: false, evidenceCategory: 'observed_data' })).toBe('dashed'); | |
| 136 | + expect(edgeStroke({ derived: false, evidenceCategory: 'curated_evidence' })).toBe('solid'); | |
| 137 | + expect(edgeStroke({ derived: false, evidenceCategory: 'regulatory_status' })).toBe('solid'); | |
| 138 | + }); | |
| 139 | + it('sortGroups puts regulatory and curated relationships before derived counts', () => { | |
| 140 | + const g = sortGroups([{ relationshipType: 'STUDIED_IN' }, { relationshipType: 'ASSOCIATED_WITH' }, { relationshipType: 'APPROVED_FOR' }, { relationshipType: 'ZZ_UNKNOWN' }]); | |
| 141 | + expect(g.map((x) => x.relationshipType)).toEqual(['APPROVED_FOR', 'ASSOCIATED_WITH', 'STUDIED_IN', 'ZZ_UNKNOWN']); | |
| 142 | + }); | |
| 143 | +}); | |
| 144 | + | |
| 145 | +describe('parseFocus', () => { | |
| 146 | + it('parses type:ref pairs and bare identifiers', () => { | |
| 147 | + expect(parseFocus('cancer:melanoma')).toEqual({ type: 'cancer', ref: 'melanoma' }); | |
| 148 | + expect(parseFocus('Gene: egfr')).toEqual({ type: 'gene', ref: 'egfr' }); | |
| 149 | + expect(parseFocus('NCT04487080')).toEqual({ type: 'trial', ref: 'NCT04487080' }); | |
| 150 | + expect(parseFocus('CI-DRUG-00000464')).toEqual({ type: 'drug', ref: 'CI-DRUG-00000464' }); | |
| 151 | + expect(parseFocus('TP53')).toEqual({ type: 'gene', ref: 'TP53' }); | |
| 152 | + expect(parseFocus('lung adenocarcinoma')).toEqual({ type: 'cancer', ref: 'lung adenocarcinoma' }); | |
| 153 | + }); | |
| 154 | + it('rejects unknown types and empty input', () => { | |
| 155 | + expect(parseFocus('publication:123')).toBeNull(); | |
| 156 | + expect(parseFocus('')).toBeNull(); | |
| 157 | + expect(parseFocus('drug:')).toBeNull(); | |
| 158 | + }); | |
| 159 | +}); | |
| 160 | + | |
| 161 | +describe('evidence ranking', () => { | |
| 162 | + it('ranks CIViC A–E, then native phase scales, unknown last', () => { | |
| 163 | + expect(evidenceLevelRank('A')).toBeLessThan(evidenceLevelRank('B')); | |
| 164 | + expect(evidenceLevelRank('E')).toBeLessThan(evidenceLevelRank('INHIBITOR')); | |
| 165 | + expect(evidenceLevelRank('4')).toBeLessThan(evidenceLevelRank('1')); | |
| 166 | + expect(evidenceLevelRank(null)).toBe(99); | |
| 167 | + }); | |
| 168 | + it('compareChains orders by level, support, frequency, then names (stable)', () => { | |
| 169 | + const base: PathChain = { | |
| 170 | + cancer: { id: 'c', slug: 'c', name: 'C' }, | |
| 171 | + gene: { id: 'g', symbol: 'EGFR', frequency: 0.2, casesAffected: 20, casesProfiled: 100, cohorts: 1 }, | |
| 172 | + variant: { id: 'v', slug: 'v', name: 'L858R' }, | |
| 173 | + drug: { id: 'd', slug: 'd', name: 'osimertinib' }, | |
| 174 | + edge: { evidenceLevel: 'B', direction: 'sensitivity', supportCount: 3, sourceIds: [], provenanceIds: [], contextIds: [], contextNames: [] }, | |
| 175 | + approval: null, | |
| 176 | + trials: null, | |
| 177 | + }; | |
| 178 | + const a = { ...base, edge: { ...base.edge, evidenceLevel: 'A', supportCount: 1 } }; | |
| 179 | + const b = { ...base, edge: { ...base.edge, supportCount: 10 } }; | |
| 180 | + const c = { ...base, gene: { ...base.gene, symbol: 'KRAS', frequency: 0.5 } }; | |
| 181 | + const sorted = [base, c, b, a].sort(compareChains); | |
| 182 | + expect(sorted.map((x) => `${x.edge.evidenceLevel}/${x.edge.supportCount}/${x.gene.symbol}`)).toEqual(['A/1/EGFR', 'B/10/EGFR', 'B/3/KRAS', 'B/3/EGFR']); | |
| 183 | + }); | |
| 184 | +}); | |
added
docs/methodology/knowledge-graph.md
+153 −0
@@ -0,0 +1,153 @@ | ||
| 1 | +# Knowledge graph — cancer · gene · variant · drug · trial | |
| 2 | + | |
| 3 | +The knowledge graph at `/graph` (API: `GET /v1/graph/:type/:id`) shows the **contextual neighbourhood | |
| 4 | +of one entity**: which cancers, genes, variants, drugs and trials a source connects to it, with the | |
| 5 | +cancer context, direction, evidence level, claim category and provenance of every link | |
| 6 | +(CLAUDE.md non-negotiable 7, SPEC §29, §244). It is a reading aid over data CancerIndex already | |
| 7 | +holds — it never adds knowledge of its own. | |
| 8 | + | |
| 9 | +## Nodes | |
| 10 | + | |
| 11 | +| Type | Public id | Focus reference (`/graph?focus=`) | Entity page | | |
| 12 | +|---|---|---|---| | |
| 13 | +| cancer | `CI-CAN-…` | `cancer:<slug>` | `/cancer/<slug>` | | |
| 14 | +| gene | `CI-GENE-…` | `gene:<HGNC symbol>` (aliases and `HGNC:n` resolve) | `/gene/<symbol>` | | |
| 15 | +| variant | `CI-VAR-…` | `variant:<slug>` | `/variant/<slug>` | | |
| 16 | +| drug | `CI-DRUG-…` | `drug:<slug>` | `/drug/<slug>` | | |
| 17 | +| trial | `CI-TRIAL-…` | `trial:<NCT id>` | `/trial/<nct>` | | |
| 18 | +| approval | `drug_approvals.id` (display only) | not focusable | `/drug/<slug>#approvals` | | |
| 19 | + | |
| 20 | +A bare NCT id, a bare CI id or an upper-case symbol is also accepted as `focus`. Approval nodes | |
| 21 | +appear only for a **drug focus** when an approval record has no mapped cancer (the indication text | |
| 22 | +is the label); approvals with a mapped cancer are drawn as `APPROVED_FOR` edges to that cancer. | |
| 23 | + | |
| 24 | +The **default focus** is the cancer with the most knowledge edges (source, target or context), | |
| 25 | +computed at request time. The six "most connected" quick links (two cancers, two genes, two | |
| 26 | +drugs) come from the same count. Nothing is hardcoded. | |
| 27 | + | |
| 28 | +## Edges | |
| 29 | + | |
| 30 | +Every edge — drawn or tabulated — carries: | |
| 31 | + | |
| 32 | +- `relationshipType` (source-native or derived, see below), `outgoing` (focus → neighbour or | |
| 33 | + neighbour → focus), `direction` (supports / sensitivity / resistance / unknown / mixed); | |
| 34 | +- `evidenceLevel` **as stated by the source** (CIViC A–E, ChEMBL max phase 1–4 or mechanism | |
| 35 | + action type, openFDA application type, approval status). Scales are never converted into one | |
| 36 | + another; they are only *ordered* for display (A, FDA ≻ B, phase 4 ≻ C, phase 3 ≻ D, phase 2 ≻ E, | |
| 37 | + phase 1 ≻ others ≻ unknown); | |
| 38 | +- `evidenceCategory` → claim badge (observed / published / curated / regulatory / guideline / | |
| 39 | + computed), never merged; | |
| 40 | +- `cancerContext` (`cancer_context_ids` resolved to names — the disease the source stated, which | |
| 41 | + may be a descendant of the focus), `supportCount`, `sourceIds`, `provenanceIds`, `derived`. | |
| 42 | + | |
| 43 | +### Source-native edges (`knowledge_edges`, `derived: false`, solid spokes) | |
| 44 | + | |
| 45 | +Rows written by connectors, one per source record, with `status = 'active'`: | |
| 46 | + | |
| 47 | +| Relationship | Direction of the row | Source | Native level | | |
| 48 | +|---|---|---|---| | |
| 49 | +| `PREDICTS_RESPONSE_TO` | variant → drug (cancer in context) | CIViC | A–E | | |
| 50 | +| `ASSOCIATED_WITH` | gene → cancer | CIViC | A–E | | |
| 51 | +| `PROGNOSTIC_IN` / `DIAGNOSTIC_OF` / `PREDISPOSES_TO` | variant → cancer | CIViC | A–E | | |
| 52 | +| `INVESTIGATED_FOR` | drug → cancer | ChEMBL (max phase), CIViC | 1–4 / A–E | | |
| 53 | +| `TARGETS` | drug → gene | ChEMBL mechanisms | action type (e.g. INHIBITOR) | | |
| 54 | +| `APPROVED_FOR` | drug → cancer | openFDA | application type (FDA ORIG) | | |
| 55 | + | |
| 56 | +For display the rows are **aggregated per (neighbour, relationship, direction, level, source)**: | |
| 57 | +`supportCount` is the sum of the rows' `support_count`, `provenanceIds` the union of their | |
| 58 | +provenance rows, `cancerContext` the union of their contexts, and the table says | |
| 59 | +"*n* source records aggregated". Aggregation groups identical claims; it never combines | |
| 60 | +different directions or levels into one. | |
| 61 | + | |
| 62 | +For a **cancer focus**, edges where the cancer is only the *context* (variant → drug **in this | |
| 63 | +cancer**) are included too: the neighbour is the variant, the drug is shown as "→ predicts response | |
| 64 | +to <drug>" (`via` in the API). These are the edges the "Paths" panel chains. | |
| 65 | + | |
| 66 | +### Derived registry links (`derived: true`, dashed spokes) | |
| 67 | + | |
| 68 | +Counts and measurements read from registry tables at query time. They state that two entities | |
| 69 | +co-occur in a registry, **not** that a source asserted a biological or clinical relationship. | |
| 70 | + | |
| 71 | +| Relationship | Focus → neighbour | Computed from | Shown as | | |
| 72 | +|---|---|---|---| | |
| 73 | +| `STUDIED_IN` | cancer → trial; drug → trial | `trial_conditions.cancer_id` (cancer + descendants) ; `trial_interventions.drug_id` | top 10 by `last_update_posted_date`, plus total and active count (`RECRUITING`, `NOT_YET_RECRUITING`, `ENROLLING_BY_INVITATION`, `ACTIVE_NOT_RECRUITING`) | | |
| 74 | +| `INVESTIGATED_IN_TRIALS` | cancer ↔ drug | `trial_interventions.drug_id × trial_conditions.cancer_id` | trial count and active count per pair | | |
| 75 | +| `ALTERED_IN` | cancer ↔ gene | `cancer_gene_frequencies` (GDC / cBioPortal cohorts) | **the cohort with the largest denominator** for the pair: `cases_affected / cases_profiled (frequency)`, alteration type, study id, number of qualifying cohorts. Thresholds: frequency ≥ 0.05 **and** cases affected ≥ 20 on that cohort row. Genes flagged `is_cancer_gene` are listed first | | |
| 76 | +| `APPROVED_FOR` (derived) | cancer ↔ drug / drug → approval | `drug_approvals` | authority, jurisdiction, status, date as published, tumour-agnostic flag; rows already present as an openFDA `APPROVED_FOR` knowledge edge are skipped | | |
| 77 | +| `HAS_VARIANT` | gene → variant | `variants.gene_id` | ordered by accepted CIViC evidence items (curated when > 0, otherwise the bare variant record) | | |
| 78 | +| `HAS_EVIDENCE_IN` | variant → cancer | `civic_evidence_items` (`ACCEPTED`) | items, levels present, sensitivity / resistance counts, supports / does-not-support counts | | |
| 79 | +| `PREDICTS_RESPONSE_TO` (derived) | variant → drug | `civic_evidence_items` predictive items **without** a knowledge edge yet | gap filler, flagged derived | | |
| 80 | +| `CONDITION_OF` / `INTERVENTION_OF` | cancer → trial / drug → trial (trial focus) | `trial_conditions`, `trial_interventions` | registry text and `match_type` of the mapping | | |
| 81 | + | |
| 82 | +Registry counts have no `provenance` row of their own (they are not a published number); the | |
| 83 | +edge names the registry source and the query definition above, and cohort frequencies and | |
| 84 | +approvals carry the provenance id of the underlying row. | |
| 85 | + | |
| 86 | +Cancer foci roll up **descendants** (all hierarchy types, depth ≤ 12, capped at 600 ids) for | |
| 87 | +trials, cohort frequencies and approvals, and the edge's `cancerContext` names the descendant that | |
| 88 | +actually matched. Source-native edges are not rolled up: they must name the focus itself (or have it | |
| 89 | +in context). | |
| 90 | + | |
| 91 | +## Caps and truncation | |
| 92 | + | |
| 93 | +- Per relationship type: 25 aggregated edges (10 for `STUDIED_IN`), ranked by native level, then | |
| 94 | + support, then recency. `?more=<RELATIONSHIP>` raises **one** group to 200. Each group shows | |
| 95 | + "*shown* of *total*"; the API returns `groups: { [relationshipType]: total }` and `truncated`. | |
| 96 | +- Drawn nodes: at most **60**. Selection is a deterministic round-robin over entity types (each type | |
| 97 | + contributes its highest-degree node in turn) so every type present stays visible; the figure | |
| 98 | + caption states how many neighbours are not drawn. The table lists every fetched edge. | |
| 99 | +- Spokes: at most 3 relationships per neighbour are drawn as parallel lines; all are in the table. | |
| 100 | +- Paths: 8 chains (`?limit` ≤ 50 in the API). | |
| 101 | + | |
| 102 | +## Layout rules (`apps/web/src/lib/graph-model.ts`, unit-tested) | |
| 103 | + | |
| 104 | +- Focus at the centre. Neighbours sit on arcs grouped by entity type in the **fixed** clockwise | |
| 105 | + order cancer → gene → variant → drug → trial → approval, starting at the top; sectors never | |
| 106 | + reorder by size, so the same focus always draws the same picture. | |
| 107 | +- Sector width ∝ node count with a minimum share; a fixed gap separates sectors; every sector uses | |
| 108 | + one of three alternating ring radii so labels at sector borders do not collide. | |
| 109 | +- Inside a sector nodes are ordered by degree (edges at the node), then label; a lone node sits at | |
| 110 | + the sector's centre. Mark radius = 4 + 2.2·log₂(degree + 1), capped at 13. | |
| 111 | +- Labels run along the spoke (rotated), flipped on the left half, truncated at 18 characters | |
| 112 | + (full text in `<title>`). Entity type is encoded by sector position and caption, mark shape | |
| 113 | + (circle / square / triangle / rounded square / hexagon / diamond) and a muted fill from the | |
| 114 | + design tokens — the picture is readable without colour. | |
| 115 | +- Solid spoke = source-native curated / regulatory / published claim; dashed = derived count or | |
| 116 | + observed data. Every spoke has a `<title>`: relationship · direction · level · context · source. | |
| 117 | +- Server-rendered SVG only (no client graph library); the SVG scrolls horizontally below 560 px | |
| 118 | + and the edge table is the accessible equivalent. | |
| 119 | + | |
| 120 | +## Paths (cancer focus) | |
| 121 | + | |
| 122 | +`cancer → gene → variant → drug → approval → trials`, built only from what exists: | |
| 123 | + | |
| 124 | +1. variant → drug: a source-native `PREDICTS_RESPONSE_TO` edge with `direction = 'sensitivity'` | |
| 125 | + whose context contains the cancer or a descendant (aggregated per variant–drug pair); | |
| 126 | +2. gene: the variant's gene; its cohort frequency in the cancer is the **largest-denominator** | |
| 127 | + cohort row with cases affected ≥ 20 — shown as "not yet available" when no cohort covers it; | |
| 128 | +3. approval: the earliest `drug_approvals` row for the drug in the cancer (or tumour-agnostic), | |
| 129 | + with authority, jurisdiction, status and date as published; `null` when none; | |
| 130 | +4. trials: registry trials listing the drug with a condition mapped to the cancer (total / active). | |
| 131 | + | |
| 132 | +Ranking: native evidence level, then support count, then cohort frequency, then names (stable). | |
| 133 | +Each hop keeps its own claim category. Chains are descriptive; they are not treatment guidance. | |
| 134 | + | |
| 135 | +## What the graph never does | |
| 136 | + | |
| 137 | +- **No inferred edges.** Two entities are connected only when a source states the relationship or a | |
| 138 | + registry row literally joins them. No transitive closure, no similarity, no co-citation. | |
| 139 | +- **No LLM edges.** Nothing in the graph is generated or ranked by a language model. | |
| 140 | +- **No re-scaled evidence.** CIViC letters, ChEMBL phases and FDA application types stay in their | |
| 141 | + native form; ordering for display is documented above and never shown as a score. | |
| 142 | +- **No silent merging.** Different directions, levels or sources are separate rows; derived counts | |
| 143 | + are never mixed with curated claims; claim badges are never collapsed. | |
| 144 | +- **No fabricated hops.** A missing frequency, approval or trial count is shown as missing. | |
| 145 | + | |
| 146 | +## Implementation | |
| 147 | + | |
| 148 | +- Web: `apps/web/src/lib/graph-model.ts` (types, layout, ranking — pure), `apps/web/src/lib/queries/graph.ts` | |
| 149 | + (SQL), `apps/web/src/components/graph/{radial-graph,path-chain,graph-link}.tsx`, | |
| 150 | + `apps/web/src/app/graph/page.tsx`, home teaser `apps/web/src/components/home/graph-module.tsx`. | |
| 151 | +- API: `apps/api/src/routes/graph.ts` — same SQL, duplicated on purpose (the web query module is | |
| 152 | + `server-only`); keep thresholds and ordering in step. | |
| 153 | +- Tests: `apps/web/test/graph-layout.test.ts`. | |
| 154 | ||