/** * Knowledge-graph model + radial layout math (pure: no database, no React). Shared by the web * queries (`lib/queries/graph.ts`), the SVG renderer and the unit tests. * * Every edge carries its cancer context, direction, evidence level, claim category and provenance * (CLAUDE.md §7). `derived: true` marks links that CancerIndex computes from registry tables * (trial_conditions, trial_interventions, cancer_gene_frequencies, drug_approvals, * civic_evidence_items) as opposed to source-native `knowledge_edges` rows. Nothing here is inferred. */ export type NodeType = 'cancer' | 'gene' | 'variant' | 'drug' | 'trial' | 'approval'; /** Fixed angular order of the sectors (never re-sorted by count so the picture stays stable). */ export const NODE_TYPE_ORDER: readonly NodeType[] = ['cancer', 'gene', 'variant', 'drug', 'trial', 'approval']; export const NODE_TYPE_LABEL: Record = { cancer: 'Cancers', gene: 'Genes', variant: 'Variants', drug: 'Drugs', trial: 'Trials', approval: 'Approvals', }; /** Focus reference forms accepted by `/graph?focus=:` (approvals are not focusable). */ export type FocusType = Exclude; export const FOCUS_TYPES: readonly FocusType[] = ['cancer', 'gene', 'variant', 'drug', 'trial']; export interface GraphNode { type: NodeType; /** Public CI id (CI-CAN-…, CI-GENE-…) or `approval:` for approval nodes. */ id: string; /** Reference used in `focus=` (slug / symbol / NCT id). Null for approval nodes. */ ref: string | null; label: string; /** Secondary line (gene symbol for a variant, phase/status for a trial, jurisdiction for an approval). */ sublabel?: string | null; /** Entity page. */ href: string; /** Number of edges touching this node in the current neighbourhood. */ degree: number; } export interface CancerContext { id: string; name: string; slug: string; } export interface GraphEdge { /** Stable key (`ke:` for knowledge_edges rows, `dv::` for derived links). */ key: string; relationshipType: string; /** `${type}:${id}` of the neighbour node. */ neighborKey: string; /** True when the focus is the source of the relationship (focus → neighbour). */ outgoing: boolean; direction: string | null; /** Source-native evidence level (CIViC A–E, ChEMBL phase 1–4, "FDA ORIG"…), never re-scaled. */ evidenceLevel: string | null; /** observed_data | published_evidence | curated_evidence | regulatory_status | clinical_guideline | computed_metric */ evidenceCategory: string; cancerContext: CancerContext[]; supportCount: number; sourceIds: string[]; sourceSlugs: string[]; provenanceIds: number[]; derived: boolean; /** Human-readable measurement behind a derived link ("183 / 186 cases (98.4 %)", "417 trials (197 active)"). */ detail?: string | null; /** ISO date attached to the edge (approval date, trial last update). */ date?: string | null; /** Third party when the focus is only the *context* of the edge (e.g. variant → drug in this cancer). */ via?: { type: NodeType; id: string; label: string; href: string } | null; } export interface EdgeGroup { relationshipType: string; /** Total edges of this type in the database for the focus (before LIMIT). */ total: number; edges: GraphEdge[]; derived: boolean; } export interface Neighborhood { focus: GraphNode; /** Neighbour nodes (focus excluded), unique by `${type}:${id}`. */ nodes: GraphNode[]; groups: EdgeGroup[]; degreeByType: Record; } export const nodeKey = (n: Pick): string => `${n.type}:${n.id}`; export interface FocusRef { type: FocusType; ref: string; } /** Parse `focus=:` (also accepts a bare NCT id, a bare CI id or a bare gene symbol in upper case). */ export function parseFocus(raw: string | null | undefined): FocusRef | null { const s = (raw ?? '').trim(); if (!s) return null; const m = /^([a-z]+)\s*:\s*(.*)$/i.exec(s); if (m) { const type = m[1]!.toLowerCase(); const ref = m[2]!.trim().slice(0, 200); if (!ref) return null; if ((FOCUS_TYPES as readonly string[]).includes(type)) return { type: type as FocusType, ref }; return null; } if (/^NCT\d{8}$/i.test(s)) return { type: 'trial', ref: s.toUpperCase() }; const ci = /^CI-(CAN|GENE|VAR|DRUG|TRIAL)-\d+$/i.exec(s); if (ci) { const ns = ci[1]!.toUpperCase(); const type: FocusType = ns === 'CAN' ? 'cancer' : ns === 'GENE' ? 'gene' : ns === 'VAR' ? 'variant' : ns === 'DRUG' ? 'drug' : 'trial'; return { type, ref: s.toUpperCase() }; } if (/^[A-Z][A-Z0-9-]{1,14}$/.test(s)) return { type: 'gene', ref: s }; return { type: 'cancer', ref: s.toLowerCase() }; } export const focusHref = (type: NodeType, ref: string | null): string | null => (type === 'approval' || !ref ? null : `/graph?focus=${type}:${encodeURIComponent(ref)}`); /** Relationship labels in editorial English (the raw type stays in `title` / tables). */ export const RELATIONSHIP_LABEL: Record = { ASSOCIATED_WITH: 'associated with', PREDICTS_RESPONSE_TO: 'predicts response to', CONFERS_RESISTANCE_TO: 'confers resistance to', PROGNOSTIC_IN: 'prognostic in', DIAGNOSTIC_OF: 'diagnostic of', PREDISPOSES_TO: 'predisposes to', TARGETS: 'targets', APPROVED_FOR: 'approved for', INVESTIGATED_FOR: 'investigated for', INVESTIGATED_IN_TRIALS: 'investigated in trials for', STUDIED_IN: 'studied in', ALTERED_IN: 'altered in cohorts of', HAS_VARIANT: 'has variant', HAS_EVIDENCE_IN: 'has curated evidence in', CONDITION_OF: 'condition of', INTERVENTION_OF: 'intervention of', }; export const relationshipLabel = (t: string): string => RELATIONSHIP_LABEL[t] ?? t.toLowerCase().replace(/_/g, ' '); /** Display order of relationship groups: regulatory first, then curated, then derived counts. */ export const RELATIONSHIP_ORDER: readonly string[] = [ 'APPROVED_FOR', 'PREDICTS_RESPONSE_TO', 'CONFERS_RESISTANCE_TO', 'PROGNOSTIC_IN', 'DIAGNOSTIC_OF', 'PREDISPOSES_TO', 'ASSOCIATED_WITH', 'TARGETS', 'INVESTIGATED_FOR', 'HAS_VARIANT', 'HAS_EVIDENCE_IN', 'ALTERED_IN', 'INVESTIGATED_IN_TRIALS', 'STUDIED_IN', 'CONDITION_OF', 'INTERVENTION_OF', ]; export function sortGroups(groups: T[]): T[] { const rank = (t: string) => { const i = RELATIONSHIP_ORDER.indexOf(t); return i === -1 ? RELATIONSHIP_ORDER.length : i; }; return [...groups].sort((a, b) => rank(a.relationshipType) - rank(b.relationshipType) || a.relationshipType.localeCompare(b.relationshipType)); } /** CIViC A–E rank (lower is stronger); other native scales rank after A–E, unknown last. */ export function evidenceLevelRank(level: string | null | undefined): number { if (!level) return 99; const l = level.trim().toUpperCase(); const civic = ['A', 'B', 'C', 'D', 'E'].indexOf(l); if (civic !== -1) return civic; if (l === 'FDA ORIG' || l.startsWith('FDA')) return 0; const phase = Number(l); if (Number.isFinite(phase)) return phase >= 4 ? 1 : phase >= 3 ? 2 : phase >= 2 ? 3 : 4; // ChEMBL max phase return 50; } /** Edge stroke family: `solid` for source-native curated/regulatory/published claims, `dashed` for derived or observed counts. */ export function edgeStroke(e: Pick): 'solid' | 'dashed' { if (e.derived) return 'dashed'; return e.evidenceCategory === 'observed_data' || e.evidenceCategory === 'computed_metric' ? 'dashed' : 'solid'; } // --------------------------------------------------------------------------------------------- // Layout // --------------------------------------------------------------------------------------------- export interface LayoutOptions { /** Square viewBox side (default 760). */ size?: number; /** Hard cap on drawn neighbours (default 60); the table below the graph still lists everything. */ maxNodes?: number; /** Angular gap between sectors, radians (default 0.16 ≈ 9°). */ sectorGap?: number; /** Outer margin reserved for radial labels (default 140 ≈ an 18-character label at 11 px plus the mark). */ labelMargin?: number; } export interface PlacedNode { node: GraphNode; x: number; y: number; /** Mark radius (log of degree). */ r: number; /** Polar angle in radians (0 = east, clockwise positive in SVG space). */ angle: number; /** Ring radius from the centre. */ ring: number; } export interface Sector { type: NodeType; start: number; end: number; count: number; /** Ring radius used by this sector. */ ring: number; } export interface RadialLayout { size: number; cx: number; cy: number; focus: { x: number; y: number; r: number }; nodes: PlacedNode[]; sectors: Sector[]; /** Neighbours that exist but were not drawn because of `maxNodes`. */ hidden: number; } const TAU = Math.PI * 2; export function nodeRadius(degree: number): number { const d = Math.max(0, degree); return Math.min(13, 4 + 2.2 * Math.log2(d + 1)); } /** Deterministic node ordering: degree desc, then label, then id. */ export function compareNodes(a: GraphNode, b: GraphNode): number { return b.degree - a.degree || a.label.localeCompare(b.label) || a.id.localeCompare(b.id); } /** * Pick at most `max` nodes while keeping every entity type represented: round-robin over the types * (in fixed order), each type contributing its highest-degree node first. Deterministic. */ export function selectNodes(nodes: GraphNode[], max: number): { drawn: GraphNode[]; hidden: number } { const byType = new Map(); for (const t of NODE_TYPE_ORDER) byType.set(t, []); for (const n of nodes) byType.get(n.type)?.push(n); for (const list of byType.values()) list.sort(compareNodes); const drawn: GraphNode[] = []; let progressed = true; while (drawn.length < max && progressed) { progressed = false; for (const t of NODE_TYPE_ORDER) { if (drawn.length >= max) break; const next = byType.get(t)!.shift(); if (next) { drawn.push(next); progressed = true; } } } return { drawn, hidden: Math.max(0, nodes.length - drawn.length) }; } /** * Radial layout: focus at the centre, neighbours on sector arcs grouped by entity type in the fixed * order cancer → gene → variant → drug → trial → approval (clockwise from the top). Sector width is * proportional to node count with a minimum so small groups stay legible; sectors never overlap; * each sector sits on its own ring radius (alternating three radii) so labels at sector borders do * not collide. Positions are a pure function of the input. */ export function layoutRadial(nodesIn: GraphNode[], opts: LayoutOptions = {}): RadialLayout { const size = opts.size ?? 760; const maxNodes = opts.maxNodes ?? 60; const gap = opts.sectorGap ?? 0.16; const labelMargin = opts.labelMargin ?? 140; const cx = size / 2; const cy = size / 2; const { drawn, hidden } = selectNodes(nodesIn, maxNodes); const counts = new Map(); for (const n of drawn) counts.set(n.type, [...(counts.get(n.type) ?? []), n]); const present = NODE_TYPE_ORDER.filter((t) => (counts.get(t)?.length ?? 0) > 0); const total = drawn.length; const outerRing = size / 2 - labelMargin; const rings = [outerRing, outerRing * 0.84, outerRing * 0.92]; const sectors: Sector[] = []; const nodes: PlacedNode[] = []; if (present.length === 0) return { size, cx, cy, focus: { x: cx, y: cy, r: 16 }, nodes, sectors, hidden }; // Angular budget: full circle minus one gap per sector; each sector gets a share proportional to // its count, floored at `minShare` so a single node still gets breathing room. const usable = TAU - gap * present.length; const minShare = Math.min(0.35, usable / present.length / 2); const rawShares = present.map((t) => Math.max(minShare, (usable * counts.get(t)!.length) / total)); const shareSum = rawShares.reduce((a, b) => a + b, 0); const shares = rawShares.map((s) => (s * usable) / shareSum); let cursor = -Math.PI / 2 + gap / 2; // start at the top, clockwise present.forEach((t, i) => { const list = counts.get(t)!.sort(compareNodes); const span = shares[i]!; const ring = rings[i % rings.length]!; const start = cursor; const end = cursor + span; sectors.push({ type: t, start, end, count: list.length, ring }); // Nodes are spread over the sector interior; a lone node sits at the sector's centre. const n = list.length; list.forEach((node, k) => { const frac = n === 1 ? 0.5 : (k + 0.5) / n; const angle = start + frac * span; nodes.push({ node, angle, ring, r: nodeRadius(node.degree), x: cx + ring * Math.cos(angle), y: cy + ring * Math.sin(angle) }); }); cursor = end + gap; }); return { size, cx, cy, focus: { x: cx, y: cy, r: 16 }, nodes, sectors, hidden }; } /** Radial label transform: text drawn along the spoke, flipped on the left half so it never reads upside down. */ export function labelPlacement(p: PlacedNode, offset = 6): { x: number; y: number; rotate: number; anchor: 'start' | 'end' } { const deg = (p.angle * 180) / Math.PI; const left = Math.cos(p.angle) < 0; const dist = p.r + offset; 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' }; } /** Truncate a label for the SVG (full text goes in ``). */ export function shortLabel(s: string, max = 18): string { const t = s.trim(); return t.length <= max ? t : `${t.slice(0, max - 1).trimEnd()}…`; } /** Perpendicular offsets so several relationships between the same pair render as distinct parallel spokes. */ export function parallelOffsets(n: number, step = 3): number[] { const out: number[] = []; for (let i = 0; i < n; i++) out.push((i - (n - 1) / 2) * step); return out; } // --------------------------------------------------------------------------------------------- // Paths (cancer focus): cancer → gene → variant → drug → approval → trials // --------------------------------------------------------------------------------------------- export interface PathChain { cancer: { id: string; slug: string; name: string }; gene: { id: string; symbol: string; frequency: number | null; casesAffected: number | null; casesProfiled: number | null; cohorts: number }; variant: { id: string; slug: string; name: string }; drug: { id: string; slug: string; name: string }; edge: { evidenceLevel: string | null; direction: string | null; supportCount: number; sourceIds: string[]; provenanceIds: number[]; contextIds: string[]; contextNames: string[] }; approval: { id: number; jurisdiction: string; authority: string; approvalDate: string | null; status: string; cancerId: string | null; cancerName: string | null; tumorAgnostic: boolean; total: number } | null; trials: { total: number; active: number } | null; } /** Rank chains: strongest evidence level, then support, then alteration frequency, then names (stable). */ export function compareChains(a: PathChain, b: PathChain): number { return ( evidenceLevelRank(a.edge.evidenceLevel) - evidenceLevelRank(b.edge.evidenceLevel) || b.edge.supportCount - a.edge.supportCount || (b.gene.frequency ?? -1) - (a.gene.frequency ?? -1) || a.gene.symbol.localeCompare(b.gene.symbol) || a.variant.name.localeCompare(b.variant.name) || a.drug.name.localeCompare(b.drug.name) ); }