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 = {
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 ;
case 'variant':
return ;
case 'drug':
return ;
case 'trial':
return ;
case 'approval':
return ;
default:
return ;
}
}
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(layout.nodes.map((p) => [nodeKey(p.node), p]));
const byNeighbor = new Map();
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 (
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 ↗
);
}