SPB Git forge

spb/cancerindex

Public
37commits 1branches 0releases
2.9 MBsize
maindefault branch
10 days agolast push
TypeScript 97.2% SQL 1.5% CSS 0.6% JavaScript 0.5%
15.2 KB · 380 lines typescript
Raw Blame History
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