import Link from 'next/link'; 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'; /** * Server-rendered radial SVG of one neighbourhood. No client graph library: positions come from * `layoutRadial` (pure). Entity type is encoded three ways — sector position + sector caption, * mark shape and a muted fill — so the picture is readable without colour. Edge stroke: solid for * source-native curated / regulatory claims, dashed for derived or observed registry counts. * Every node is a link to `/graph?focus=…` (contextual expansion) with a small ↗ to the entity page; * `` elements carry the full label and the edge context for hover and assistive technology. */ const FILL: Record<NodeType, string> = { cancer: 'var(--color-series-1)', gene: 'var(--color-series-7)', variant: 'var(--color-series-8)', drug: 'var(--color-series-2)', trial: 'var(--color-series-3)', approval: 'var(--color-series-4)', }; /** Mark shape per entity type (secondary encoding, independent of colour). */ function Mark({ type, x, y, r, fill }: { type: NodeType; x: number; y: number; r: number; fill: string }) { const common = { fill, stroke: 'var(--color-paper)', strokeWidth: 1.5 } as const; switch (type) { case 'gene': return <rect x={x - r} y={y - r} width={2 * r} height={2 * r} {...common} />; case 'variant': 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} />; case 'drug': return <rect x={x - r} y={y - r} width={2 * r} height={2 * r} rx={r * 0.45} {...common} />; case 'trial': return <polygon points={hexagon(x, y, r * 1.1)} {...common} />; case 'approval': return <polygon points={`${x},${y - r * 1.2} ${x + r * 1.2},${y} ${x},${y + r * 1.2} ${x - r * 1.2},${y}`} {...common} />; default: return <circle cx={x} cy={y} r={r} {...common} />; } } function hexagon(cx: number, cy: number, r: number): string { const pts: string[] = []; for (let i = 0; i < 6; i++) { const a = (Math.PI / 3) * i - Math.PI / 6; pts.push(`${(cx + r * Math.cos(a)).toFixed(1)},${(cy + r * Math.sin(a)).toFixed(1)}`); } return pts.join(' '); } function edgeTitle(e: GraphEdge, focus: GraphNode, neighbor: GraphNode): string { const from = e.outgoing ? focus.label : neighbor.label; const to = e.outgoing ? neighbor.label : focus.label; const bits = [e.via ? `${neighbor.label} ${relationshipLabel(e.relationshipType)} ${e.via.label} — in ${focus.label}` : `${from} ${relationshipLabel(e.relationshipType)} ${to}`]; if (e.direction) bits.push(`direction: ${e.direction}`); if (e.evidenceLevel) bits.push(`evidence level: ${e.evidenceLevel}`); 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}` : ''}`); bits.push(`${e.derived ? 'derived count' : e.evidenceCategory.replace(/_/g, ' ')} · source: ${e.sourceSlugs.join(', ')}`); if (e.detail) bits.push(e.detail); return bits.join(' · '); } export function RadialGraph({ focus, nodes, edges, size = 760, maxNodes = 60, className = '' }: { focus: GraphNode; nodes: GraphNode[]; edges: GraphEdge[]; size?: number; maxNodes?: number; className?: string }) { const layout = layoutRadial(nodes, { size, maxNodes }); const placed = new Map<string, PlacedNode>(layout.nodes.map((p) => [nodeKey(p.node), p])); const byNeighbor = new Map<string, GraphEdge[]>(); for (const e of edges) { if (!placed.has(e.neighborKey)) continue; byNeighbor.set(e.neighborKey, [...(byNeighbor.get(e.neighborKey) ?? []), e]); } const { cx, cy } = layout; const focusR = layout.focus.r; return ( <figure className={`ci-graph ${className}`}> <div className="overflow-x-auto"> <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)' }}> <title id="ci-graph-title">{`Knowledge graph around ${focus.label}`} {`${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.`} {/* Sector captions: inside the ring, on the sector's mid-angle, with a paper halo so they stay legible over the spokes */} {layout.sectors.map((s) => { const mid = (s.start + s.end) / 2; const capR = s.ring - 34; const capX = cx + capR * Math.cos(mid); const capY = cy + capR * Math.sin(mid); return ( {`${NODE_TYPE_LABEL[s.type]} · ${s.count}`} ); })} {/* Edges: one spoke per (neighbour, relationship), parallel offsets when several */} {layout.nodes.map((p) => { const list = byNeighbor.get(nodeKey(p.node)) ?? []; const shown = list.slice(0, 3); const offsets = parallelOffsets(shown.length); const ux = p.x - cx; const uy = p.y - cy; const len = Math.hypot(ux, uy) || 1; const nx = -uy / len; // unit normal const ny = ux / len; const sx = cx + (ux / len) * (focusR + 3); const sy = cy + (uy / len) * (focusR + 3); const ex = p.x - (ux / len) * (p.r + 2); const ey = p.y - (uy / len) * (p.r + 2); return shown.map((e, i) => { const o = offsets[i]!; const dashed = edgeStroke(e) === 'dashed'; return ( {edgeTitle(e, focus, p.node)} ); }); })} {/* Neighbour nodes */} {layout.nodes.map((p) => { const lp = labelPlacement(p); const href = focusHref(p.node.type, p.node.ref); const label = `${p.node.label}${p.node.sublabel ? ` — ${p.node.sublabel}` : ''} · ${p.node.degree} edge${p.node.degree === 1 ? '' : 's'} here`; const body = ( <> {label} {shortLabel(p.node.label)} ); return ( {href ? ( {body} ) : ( {body} )} ); })} {/* Focus */} {`${focus.label} · ${NODE_TYPE_LABEL[focus.type].replace(/s$/, '')} · focus`} {shortLabel(focus.label, 34)}
Legend {NODE_TYPE_ORDER.map((t) => ( {NODE_TYPE_LABEL[t]} ))} source-native edge (curated / regulatory) derived count (registries) mark size = log of edges at the node {layout.hidden > 0 ? ( {layout.hidden} more neighbour{layout.hidden === 1 ? '' : 's'} not drawn (cap {maxNodes}) — all listed in the table ) : null} Entity page ↗
); }