spb/cancerindex
Public
TypeScript 97.2%
SQL 1.5%
CSS 0.6%
JavaScript 0.5%
1/**2 * Knowledge-graph model + radial layout math (pure: no database, no React). Shared by the web3 * 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 provenance6 * (CLAUDE.md §7). `derived: true` marks links that CancerIndex computes from registry tables7 * (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 */1011export type NodeType = 'cancer' | 'gene' | 'variant' | 'drug' | 'trial' | 'approval';1213/** Fixed angular order of the sectors (never re-sorted by count so the picture stays stable). */14export const NODE_TYPE_ORDER: readonly NodeType[] = ['cancer', 'gene', 'variant', 'drug', 'trial', 'approval'];1516export 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};2425/** Focus reference forms accepted by `/graph?focus=<type>:<ref>` (approvals are not focusable). */26export type FocusType = Exclude<NodeType, 'approval'>;27export const FOCUS_TYPES: readonly FocusType[] = ['cancer', 'gene', 'variant', 'drug', 'trial'];2829export 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}4344export interface CancerContext {45 id: string;46 name: string;47 slug: string;48}4950export 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}7677export 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}8485export 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}9293export const nodeKey = (n: Pick<GraphNode, 'type' | 'id'>): string => `${n.type}:${n.id}`;9495export interface FocusRef {96 type: FocusType;97 ref: string;98}99100/** Parse `focus=<type>:<ref>` (also accepts a bare NCT id, a bare CI id or a bare gene symbol in upper case). */101export 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}122123export const focusHref = (type: NodeType, ref: string | null): string | null => (type === 'approval' || !ref ? null : `/graph?focus=${type}:${encodeURIComponent(ref)}`);124125/** Relationship labels in editorial English (the raw type stays in `title` / tables). */126export 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};144145export const relationshipLabel = (t: string): string => RELATIONSHIP_LABEL[t] ?? t.toLowerCase().replace(/_/g, ' ');146147/** Display order of relationship groups: regulatory first, then curated, then derived counts. */148export 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];166167export 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}174175/** CIViC A–E rank (lower is stronger); other native scales rank after A–E, unknown last. */176export 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 phase184 return 50;185}186187/** Edge stroke family: `solid` for source-native curated/regulatory/published claims, `dashed` for derived or observed counts. */188export 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}192193// ---------------------------------------------------------------------------------------------194// Layout195// ---------------------------------------------------------------------------------------------196197export 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}207208export 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}219220export 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}228229export 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}239240const TAU = Math.PI * 2;241242export 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}246247/** Deterministic node ordering: degree desc, then label, then id. */248export function compareNodes(a: GraphNode, b: GraphNode): number {249 return b.degree - a.degree || a.label.localeCompare(b.label) || a.id.localeCompare(b.id);250}251252/**253 * Pick at most `max` nodes while keeping every entity type represented: round-robin over the types254 * (in fixed order), each type contributing its highest-degree node first. Deterministic.255 */256export 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}276277/**278 * Radial layout: focus at the centre, neighbours on sector arcs grouped by entity type in the fixed279 * order cancer → gene → variant → drug → trial → approval (clockwise from the top). Sector width is280 * 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 do282 * not collide. Positions are a pure function of the input.283 */284export 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);292293 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;297298 const outerRing = size / 2 - labelMargin;299 const rings = [outerRing, outerRing * 0.84, outerRing * 0.92];300301 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 };304305 // Angular budget: full circle minus one gap per sector; each sector gets a share proportional to306 // 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);312313 let cursor = -Math.PI / 2 + gap / 2; // start at the top, clockwise314 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 });330331 return { size, cx, cy, focus: { x: cx, y: cy, r: 16 }, nodes, sectors, hidden };332}333334/** Radial label transform: text drawn along the spoke, flipped on the left half so it never reads upside down. */335export 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}341342/** Truncate a label for the SVG (full text goes in `<title>`). */343export 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}347348/** Perpendicular offsets so several relationships between the same pair render as distinct parallel spokes. */349export 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}354355// ---------------------------------------------------------------------------------------------356// Paths (cancer focus): cancer → gene → variant → drug → approval → trials357// ---------------------------------------------------------------------------------------------358359export 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}368369/** Rank chains: strongest evidence level, then support, then alteration frequency, then names (stable). */370export 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}380