spb/cancerindex
Public
TypeScript 97.2%
SQL 1.5%
CSS 0.6%
JavaScript 0.5%
1import Link from 'next/link';2import { 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';34/**5 * Server-rendered radial SVG of one neighbourhood. No client graph library: positions come from6 * `layoutRadial` (pure). Entity type is encoded three ways — sector position + sector caption,7 * mark shape and a muted fill — so the picture is readable without colour. Edge stroke: solid for8 * source-native curated / regulatory claims, dashed for derived or observed registry counts.9 * Every node is a link to `/graph?focus=…` (contextual expansion) with a small ↗ to the entity page;10 * `<title>` elements carry the full label and the edge context for hover and assistive technology.11 */1213const FILL: Record<NodeType, string> = {14 cancer: 'var(--color-series-1)',15 gene: 'var(--color-series-7)',16 variant: 'var(--color-series-8)',17 drug: 'var(--color-series-2)',18 trial: 'var(--color-series-3)',19 approval: 'var(--color-series-4)',20};2122/** Mark shape per entity type (secondary encoding, independent of colour). */23function Mark({ type, x, y, r, fill }: { type: NodeType; x: number; y: number; r: number; fill: string }) {24 const common = { fill, stroke: 'var(--color-paper)', strokeWidth: 1.5 } as const;25 switch (type) {26 case 'gene':27 return <rect x={x - r} y={y - r} width={2 * r} height={2 * r} {...common} />;28 case 'variant':29 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} />;30 case 'drug':31 return <rect x={x - r} y={y - r} width={2 * r} height={2 * r} rx={r * 0.45} {...common} />;32 case 'trial':33 return <polygon points={hexagon(x, y, r * 1.1)} {...common} />;34 case 'approval':35 return <polygon points={`${x},${y - r * 1.2} ${x + r * 1.2},${y} ${x},${y + r * 1.2} ${x - r * 1.2},${y}`} {...common} />;36 default:37 return <circle cx={x} cy={y} r={r} {...common} />;38 }39}4041function hexagon(cx: number, cy: number, r: number): string {42 const pts: string[] = [];43 for (let i = 0; i < 6; i++) {44 const a = (Math.PI / 3) * i - Math.PI / 6;45 pts.push(`${(cx + r * Math.cos(a)).toFixed(1)},${(cy + r * Math.sin(a)).toFixed(1)}`);46 }47 return pts.join(' ');48}4950function edgeTitle(e: GraphEdge, focus: GraphNode, neighbor: GraphNode): string {51 const from = e.outgoing ? focus.label : neighbor.label;52 const to = e.outgoing ? neighbor.label : focus.label;53 const bits = [e.via ? `${neighbor.label} ${relationshipLabel(e.relationshipType)} ${e.via.label} — in ${focus.label}` : `${from} ${relationshipLabel(e.relationshipType)} ${to}`];54 if (e.direction) bits.push(`direction: ${e.direction}`);55 if (e.evidenceLevel) bits.push(`evidence level: ${e.evidenceLevel}`);56 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}` : ''}`);57 bits.push(`${e.derived ? 'derived count' : e.evidenceCategory.replace(/_/g, ' ')} · source: ${e.sourceSlugs.join(', ')}`);58 if (e.detail) bits.push(e.detail);59 return bits.join(' · ');60}6162export function RadialGraph({ focus, nodes, edges, size = 760, maxNodes = 60, className = '' }: { focus: GraphNode; nodes: GraphNode[]; edges: GraphEdge[]; size?: number; maxNodes?: number; className?: string }) {63 const layout = layoutRadial(nodes, { size, maxNodes });64 const placed = new Map<string, PlacedNode>(layout.nodes.map((p) => [nodeKey(p.node), p]));65 const byNeighbor = new Map<string, GraphEdge[]>();66 for (const e of edges) {67 if (!placed.has(e.neighborKey)) continue;68 byNeighbor.set(e.neighborKey, [...(byNeighbor.get(e.neighborKey) ?? []), e]);69 }70 const { cx, cy } = layout;71 const focusR = layout.focus.r;7273 return (74 <figure className={`ci-graph ${className}`}>75 <div className="overflow-x-auto">76 <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)' }}>77 <title id="ci-graph-title">{`Knowledge graph around ${focus.label}`}</title>78 <desc id="ci-graph-desc">{`${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.`}</desc>7980 {/* Sector captions: inside the ring, on the sector's mid-angle, with a paper halo so they stay legible over the spokes */}81 {layout.sectors.map((s) => {82 const mid = (s.start + s.end) / 2;83 const capR = s.ring - 34;84 const capX = cx + capR * Math.cos(mid);85 const capY = cy + capR * Math.sin(mid);86 return (87 <text key={s.type} aria-hidden x={capX.toFixed(1)} y={capY.toFixed(1)} textAnchor="middle" dominantBaseline="middle" fontSize="10" letterSpacing="0.08em" fill="var(--color-ink-3)" stroke="var(--color-paper)" strokeWidth="3" paintOrder="stroke" style={{ textTransform: 'uppercase' }}>88 {`${NODE_TYPE_LABEL[s.type]} · ${s.count}`}89 </text>90 );91 })}9293 {/* Edges: one spoke per (neighbour, relationship), parallel offsets when several */}94 <g>95 {layout.nodes.map((p) => {96 const list = byNeighbor.get(nodeKey(p.node)) ?? [];97 const shown = list.slice(0, 3);98 const offsets = parallelOffsets(shown.length);99 const ux = p.x - cx;100 const uy = p.y - cy;101 const len = Math.hypot(ux, uy) || 1;102 const nx = -uy / len; // unit normal103 const ny = ux / len;104 const sx = cx + (ux / len) * (focusR + 3);105 const sy = cy + (uy / len) * (focusR + 3);106 const ex = p.x - (ux / len) * (p.r + 2);107 const ey = p.y - (uy / len) * (p.r + 2);108 return shown.map((e, i) => {109 const o = offsets[i]!;110 const dashed = edgeStroke(e) === 'dashed';111 return (112 <line key={e.key} x1={(sx + nx * o).toFixed(1)} y1={(sy + ny * o).toFixed(1)} x2={(ex + nx * o).toFixed(1)} y2={(ey + ny * o).toFixed(1)} stroke={dashed ? 'var(--color-ink-4)' : 'var(--color-ink-3)'} strokeWidth={dashed ? 1 : 1.2} strokeDasharray={dashed ? '3 3' : undefined} strokeOpacity={0.9}>113 <title>{edgeTitle(e, focus, p.node)}</title>114 </line>115 );116 });117 })}118 </g>119120 {/* Neighbour nodes */}121 {layout.nodes.map((p) => {122 const lp = labelPlacement(p);123 const href = focusHref(p.node.type, p.node.ref);124 const label = `${p.node.label}${p.node.sublabel ? ` — ${p.node.sublabel}` : ''} · ${p.node.degree} edge${p.node.degree === 1 ? '' : 's'} here`;125 const body = (126 <>127 <title>{label}</title>128 <Mark type={p.node.type} x={p.x} y={p.y} r={p.r} fill={FILL[p.node.type]} />129 <text transform={`rotate(${lp.rotate.toFixed(2)} ${lp.x.toFixed(1)} ${lp.y.toFixed(1)})`} x={lp.x} y={lp.y} textAnchor={lp.anchor} dominantBaseline="middle" fontSize="11" fill="var(--color-ink)">130 {shortLabel(p.node.label)}131 </text>132 </>133 );134 return (135 <g key={nodeKey(p.node)} className="ci-graph-node">136 {href ? (137 <a href={href} aria-label={`Explore ${p.node.label} in the graph`}>138 {body}139 </a>140 ) : (141 <a href={p.node.href} aria-label={`Open ${p.node.label}`}>142 {body}143 </a>144 )}145 </g>146 );147 })}148149 {/* Focus */}150 <g>151 <title>{`${focus.label} · ${NODE_TYPE_LABEL[focus.type].replace(/s$/, '')} · focus`}</title>152 <a href={focus.href} aria-label={`Open the ${focus.type} page for ${focus.label}`}>153 <Mark type={focus.type} x={cx} y={cy} r={focusR} fill={FILL[focus.type]} />154 </a>155 <text x={cx} y={cy + focusR + 14} textAnchor="middle" fontSize="12" fontWeight={600} fill="var(--color-ink)">156 {shortLabel(focus.label, 34)}157 </text>158 </g>159 </svg>160 </div>161162 <figcaption className="mt-2 flex flex-wrap items-center gap-x-4 gap-y-1.5 text-[12px] text-ink-3">163 <span className="ci-kicker">Legend</span>164 {NODE_TYPE_ORDER.map((t) => (165 <span key={t} className="inline-flex items-center gap-1.5">166 <svg width="14" height="14" viewBox="-8 -8 16 16" aria-hidden>167 <Mark type={t} x={0} y={0} r={5} fill={FILL[t]} />168 </svg>169 {NODE_TYPE_LABEL[t]}170 </span>171 ))}172 <span className="inline-flex items-center gap-1.5">173 <svg width="26" height="8" viewBox="0 0 26 8" aria-hidden>174 <line x1="0" y1="4" x2="26" y2="4" stroke="var(--color-ink-3)" strokeWidth="1.4" />175 </svg>176 source-native edge (curated / regulatory)177 </span>178 <span className="inline-flex items-center gap-1.5">179 <svg width="26" height="8" viewBox="0 0 26 8" aria-hidden>180 <line x1="0" y1="4" x2="26" y2="4" stroke="var(--color-ink-4)" strokeWidth="1.2" strokeDasharray="3 3" />181 </svg>182 derived count (registries)183 </span>184 <span>mark size = log of edges at the node</span>185 {layout.hidden > 0 ? (186 <span>187 {layout.hidden} more neighbour{layout.hidden === 1 ? '' : 's'} not drawn (cap {maxNodes}) — all listed in the table188 </span>189 ) : null}190 <Link href={focus.href} className="ci-link ml-auto">191 Entity page ↗192 </Link>193 </figcaption>194 </figure>195 );196}197