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%
10.0 KB · 197 lines tsx
Raw Blame History
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