diff --git a/client/src/components/universeBuilder/UniverseBuilderPage.jsx b/client/src/components/universeBuilder/UniverseBuilderPage.jsx
index 83118ea342..6be4930a08 100644
--- a/client/src/components/universeBuilder/UniverseBuilderPage.jsx
+++ b/client/src/components/universeBuilder/UniverseBuilderPage.jsx
@@ -12,7 +12,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Link, useLocation, useParams } from 'react-router';
import {
ArrowLeft, BookOpen, FolderTree, ImagePlus, Layers, Loader2,
- MapPin, Package, Plus, Save, Trash2, Users,
+ MapPin, Network, Package, Plus, Save, Trash2, Users,
} from 'lucide-react';
import InlineConfirmRow from '../ui/InlineConfirmRow';
import toast from '../ui/Toast';
@@ -35,6 +35,7 @@ import ShareToButton from '../sharing/ShareToButton';
import SyncToPeerButton from '../sharing/SyncToPeerButton';
import TabPills from '../ui/TabPills';
import CompositeSheetsEditor from './CompositeSheetsEditor';
+import UniverseGraphTab from './graph/UniverseGraphTab';
import RenderTab from './RenderTab';
import UniverseBibleTab from './UniverseBibleTab';
import { OtherTab, TrunkView } from './UniverseTrunkPanels';
@@ -46,6 +47,7 @@ import {
TAB_BIBLE,
TAB_CAST,
TAB_COMPOSITES,
+ TAB_GRAPH,
TAB_OBJECTS,
TAB_OTHER,
TAB_PLACES,
@@ -456,6 +458,7 @@ export default function UniverseBuilder() {
hasOtherBuckets && { id: TAB_OTHER, label: 'Other', icon: FolderTree, count: bucketsByKind.other.reduce((n, k) => n + (draft.categories?.[k]?.variations?.length || 0), 0) },
{ id: TAB_COMPOSITES, label: 'Composites', icon: Layers, count: totalSheets },
{ id: TAB_RENDER, label: 'Render', icon: ImagePlus },
+ { id: TAB_GRAPH, label: 'Graph', icon: Network },
]}
/>
@@ -609,6 +612,10 @@ export default function UniverseBuilder() {
runs={runs}
/>
)}
+
+ {activeTab === TAB_GRAPH && (
+
+ )}
{/* Single page-level lightbox for every thumb on the page: variation
diff --git a/client/src/components/universeBuilder/UniverseBuilderPage.test.jsx b/client/src/components/universeBuilder/UniverseBuilderPage.test.jsx
index 59e441a689..5d017fc4c9 100644
--- a/client/src/components/universeBuilder/UniverseBuilderPage.test.jsx
+++ b/client/src/components/universeBuilder/UniverseBuilderPage.test.jsx
@@ -41,13 +41,15 @@ vi.mock('../ui/TabPills', () => ({ default: () => null }));
vi.mock('./CompositeSheetsEditor', () => ({ default: () => null }));
vi.mock('./RenderTab', () => ({ default: () => null }));
vi.mock('./UniverseBibleTab', () => ({ default: () => null }));
+vi.mock('./graph/UniverseGraphTab', () => ({ default: () => null }));
vi.mock('./UniverseCategoryEditor', () => ({ CategoryEditor: () => null }));
vi.mock('./UniverseTrunkPanels', () => ({ OtherTab: () => null, TrunkView: () => null }));
vi.mock('lucide-react', () => {
const Icon = () => null;
return {
ArrowLeft: Icon, BookOpen: Icon, FolderTree: Icon, ImagePlus: Icon, Layers: Icon,
- Loader2: Icon, MapPin: Icon, Package: Icon, Plus: Icon, Save: Icon, Trash2: Icon, Users: Icon,
+ Loader2: Icon, MapPin: Icon, Network: Icon, Package: Icon, Plus: Icon, Save: Icon,
+ Trash2: Icon, Users: Icon,
};
});
diff --git a/client/src/components/universeBuilder/graph/GraphCanvas.jsx b/client/src/components/universeBuilder/graph/GraphCanvas.jsx
new file mode 100644
index 0000000000..a11daa94bd
--- /dev/null
+++ b/client/src/components/universeBuilder/graph/GraphCanvas.jsx
@@ -0,0 +1,539 @@
+/**
+ * The Universe Builder graph canvas: force/radial/timeline layout, pan + zoom,
+ * node drag, hover tooltip and selection.
+ *
+ * Positions live on the node objects the parent hands down (the simulation
+ * mutates them in place), so toggling a filter or switching layout keeps the
+ * graph where the user left it instead of re-scattering it.
+ */
+
+import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
+import { Loader2, Maximize2, Minus, Plus } from 'lucide-react';
+import {
+ GRAPH_KINDS, GRAPH_KIND_ORDER, edgeDef, hexToRgba, kindDef, nodeInitials,
+} from '../../../lib/universeGraphModel';
+import {
+ applyAnchors, computeFit, hitTest, radiusFor, seedPositions, settleLayout,
+ stepLayout, timelineGeometry, toScreen,
+} from '../../../lib/universeGraphLayout';
+
+const ALPHA_MIN = 0.003;
+const ZOOM_STEP = 1.3;
+const MIN_ZOOM = 0.15;
+const MAX_ZOOM = 6;
+// A drag under this many pixels is a click, not a pan — otherwise a slightly
+// shaky click on a node never selects it.
+const CLICK_SLOP_PX = 3;
+
+const LABELLED_KINDS = new Set(['character', 'place', 'series']);
+
+function drawGraph(ctx, {
+ view, width, height, nodes, edges, degree, match, adjacency,
+ selectedId, hoveredId, timeIndex, layout, totalIssues, series, issues, kinds, dpr,
+}) {
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
+ ctx.clearRect(0, 0, width, height);
+
+ const ghost = (node) => timeIndex != null && (node.firstIssue || 0) > timeIndex;
+ const highlightId = selectedId || hoveredId;
+ const near = highlightId
+ ? new Set([highlightId, ...(adjacency.get(highlightId) || [])
+ .map((e) => (e.source === highlightId ? e.target : e.source))])
+ : null;
+
+ ctx.save();
+ ctx.translate(view.x, view.y);
+ ctx.scale(view.k, view.k);
+
+ if (layout === 'timeline' && totalIssues > 0) {
+ // Series bands + kind lane labels sit under the nodes so the strip reads
+ // as a chart rather than a scatter.
+ const { width: TW, laneHeight, top } = timelineGeometry;
+ ctx.font = `${11 / view.k}px -apple-system, Segoe UI, sans-serif`;
+ ctx.textAlign = 'left';
+ ctx.textBaseline = 'alphabetic';
+ series.forEach((s, i) => {
+ const own = issues.filter((x) => x.seriesId === s.id);
+ if (!own.length) return;
+ const x0 = (own[0].index / totalIssues) * TW - TW / 2;
+ const x1 = ((own[own.length - 1].index + 1) / totalIssues) * TW - TW / 2;
+ ctx.fillStyle = hexToRgba(GRAPH_KINDS.series.color, 0.05 + (i % 2) * 0.03);
+ ctx.fillRect(x0, top - 50, x1 - x0, GRAPH_KIND_ORDER.length * laneHeight + 100);
+ ctx.fillStyle = '#6b7280';
+ ctx.fillText(s.name, x0 + 6, top - 34);
+ });
+ GRAPH_KIND_ORDER.forEach((kind, lane) => {
+ if (!kinds.has(kind)) return;
+ ctx.fillStyle = hexToRgba(GRAPH_KINDS[kind].color, 0.5);
+ ctx.fillText(GRAPH_KINDS[kind].label, -TW / 2 - 150, lane * laneHeight + top + 4);
+ ctx.strokeStyle = 'rgba(255,255,255,0.04)';
+ ctx.beginPath();
+ ctx.moveTo(-TW / 2 - 150, lane * laneHeight + top + 30);
+ ctx.lineTo(TW / 2 + 60, lane * laneHeight + top + 30);
+ ctx.stroke();
+ });
+ }
+
+ ctx.lineCap = 'round';
+ for (const edge of edges) {
+ const def = edgeDef(edge.type);
+ let alpha = def.group === 'relationship' ? 0.7 : def.group === 'attachment' ? 0.45 : 0.22;
+ if (near) {
+ alpha = near.has(edge.source) && near.has(edge.target)
+ && (edge.source === highlightId || edge.target === highlightId) ? 0.95 : 0.05;
+ }
+ if (ghost(edge.sourceNode) || ghost(edge.targetNode)
+ || (timeIndex != null && (edge.since || 0) > timeIndex)) alpha *= 0.15;
+ if (match && !(match.has(edge.source) || match.has(edge.target))) alpha *= 0.2;
+ ctx.strokeStyle = hexToRgba(def.color, alpha);
+ ctx.lineWidth = (def.group === 'relationship' ? 1.6 : def.group === 'appearance' ? 1 : 0.7) / Math.sqrt(view.k);
+ ctx.setLineDash(def.dashed ? [4 / view.k, 4 / view.k] : []);
+ ctx.beginPath();
+ ctx.moveTo(edge.sourceNode.x, edge.sourceNode.y);
+ if (def.group === 'relationship') {
+ // Bow typed links so a mutual pair draws as two arcs, not one line.
+ const mx = (edge.sourceNode.x + edge.targetNode.x) / 2;
+ const my = (edge.sourceNode.y + edge.targetNode.y) / 2;
+ const dx = edge.targetNode.x - edge.sourceNode.x;
+ const dy = edge.targetNode.y - edge.sourceNode.y;
+ const d = Math.sqrt(dx * dx + dy * dy) || 1;
+ const off = Math.min(18, d * 0.12);
+ ctx.quadraticCurveTo(mx - (dy / d) * off, my + (dx / d) * off, edge.targetNode.x, edge.targetNode.y);
+ } else ctx.lineTo(edge.targetNode.x, edge.targetNode.y);
+ ctx.stroke();
+ if (edge.directed && alpha > 0.3) {
+ const dx = edge.targetNode.x - edge.sourceNode.x;
+ const dy = edge.targetNode.y - edge.sourceNode.y;
+ const d = Math.sqrt(dx * dx + dy * dy) || 1;
+ const r = radiusFor(edge.targetNode, degree.get(edge.target) || 0) + 2;
+ const ax = edge.targetNode.x - (dx / d) * r;
+ const ay = edge.targetNode.y - (dy / d) * r;
+ const size = 5 / Math.sqrt(view.k);
+ ctx.fillStyle = hexToRgba(def.color, alpha);
+ ctx.beginPath();
+ ctx.moveTo(ax, ay);
+ ctx.lineTo(ax - (dx / d) * size - (dy / d) * size * 0.6, ay - (dy / d) * size + (dx / d) * size * 0.6);
+ ctx.lineTo(ax - (dx / d) * size + (dy / d) * size * 0.6, ay - (dy / d) * size - (dx / d) * size * 0.6);
+ ctx.closePath();
+ ctx.fill();
+ }
+ }
+ ctx.setLineDash([]);
+
+ // Labels are placed in screen space and culled on overlap, highest priority
+ // first — otherwise a dense cast renders as a wall of overlapping names.
+ const labelFont = `${11 / view.k}px -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif`;
+ const placed = [];
+ const labelFits = (node, r) => {
+ ctx.font = labelFont;
+ const w = ctx.measureText(node.name).width * view.k + 6;
+ const [sx, sy] = toScreen(view, node.x, node.y + r);
+ const rect = { x0: sx - w / 2, y0: sy + 2, x1: sx + w / 2, y1: sy + 16 };
+ const pinned = node.id === selectedId || node.id === hoveredId;
+ if (!pinned && placed.some((p) => rect.x0 < p.x1 && rect.x1 > p.x0 && rect.y0 < p.y1 && rect.y1 > p.y0)) return false;
+ placed.push(rect);
+ return true;
+ };
+ const priority = (node) => {
+ if (node.id === selectedId || node.id === hoveredId) return 3;
+ if ((near && near.has(node.id)) || (match && match.has(node.id))) return 2;
+ return 0;
+ };
+ const ordered = nodes.slice().sort((a, b) =>
+ priority(b) - priority(a) || (degree.get(b.id) || 0) - (degree.get(a.id) || 0));
+
+ const labelPass = [];
+ for (const node of ordered) {
+ const color = kindDef(node.kind).color;
+ const r = radiusFor(node, degree.get(node.id) || 0);
+ let alpha = 1;
+ if (near && !near.has(node.id)) alpha = 0.18;
+ if (match && !match.has(node.id)) alpha = Math.min(alpha, 0.15);
+ const isGhost = ghost(node);
+ if (isGhost) alpha *= 0.25;
+ const isSelected = node.id === selectedId;
+ const isHovered = node.id === hoveredId;
+
+ if (node.kind === 'image' || node.kind === 'composite' || node.kind === 'moodboard') {
+ ctx.fillStyle = hexToRgba(color, alpha * 0.85);
+ ctx.strokeStyle = hexToRgba('#ffffff', alpha * 0.35);
+ ctx.lineWidth = 0.8 / view.k;
+ const rr = r * 1.1;
+ ctx.beginPath();
+ ctx.roundRect(node.x - rr, node.y - rr, rr * 2, rr * 2, 1.5);
+ ctx.fill();
+ ctx.stroke();
+ } else {
+ if (node.hasImage) {
+ const g = ctx.createRadialGradient(node.x - r * 0.35, node.y - r * 0.35, r * 0.1, node.x, node.y, r);
+ g.addColorStop(0, hexToRgba('#ffffff', alpha * 0.35));
+ g.addColorStop(0.5, hexToRgba(color, alpha * 0.9));
+ g.addColorStop(1, hexToRgba(color, alpha * 0.55));
+ ctx.fillStyle = g;
+ } else ctx.fillStyle = hexToRgba(color, alpha * 0.28);
+ ctx.beginPath();
+ ctx.arc(node.x, node.y, r, 0, Math.PI * 2);
+ ctx.fill();
+ ctx.lineWidth = (node.hasImage ? 1.6 : 1.2) / Math.sqrt(view.k);
+ ctx.strokeStyle = hexToRgba(color, alpha);
+ if (isGhost) ctx.setLineDash([2 / view.k, 3 / view.k]);
+ ctx.stroke();
+ ctx.setLineDash([]);
+ if (node.kind === 'character' && r * view.k > 9) {
+ ctx.fillStyle = hexToRgba('#ffffff', alpha * (node.hasImage ? 0.95 : 0.75));
+ ctx.font = `600 ${Math.max(6, r * 0.85)}px -apple-system, Segoe UI, sans-serif`;
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'middle';
+ ctx.fillText(nodeInitials(node.name), node.x, node.y + 0.5);
+ }
+ }
+ if (node.locked) {
+ ctx.fillStyle = hexToRgba('#2563eb', alpha);
+ ctx.beginPath();
+ ctx.arc(node.x + r * 0.75, node.y - r * 0.75, 2.2 / Math.sqrt(view.k), 0, Math.PI * 2);
+ ctx.fill();
+ }
+ if (isSelected || isHovered) {
+ ctx.strokeStyle = isSelected ? '#ffffff' : hexToRgba('#ffffff', 0.6);
+ ctx.lineWidth = 2 / view.k;
+ ctx.beginPath();
+ ctx.arc(node.x, node.y, r + 3.5 / view.k, 0, Math.PI * 2);
+ ctx.stroke();
+ }
+ const wantsLabel = isSelected || isHovered
+ || (near && near.has(node.id)) || (match && match.has(node.id))
+ || node.kind === 'character' || (LABELLED_KINDS.has(node.kind) && view.k > 0.75)
+ || view.k > 1.6;
+ if (wantsLabel && alpha > 0.2 && labelFits(node, r)) labelPass.push([node, r, alpha, isSelected]);
+ }
+ for (const [node, r, alpha, isSelected] of labelPass) {
+ ctx.font = (isSelected ? '600 ' : '') + labelFont;
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'top';
+ const ly = node.y + r + 3 / view.k;
+ ctx.lineWidth = 3 / view.k;
+ ctx.strokeStyle = 'rgba(30,30,30,0.85)';
+ ctx.lineJoin = 'round';
+ ctx.strokeText(node.name, node.x, ly);
+ ctx.fillStyle = isSelected
+ ? '#ffffff'
+ : hexToRgba(node.kind === 'issue' || node.kind === 'image' ? '#6b7280' : '#d1d5db', alpha);
+ ctx.fillText(node.name, node.x, ly);
+ }
+ ctx.restore();
+}
+
+export default function GraphCanvas({
+ index, visible, layout, kinds, selectedId, hoveredId, focusId, timeIndex,
+ loading, statsLabel, resetToken, onSelect, onHover, onFocus,
+}) {
+ const wrapRef = useRef(null);
+ const canvasRef = useRef(null);
+ const viewRef = useRef({ x: 0, y: 0, k: 1 });
+ const alphaRef = useRef(1);
+ const dragRef = useRef(null);
+ // Seeded false, not true: every mount path below calls markDirty() before the
+ // first frame, so an initial `true` would be a second, redundant source of
+ // truth for "needs a repaint".
+ const dirtyRef = useRef(false);
+ const sizeRef = useRef({ width: 0, height: 0, dpr: 1 });
+ const fitPendingRef = useRef(true);
+ const seededRef = useRef(null);
+ const settledRef = useRef(false);
+ const [cursor, setCursor] = useState('grab');
+ const [tip, setTip] = useState(null);
+ const [legendOpen, setLegendOpen] = useState(true);
+
+ // Everything the draw + step passes need, refreshed each render without
+ // restarting the animation loop.
+ const frame = useRef({});
+ frame.current = { index, visible, layout, kinds, selectedId, hoveredId, timeIndex };
+
+ const markDirty = useCallback(() => { dirtyRef.current = true; }, []);
+
+ const fit = useCallback(() => {
+ const { width, height } = sizeRef.current;
+ viewRef.current = computeFit(visible.nodes, { width, height, layout });
+ markDirty();
+ }, [visible.nodes, layout, markDirty]);
+
+ // Seed once per loaded graph, re-anchor on every layout switch, then settle
+ // synchronously so the first paint shows a resolved shape rather than an
+ // expanding blob. Re-seeding is deliberately per-GRAPH: a filter change must
+ // not scatter the layout the user has been reading.
+ useLayoutEffect(() => {
+ if (seededRef.current !== index) {
+ seededRef.current = index;
+ seedPositions(index.nodes);
+ }
+ applyAnchors(index.nodes, layout, { totalIssues: index.totalIssues });
+ settleLayout(visible.nodes, visible.edges, { layout, degree: visible.degree });
+ alphaRef.current = 0;
+ fitPendingRef.current = true;
+ settledRef.current = true;
+ markDirty();
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [index, layout]);
+
+ // A filter / focus change re-heats the simulation gently and refits — but not
+ // in the same commit the effect above already settled, or the first paint
+ // would immediately jiggle out of the shape it just resolved to.
+ useEffect(() => {
+ if (settledRef.current) { settledRef.current = false; return; }
+ alphaRef.current = Math.max(alphaRef.current, 0.5);
+ fitPendingRef.current = true;
+ markDirty();
+ }, [visible.nodes, visible.edges, focusId, markDirty]);
+
+ useEffect(() => { markDirty(); }, [selectedId, hoveredId, timeIndex, markDirty]);
+
+ // "Reset view" from the toolbar — refit on the next frame rather than
+ // reaching into the canvas from the parent.
+ useEffect(() => {
+ if (!resetToken) return;
+ fitPendingRef.current = true;
+ markDirty();
+ }, [resetToken, markDirty]);
+
+ useEffect(() => {
+ const wrap = wrapRef.current;
+ const canvas = canvasRef.current;
+ if (!wrap || !canvas) return undefined;
+ const resize = () => {
+ const dpr = window.devicePixelRatio || 1;
+ const width = wrap.clientWidth;
+ const height = wrap.clientHeight;
+ if (!width || !height) return;
+ sizeRef.current = { width, height, dpr };
+ canvas.width = width * dpr;
+ canvas.height = height * dpr;
+ fitPendingRef.current = true;
+ markDirty();
+ };
+ resize();
+ const observer = new ResizeObserver(resize);
+ observer.observe(wrap);
+ return () => observer.disconnect();
+ }, [markDirty]);
+
+ useEffect(() => {
+ let raf = 0;
+ const loop = () => {
+ raf = requestAnimationFrame(loop);
+ const { width, height, dpr } = sizeRef.current;
+ const canvas = canvasRef.current;
+ if (!canvas || !width) return;
+ const f = frame.current;
+ if (alphaRef.current > ALPHA_MIN) {
+ alphaRef.current = stepLayout(f.visible.nodes, f.visible.edges, {
+ alpha: alphaRef.current,
+ layout: f.layout,
+ degree: f.visible.degree,
+ dragId: dragRef.current?.node?.id || null,
+ aspect: Math.min(2, width / height),
+ });
+ markDirty();
+ if (fitPendingRef.current && alphaRef.current < 0.01) {
+ fitPendingRef.current = false;
+ viewRef.current = computeFit(f.visible.nodes, { width, height, layout: f.layout });
+ }
+ } else if (fitPendingRef.current) {
+ fitPendingRef.current = false;
+ viewRef.current = computeFit(f.visible.nodes, { width, height, layout: f.layout });
+ markDirty();
+ }
+ if (!dirtyRef.current) return;
+ dirtyRef.current = false;
+ const ctx = canvas.getContext('2d');
+ if (!ctx) return;
+ drawGraph(ctx, {
+ view: viewRef.current,
+ width,
+ height,
+ dpr,
+ nodes: f.visible.nodes,
+ edges: f.visible.edges,
+ degree: f.visible.degree,
+ match: f.visible.match,
+ adjacency: f.index.adjacency,
+ selectedId: f.selectedId,
+ hoveredId: f.hoveredId,
+ timeIndex: f.timeIndex,
+ layout: f.layout,
+ totalIssues: f.index.totalIssues,
+ series: f.index.series,
+ issues: f.index.issues,
+ kinds: f.kinds,
+ });
+ };
+ loop();
+ return () => cancelAnimationFrame(raf);
+ // markDirty is a stable useCallback, so listing it can't restart the loop.
+ }, [markDirty]);
+
+ const pointAt = (event) => {
+ const rect = canvasRef.current.getBoundingClientRect();
+ return [event.clientX - rect.left, event.clientY - rect.top];
+ };
+
+ const zoomAt = useCallback((sx, sy, factor) => {
+ const view = viewRef.current;
+ const k = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, view.k * factor));
+ const ratio = k / view.k;
+ viewRef.current = { k, x: sx - (sx - view.x) * ratio, y: sy - (sy - view.y) * ratio };
+ markDirty();
+ }, [markDirty]);
+
+ // React routes wheel through a passive root listener, so preventDefault has
+ // to come from a native non-passive listener on the canvas itself.
+ useEffect(() => {
+ const canvas = canvasRef.current;
+ if (!canvas) return undefined;
+ const onWheel = (event) => {
+ event.preventDefault();
+ const rect = canvas.getBoundingClientRect();
+ zoomAt(event.clientX - rect.left, event.clientY - rect.top, Math.exp(-event.deltaY * 0.0015));
+ };
+ canvas.addEventListener('wheel', onWheel, { passive: false });
+ return () => canvas.removeEventListener('wheel', onWheel);
+ }, [zoomAt]);
+
+ const handleDown = (event) => {
+ const [sx, sy] = pointAt(event);
+ const node = hitTest(visible.nodes, viewRef.current, sx, sy, visible.degree);
+ dragRef.current = { sx, sy, x0: viewRef.current.x, y0: viewRef.current.y, node, moved: false };
+ event.currentTarget.setPointerCapture(event.pointerId);
+ setCursor('grabbing');
+ };
+
+ const handleMove = (event) => {
+ const [sx, sy] = pointAt(event);
+ const drag = dragRef.current;
+ if (drag) {
+ const dx = sx - drag.sx;
+ const dy = sy - drag.sy;
+ if (Math.abs(dx) + Math.abs(dy) > CLICK_SLOP_PX) drag.moved = true;
+ if (drag.node) {
+ const view = viewRef.current;
+ drag.node.x = (sx - view.x) / view.k;
+ drag.node.y = (sy - view.y) / view.k;
+ drag.node.tx = drag.node.x;
+ drag.node.ty = drag.node.y;
+ alphaRef.current = Math.max(alphaRef.current, 0.3);
+ } else {
+ viewRef.current = { ...viewRef.current, x: drag.x0 + dx, y: drag.y0 + dy };
+ }
+ markDirty();
+ return;
+ }
+ const node = hitTest(visible.nodes, viewRef.current, sx, sy, visible.degree);
+ setTip(node ? { x: sx, y: sy, node } : null);
+ setCursor(node ? 'pointer' : 'grab');
+ if ((node?.id || null) !== hoveredId) onHover(node?.id || null);
+ };
+
+ const handleUp = () => {
+ const drag = dragRef.current;
+ dragRef.current = null;
+ if (!drag) return;
+ setCursor(drag.node ? 'pointer' : 'grab');
+ if (!drag.moved) onSelect(drag.node ? drag.node.id : null);
+ markDirty();
+ };
+
+ const handleLeave = () => {
+ dragRef.current = null;
+ setTip(null);
+ setCursor('grab');
+ if (hoveredId) onHover(null);
+ };
+
+ const handleDoubleClick = (event) => {
+ const [sx, sy] = pointAt(event);
+ const node = hitTest(visible.nodes, viewRef.current, sx, sy, visible.degree);
+ if (node) onFocus(node.id);
+ };
+
+ const legendKinds = useMemo(
+ () => GRAPH_KIND_ORDER.filter((k) => kinds.has(k)).map((k) => ({ id: k, ...GRAPH_KINDS[k] })),
+ [kinds],
+ );
+
+ const tipNode = tip?.node;
+
+ return (
+
+
+ {loading && (
+
+ Building graph…
+
+ )}
+
+ {statsLabel}
+ {focusId && (
+ onFocus(null)}
+ className="pointer-events-auto px-2.5 py-1 text-[11px] rounded-lg bg-port-accent/20 border border-port-accent/30 text-port-accent"
+ >
+ Focused on {index.byId.get(focusId)?.name || 'node'} · clear
+
+ )}
+
+
+
zoomAt(sizeRef.current.width / 2, sizeRef.current.height / 2, ZOOM_STEP)} className="port-media-overlay port-media-overlay-item w-8 h-8 flex items-center justify-center rounded-lg">
+
zoomAt(sizeRef.current.width / 2, sizeRef.current.height / 2, 1 / ZOOM_STEP)} className="port-media-overlay port-media-overlay-item w-8 h-8 flex items-center justify-center rounded-lg">
+
+
+
+
setLegendOpen((open) => !open)}
+ aria-pressed={legendOpen}
+ className="port-media-overlay port-media-overlay-item self-start px-2 py-0.5 text-[10px] rounded"
+ >
+ {legendOpen ? 'Hide legend' : 'Legend'}
+
+ {legendOpen && (
+
+ {legendKinds.map((k) => (
+
+
+ {k.label}
+
+ ))}
+
+ )}
+
+ {tipNode && (
+
+
+
+ {tipNode.name}
+
+
{tipNode.role}
+
+ {kindDef(tipNode.kind).singular} · {visible.degree.get(tipNode.id) || 0} visible links
+
+
+ )}
+
+ );
+}
diff --git a/client/src/components/universeBuilder/graph/GraphInspector.jsx b/client/src/components/universeBuilder/graph/GraphInspector.jsx
new file mode 100644
index 0000000000..2e33f5be90
--- /dev/null
+++ b/client/src/components/universeBuilder/graph/GraphInspector.jsx
@@ -0,0 +1,389 @@
+/**
+ * Right-hand panel of the Graph tab. Three mutually exclusive faces:
+ * the gaps & enrichment list, the selected node's dossier, and — when nothing
+ * is selected — a universe overview.
+ */
+
+import { AlertTriangle, Crosshair, Image, X } from 'lucide-react';
+import {
+ GAP_CATEGORIES, edgeDef, evolutionStageRows, hexToRgba, kindDef, nodeInitials,
+} from '../../../lib/universeGraphModel';
+
+const Section = ({ title, trailing, children }) => (
+
+
+ {title}
+ {trailing}
+
+ {children}
+
+);
+
+const NodeRow = ({ node, meta, metaColor, onPick }) => (
+
+ onPick(node.id)}
+ className="w-full text-left flex items-center gap-2 px-2 py-1.5 rounded hover:bg-port-bg text-xs text-gray-300"
+ >
+
+ {node.name}
+ {meta && {meta} }
+
+
+);
+
+function GapsPanel({ gaps, category, onCategoryChange, counts, onPick, onClose }) {
+ const shown = gaps.filter((g) => category === 'all' || g.cat === category).slice(0, 60);
+ return (
+ <>
+
+
+ Gaps & enrichment
+
+
+
+
+
+
+ {GAP_CATEGORIES.map((c) => (
+ onCategoryChange(c.id)}
+ aria-pressed={category === c.id}
+ className={`px-2 py-0.5 rounded-full text-[11px] border ${
+ category === c.id
+ ? 'bg-port-warning/20 text-port-warning border-port-warning/30'
+ : 'bg-transparent text-gray-400 border-port-border'
+ }`}
+ >
+ {c.label} {counts[c.id] || 0}
+
+ ))}
+
+ {shown.length === 0 ? (
+ Nothing outstanding in this category.
+ ) : (
+
+ {shown.map((gap) => (
+
+ onPick(gap.nodeId)}
+ className="w-full text-left flex gap-2.5 items-start p-2 rounded border border-port-border bg-port-bg/60 hover:bg-port-bg"
+ >
+
+
+ {gap.title}
+ {gap.detail}
+ {gap.action}
+
+
+
+ ))}
+
+ )}
+ >
+ );
+}
+
+function Overview({ index, stats, gapCount, topNodes, onPick }) {
+ return (
+ <>
+ {index.name}
+
+ Click a node to inspect it. Drag to rearrange, scroll to zoom, double-click to focus a
+ neighbourhood. Scrub the timeline to see the world as it stood at any issue.
+
+
+ {stats.map((stat) => (
+
+
{stat.value}
+
{stat.label}
+
+ ))}
+
+
{gapCount}
+
Open gaps
+
+
+
+
+ {topNodes.map((node) => (
+
+ ))}
+
+
+ >
+ );
+}
+
+function Selection({
+ index, node, timeIndex, gaps, onPick, onClear, onFocus, onDossierPoster,
+}) {
+ const links = index.adjacency.get(node.id) || [];
+ const relationships = links.filter((e) => e.directed);
+ const connected = links.filter((e) => !e.directed && e.type !== 'appearance' && e.type !== 'membership');
+ const appearances = new Map();
+ for (const i of index.appear[node.id] || []) {
+ const issue = index.issues[i];
+ if (issue) appearances.set(issue.seriesId, (appearances.get(issue.seriesId) || 0) + 1);
+ }
+ const isCanon = ['character', 'place', 'object'].includes(node.kind);
+ const kind = kindDef(node.kind);
+ const badges = [{ label: kind.singular, color: kind.color }];
+ if (node.locked) badges.push({ label: 'locked', color: '#2563eb' });
+ if (isCanon && !node.hasImage) badges.push({ label: 'no render', color: '#f59e0b' });
+ if (timeIndex != null && (node.firstIssue || 0) > timeIndex) {
+ badges.push({ label: 'not yet introduced', color: '#9ca3af' });
+ }
+ const ownGaps = gaps.filter((g) => g.nodeId === node.id || g.otherId === node.id);
+ const sliders = node.sliders || null;
+ const framework = node.framework || null;
+
+ return (
+ <>
+
+
+ {nodeInitials(node.name)}
+
+
+
{node.name}
+
{node.role}
+
+ {badges.map((b) => (
+
+ {b.label}
+
+ ))}
+
+
+
+
+
+
+
+
+ onFocus(node.id)}
+ className="flex items-center gap-1.5 px-2.5 py-1.5 text-xs rounded-lg bg-port-accent/20 text-port-accent border border-port-accent/30"
+ >
+ Focus neighbourhood
+
+ {node.kind === 'character' && (
+ onDossierPoster(node.id)}
+ className="flex items-center gap-1.5 px-2.5 py-1.5 text-xs rounded-lg bg-port-bg text-gray-400 border border-port-border"
+ >
+ Dossier poster
+
+ )}
+
+
+ {node.kind === 'character' && (
+ <>
+
+ {sliders ? ['proactivity', 'likability', 'competence'].map((axis) => (
+
+ {axis}
+
+
+
+
+ {Number.isInteger(sliders[axis]) ? sliders[axis] : '—'}
+
+
+ )) : No axis rated yet.
}
+
+
+ {node.arcType} arc : null}
+ >
+ {framework ? ['ghost', 'wound', 'lie', 'need', 'want'].map((field) => (
+
+
{field}
+
{framework[field] || Not authored }
+
+ )) : No framework authored yet.
}
+
+
+ {node.evolution?.outcome || 'no lens'}}
+ >
+ {node.evolution ? (
+
+ {evolutionStageRows(node.evolution).map((row, i, all) => (
+
+
+
+ {i < all.length - 1 && }
+
+
+ {row.label}
+ {row.authored ? 'authored' : 'not authored'}
+
+
+ ))}
+
+ ) : (
+
+ No evolution lens authored yet.
+
+ )}
+
+
+ {relationships.length}}>
+ {relationships.length ? (
+
+ {relationships.map((edge) => {
+ const other = edge.source === node.id ? edge.targetNode : edge.sourceNode;
+ const def = edgeDef(edge.type);
+ return (
+
+ );
+ })}
+
+ ) : (
+
+ No typed relationships.
+
+ )}
+
+ >
+ )}
+
+ {connected.length}}>
+ {connected.length ? (
+
+ {connected.map((edge) => {
+ const other = edge.source === node.id ? edge.targetNode : edge.sourceNode;
+ return (
+
+ );
+ })}
+
+ ) : (
+ Nothing attached.
+ )}
+
+
+ {isCanon && (
+
+ {appearances.size ? [...appearances.entries()].map(([seriesId, count]) => (
+
+
+ {index.byId.get(seriesId)?.name || seriesId}
+ {count} issue{count === 1 ? '' : 's'}
+
+ )) : Not yet used in any series.
}
+
+ )}
+
+ {ownGaps.length > 0 && (
+
+ {ownGaps.map((gap) => (
+
+ {gap.title}
+ {gap.detail}
+
+ ))}
+
+ )}
+ >
+ );
+}
+
+export default function GraphInspector({
+ index, selectedNode, timeIndex, gaps, gapCounts, gapsOpen, gapCategory,
+ stats, topNodes, onPick, onClear, onFocus, onDossierPoster,
+ onGapCategoryChange, onCloseGaps,
+}) {
+ return (
+
+ {gapsOpen ? (
+
+ ) : selectedNode ? (
+
+ ) : (
+
+ )}
+
+ );
+}
diff --git a/client/src/components/universeBuilder/graph/GraphTimeline.jsx b/client/src/components/universeBuilder/graph/GraphTimeline.jsx
new file mode 100644
index 0000000000..fcb9cec48d
--- /dev/null
+++ b/client/src/components/universeBuilder/graph/GraphTimeline.jsx
@@ -0,0 +1,146 @@
+/**
+ * Timeline scrubber under the graph: drag (or play) to see the universe as it
+ * stood at any issue. `null` is the whole universe — the far right of the
+ * track — so the default view is everything, not issue zero.
+ */
+
+import { useEffect, useMemo } from 'react';
+import { Pause, Play } from 'lucide-react';
+
+// How long each issue holds while playing.
+const PLAY_STEP_MS = 700;
+const LANE_COLORS = ['#ec4899', '#f472b6', '#fb7185'];
+
+export default function GraphTimeline({
+ index, timeIndex, onTimeChange, playing, onPlayingChange, entriesIntroduced,
+}) {
+ const total = index.totalIssues;
+
+ // Playback walks one issue per tick and stops by returning to "whole
+ // universe" at the end, so the strip always lands somewhere meaningful.
+ useEffect(() => {
+ if (!playing || total === 0) return undefined;
+ const timer = setInterval(() => {
+ const next = timeIndex == null ? 0 : timeIndex + 1;
+ if (next >= total) {
+ onTimeChange(null);
+ onPlayingChange(false);
+ } else onTimeChange(next);
+ }, PLAY_STEP_MS);
+ return () => clearInterval(timer);
+ }, [playing, timeIndex, total, onTimeChange, onPlayingChange]);
+
+ // Lane bounds + colour per series, derived once instead of re-scanning every
+ // issue for every series (and again for every tick).
+ const lanes = useMemo(() => index.series.map((s, i) => {
+ const own = index.issues.filter((x) => x.seriesId === s.id);
+ return {
+ id: s.id,
+ name: s.name,
+ color: LANE_COLORS[i % LANE_COLORS.length],
+ first: own[0]?.index ?? null,
+ count: own.length,
+ };
+ }).filter((lane) => lane.count > 0), [index]);
+ const laneColorBySeries = useMemo(
+ () => new Map(lanes.map((lane) => [lane.id, lane.color])),
+ [lanes],
+ );
+
+ if (total === 0) {
+ return (
+
+ No series link to this universe yet — the timeline appears once an issue references its canon.
+
+ );
+ }
+
+ const current = timeIndex == null ? null : index.issues[timeIndex];
+ const seriesName = current
+ ? (index.byId.get(current.seriesId)?.name || '')
+ : `${total} issues across ${index.series.length} series`;
+
+ return (
+
+
+
onPlayingChange(!playing)}
+ aria-label={playing ? 'Pause timeline' : 'Play timeline'}
+ className="w-8 h-8 flex items-center justify-center rounded-lg bg-port-accent/20 border border-port-accent/30 text-port-accent"
+ >
+ {playing ? : }
+
+
+ {current ? current.name : 'Whole universe'}
+ · {current ? `${seriesName} · ${entriesIntroduced} entries so far` : seriesName}
+
+
+
{ onTimeChange(null); onPlayingChange(false); }}
+ className={`px-2.5 py-1 text-[11px] rounded-lg border ${
+ timeIndex == null
+ ? 'bg-port-accent/20 text-port-accent border-port-accent/30'
+ : 'bg-transparent text-gray-400 border-port-border'
+ }`}
+ >
+ Whole universe
+
+
+ Drag to see the universe as it stood at any issue
+
+
+
+ {lanes.map((lane) => (
+
+ {lane.name}
+
+ ))}
+
+ {index.issues.map((issue) => {
+ const past = timeIndex == null || issue.index <= timeIndex;
+ return (
+
+ );
+ })}
+
+
Timeline position
+
{
+ const value = Number(e.target.value);
+ onTimeChange(value >= total ? null : value);
+ onPlayingChange(false);
+ }}
+ className="absolute left-0 right-0 top-[22px] w-full m-0 h-2 bg-transparent accent-port-accent"
+ style={{ background: 'transparent' }}
+ />
+
+
+ );
+}
diff --git a/client/src/components/universeBuilder/graph/GraphToolbar.jsx b/client/src/components/universeBuilder/graph/GraphToolbar.jsx
new file mode 100644
index 0000000000..dd3eb45450
--- /dev/null
+++ b/client/src/components/universeBuilder/graph/GraphToolbar.jsx
@@ -0,0 +1,129 @@
+/**
+ * Filter + view controls above the universe graph canvas: search, layout,
+ * per-kind and per-edge-group visibility, and the two panel toggles.
+ */
+
+import { AlertTriangle, Image, Search } from 'lucide-react';
+import {
+ GRAPH_EDGE_GROUPS, GRAPH_KINDS, GRAPH_KIND_ORDER, edgeDef, hexToRgba,
+} from '../../../lib/universeGraphModel';
+import { GRAPH_LAYOUTS } from '../../../lib/universeGraphLayout';
+
+// One representative edge type per group, so a group pill takes the colour of
+// the family of links it toggles.
+const GROUP_SAMPLE_TYPE = { relationship: 'ally' };
+const groupColor = (groupId) => edgeDef(GROUP_SAMPLE_TYPE[groupId] || groupId).color;
+
+export default function GraphToolbar({
+ search, onSearchChange, layout, onLayoutChange, kinds, onToggleKind,
+ groups, onToggleGroup, kindCounts, gapCount, gapsOpen, onToggleGaps,
+ onOpenPoster, onResetView,
+}) {
+ return (
+
+
+
+ Search nodes
+ onSearchChange(e.target.value)}
+ placeholder="Search nodes…"
+ className="w-[190px] box-border bg-port-bg border border-port-border rounded px-2 py-1.5 pl-6 text-white text-xs focus:outline-none focus:border-port-accent"
+ />
+
+
+
+ {GRAPH_LAYOUTS.map((l) => (
+ onLayoutChange(l.id)}
+ aria-pressed={layout === l.id}
+ className={`px-2.5 py-1 rounded-md text-xs border ${
+ layout === l.id
+ ? 'bg-port-accent/20 text-port-accent border-port-accent/30'
+ : 'bg-transparent text-gray-400 border-transparent hover:text-white'
+ }`}
+ >
+ {l.label}
+
+ ))}
+
+
+
+ {GRAPH_KIND_ORDER.filter((k) => kindCounts[k]).map((k) => {
+ const on = kinds.has(k);
+ return (
+ onToggleKind(k)}
+ aria-pressed={on}
+ title={`${on ? 'Hide' : 'Show'} ${GRAPH_KINDS[k].label}`}
+ className={`flex items-center gap-1.5 text-xs ${on ? 'text-gray-400' : 'text-gray-600'}`}
+ >
+
+ {GRAPH_KINDS[k].label}
+ {kindCounts[k]}
+
+ );
+ })}
+
+
+
+
+
+ {GRAPH_EDGE_GROUPS.map((g) => {
+ const on = groups.has(g.id);
+ const color = groupColor(g.id);
+ return (
+ onToggleGroup(g.id)}
+ aria-pressed={on}
+ className="px-2 py-0.5 rounded-full text-[11px] border"
+ style={on
+ ? { background: hexToRgba(color, 0.15), color, borderColor: hexToRgba(color, 0.4) }
+ : { background: 'transparent', color: '#4b5563', borderColor: '#2a2a2a' }}
+ >
+ {g.label}
+
+ );
+ })}
+
+
+
+
+
+ Gaps {gapCount}
+
+
+ Poster
+
+
+ Reset view
+
+
+ );
+}
diff --git a/client/src/components/universeBuilder/graph/PosterBuilderModal.jsx b/client/src/components/universeBuilder/graph/PosterBuilderModal.jsx
new file mode 100644
index 0000000000..124068e7c9
--- /dev/null
+++ b/client/src/components/universeBuilder/graph/PosterBuilderModal.jsx
@@ -0,0 +1,178 @@
+/**
+ * Infographic poster builder. The preview canvas and the 2× PNG download go
+ * through the same `renderPoster` call, so what the user downloads is exactly
+ * what they picked.
+ */
+
+import { useCallback, useEffect, useRef, useState } from 'react';
+import { Download, Image } from 'lucide-react';
+import Modal from '../../ui/Modal';
+import toast from '../../ui/Toast';
+import { downloadBlob } from '../../../lib/downloadBlob';
+import {
+ POSTER_LAYOUTS, POSTER_SIZES, POSTER_THEMES, posterDimensions, renderPoster,
+} from '../../../lib/universeGraphPoster';
+
+const Segmented = ({ label, options, value, onChange, idPrefix }) => (
+
+
{label}
+
+ {options.map((option) => (
+ onChange(option.id)}
+ aria-pressed={value === option.id}
+ className={`flex-1 px-2 py-1 rounded-md text-xs border ${
+ value === option.id
+ ? 'bg-port-accent/20 text-port-accent border-port-accent/30'
+ : 'bg-transparent text-gray-400 border-transparent hover:text-white'
+ }`}
+ >
+ {option.label}
+
+ ))}
+
+
+);
+
+export default function PosterBuilderModal({
+ index, open, initialLayout, initialSubjectId, timeIndex, onClose,
+}) {
+ const canvasRef = useRef(null);
+ const [layout, setLayout] = useState(initialLayout || 'roster');
+ const [subjectId, setSubjectId] = useState(initialSubjectId || null);
+ const [size, setSize] = useState('portrait');
+ const [theme, setTheme] = useState('midnight');
+ const [respectTime, setRespectTime] = useState(true);
+ const [downloading, setDownloading] = useState(false);
+
+ useEffect(() => { if (open && initialLayout) setLayout(initialLayout); }, [open, initialLayout]);
+ useEffect(() => { if (open && initialSubjectId) setSubjectId(initialSubjectId); }, [open, initialSubjectId]);
+
+ const characters = index.nodes.filter((n) => n.kind === 'character');
+ const subject = subjectId || characters[0]?.id || null;
+ const asOfIssue = respectTime ? timeIndex : null;
+ const [W, H] = posterDimensions(size);
+
+ const options = useCallback(() => ({
+ index, layout, subjectId: subject, size, theme, asOfIssue,
+ }), [index, layout, subject, size, theme, asOfIssue]);
+
+ useEffect(() => {
+ const canvas = canvasRef.current;
+ if (!open || !canvas) return;
+ renderPoster(canvas, { ...options(), scale: 1 });
+ // Fit the preview to the pane without re-rendering at a fractional scale.
+ const maxW = (canvas.parentElement?.clientWidth || W / 2) - 8;
+ const maxH = Math.max(200, window.innerHeight - 320);
+ const factor = Math.min(maxW / W, maxH / H, 1);
+ canvas.style.width = `${Math.round(W * factor)}px`;
+ canvas.style.height = `${Math.round(H * factor)}px`;
+ }, [open, options, W, H]);
+
+ const download = () => {
+ setDownloading(true);
+ const canvas = document.createElement('canvas');
+ renderPoster(canvas, { ...options(), scale: 2 });
+ canvas.toBlob((blob) => {
+ setDownloading(false);
+ if (!blob) {
+ toast.error('Could not render the poster — try a smaller size.');
+ return;
+ }
+ const slug = String(index.name || 'universe').replace(/\W+/g, '-').toLowerCase();
+ downloadBlob(blob, `${slug}-${layout}.png`);
+ }, 'image/png');
+ };
+
+ if (!open) return null;
+
+ return (
+
+
+
+ Infographic poster
+
+ Close
+
+
+
+
+
Layout
+
+ {POSTER_LAYOUTS.map((option) => (
+ setLayout(option.id)}
+ aria-pressed={layout === option.id}
+ className={`text-left px-2.5 py-2 rounded border ${
+ layout === option.id
+ ? 'bg-port-accent/15 text-white border-port-accent/40'
+ : 'bg-port-bg/60 text-gray-300 border-port-border'
+ }`}
+ >
+ {option.label}
+ {option.desc}
+
+ ))}
+
+
+
+ {layout === 'dossier' && (
+
+ Subject
+ setSubjectId(e.target.value)}
+ className="w-full bg-port-bg border border-port-border rounded px-3 py-2 text-white text-sm"
+ >
+ {characters.map((node) => {node.name} )}
+
+
+ )}
+
+
+
+
+
+ setRespectTime(e.target.checked)}
+ className="accent-port-accent"
+ />
+ Respect timeline position
+ {timeIndex != null && ({index.issues[timeIndex]?.name}) }
+
+
+
+ Rendered at 2× for print. Entries without a render show initials.
+
+
+
+ Download PNG · {W * 2}×{H * 2}
+
+
+
+
+
+
+
+ );
+}
diff --git a/client/src/components/universeBuilder/graph/UniverseGraphTab.jsx b/client/src/components/universeBuilder/graph/UniverseGraphTab.jsx
new file mode 100644
index 0000000000..13e398fc64
--- /dev/null
+++ b/client/src/components/universeBuilder/graph/UniverseGraphTab.jsx
@@ -0,0 +1,259 @@
+/**
+ * Universe Builder → Graph tab.
+ *
+ * Reads the server-derived relationship graph once per universe and drives the
+ * canvas, inspector, timeline and poster builder off one in-memory index. The
+ * selected node lives in the URL (`?node=`) so a specific character's
+ * neighbourhood is shareable and survives a reload.
+ */
+
+import { useCallback, useEffect, useMemo, useState } from 'react';
+import { useSearchParams } from 'react-router';
+import { Loader2, Network } from 'lucide-react';
+import { getUniverseGraph } from '../../../services/api';
+import {
+ GRAPH_EDGE_GROUPS, GRAPH_KIND_ORDER, computeUniverseGaps, edgeDef, indexGraph, neighbourIds,
+} from '../../../lib/universeGraphModel';
+import GraphCanvas from './GraphCanvas';
+import GraphInspector from './GraphInspector';
+import GraphTimeline from './GraphTimeline';
+import GraphToolbar from './GraphToolbar';
+import PosterBuilderModal from './PosterBuilderModal';
+
+const EMPTY_GRAPH = { nodes: [], edges: [], issues: [], series: [], totalIssues: 0, appear: {}, name: '' };
+
+export default function UniverseGraphTab({ universeId, universeName }) {
+ const [searchParams, setSearchParams] = useSearchParams();
+ const [graph, setGraph] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [kinds, setKinds] = useState(() => new Set(GRAPH_KIND_ORDER));
+ const [groups, setGroups] = useState(() => new Set(GRAPH_EDGE_GROUPS.map((g) => g.id)));
+ const [layout, setLayout] = useState('force');
+ const [search, setSearch] = useState('');
+ const [hoveredId, setHoveredId] = useState(null);
+ const [focusId, setFocusId] = useState(null);
+ const [timeIndex, setTimeIndex] = useState(null);
+ const [playing, setPlaying] = useState(false);
+ const [gapsOpen, setGapsOpen] = useState(false);
+ const [gapCategory, setGapCategory] = useState('all');
+ const [poster, setPoster] = useState(null);
+ const [resetToken, setResetToken] = useState(0);
+
+ const selectedId = searchParams.get('node');
+
+ useEffect(() => {
+ if (!universeId) { setGraph(null); setLoading(false); return undefined; }
+ let cancelled = false;
+ setLoading(true);
+ setError(null);
+ // The tab already renders its own empty/error state, so the shared toast
+ // would be a second report of the same failure.
+ getUniverseGraph(universeId, { silent: true })
+ .then((data) => { if (!cancelled) { setGraph(data); setLoading(false); } })
+ .catch((err) => {
+ if (cancelled) return;
+ setError(err?.message || 'Could not load the universe graph.');
+ setLoading(false);
+ });
+ return () => { cancelled = true; };
+ }, [universeId]);
+
+ const index = useMemo(() => indexGraph(graph || { ...EMPTY_GRAPH, name: universeName }), [graph, universeName]);
+ const gaps = useMemo(() => computeUniverseGaps(index), [index]);
+ const gapCounts = useMemo(() => {
+ const counts = { all: gaps.length };
+ for (const gap of gaps) counts[gap.cat] = (counts[gap.cat] || 0) + 1;
+ return counts;
+ }, [gaps]);
+
+ const kindCounts = useMemo(() => {
+ const counts = Object.fromEntries(GRAPH_KIND_ORDER.map((k) => [k, 0]));
+ for (const node of index.nodes) counts[node.kind] = (counts[node.kind] || 0) + 1;
+ return counts;
+ }, [index]);
+
+ // The visible subset: kind filter → focus neighbourhood → edge-group filter,
+ // plus the search match set the canvas dims non-matches against.
+ const visible = useMemo(() => {
+ let nodes = index.nodes.filter((n) => kinds.has(n.kind));
+ if (focusId && index.byId.has(focusId)) {
+ const near = neighbourIds(index, focusId);
+ nodes = nodes.filter((n) => near.has(n.id));
+ }
+ const ids = new Set(nodes.map((n) => n.id));
+ const edges = index.edges.filter((e) =>
+ ids.has(e.source) && ids.has(e.target) && groups.has(edgeDef(e.type).group));
+ const query = search.trim().toLowerCase();
+ const match = query
+ ? new Set(nodes
+ .filter((n) => n.name.toLowerCase().includes(query) || (n.role || '').toLowerCase().includes(query))
+ .map((n) => n.id))
+ : null;
+ const degree = new Map(nodes.map((n) => [n.id, 0]));
+ for (const edge of edges) {
+ degree.set(edge.source, (degree.get(edge.source) || 0) + 1);
+ degree.set(edge.target, (degree.get(edge.target) || 0) + 1);
+ }
+ return { nodes, edges, match, degree };
+ }, [index, kinds, groups, focusId, search]);
+
+ const selectNode = useCallback((nodeId) => {
+ const next = new URLSearchParams(searchParams);
+ if (nodeId) next.set('node', nodeId);
+ else next.delete('node');
+ setSearchParams(next, { replace: true });
+ if (nodeId) setGapsOpen(false);
+ }, [searchParams, setSearchParams]);
+
+ // A `?node=` pointing at a record that no longer exists would leave the
+ // inspector permanently blank, so drop it once the graph has loaded.
+ useEffect(() => {
+ if (loading || !selectedId || index.byId.has(selectedId)) return;
+ const next = new URLSearchParams(searchParams);
+ next.delete('node');
+ setSearchParams(next, { replace: true });
+ }, [loading, selectedId, index, searchParams, setSearchParams]);
+
+ const toggleKind = useCallback((kind) => setKinds((prev) => {
+ const next = new Set(prev);
+ if (next.has(kind)) next.delete(kind); else next.add(kind);
+ return next;
+ }), []);
+ const toggleGroup = useCallback((group) => setGroups((prev) => {
+ const next = new Set(prev);
+ if (next.has(group)) next.delete(group); else next.add(group);
+ return next;
+ }), []);
+
+ const stats = useMemo(() => [
+ { label: 'Characters', value: kindCounts.character || 0 },
+ { label: 'Places · Objects', value: `${kindCounts.place || 0} · ${kindCounts.object || 0}` },
+ { label: 'Typed relationships', value: index.edges.filter((e) => e.directed).length },
+ { label: 'Series · Issues', value: `${kindCounts.series || 0} · ${kindCounts.issue || 0}` },
+ { label: 'Images & sheets', value: (kindCounts.image || 0) + (kindCounts.composite || 0) },
+ ], [kindCounts, index]);
+
+ const topNodes = useMemo(() => index.nodes
+ .filter((n) => n.kind !== 'issue' && n.kind !== 'image')
+ .sort((a, b) => (index.degree.get(b.id) || 0) - (index.degree.get(a.id) || 0))
+ .slice(0, 8), [index]);
+
+ const entriesIntroduced = useMemo(() => (timeIndex == null ? 0 : index.nodes.filter(
+ (n) => (n.firstIssue || 0) <= timeIndex && n.kind !== 'issue' && n.kind !== 'image',
+ ).length), [index, timeIndex]);
+
+ const statsLabel = `${visible.nodes.length} nodes · ${visible.edges.length} links${
+ search ? ` · ${visible.match ? visible.match.size : 0} matches` : ''}`;
+
+ if (!universeId) {
+ return (
+
+ Save this universe to see its relationship graph.
+
+ );
+ }
+
+ if (error) {
+ return (
+
+ {error}
+
+ );
+ }
+
+ if (loading && !graph) {
+ return (
+
+ Building graph…
+
+ );
+ }
+
+ if (index.nodes.length === 0) {
+ return (
+
+
+
+ Nothing to graph yet. Add canon entries on the Cast, Places, or Objects tabs — the graph
+ draws the relationships, attachments and series appearances they carry.
+
+
+ );
+ }
+
+ return (
+
+
setGapsOpen((open) => !open)}
+ onOpenPoster={() => setPoster({ layout: 'roster', subjectId: null })}
+ onResetView={() => { setFocusId(null); setResetToken((n) => n + 1); }}
+ />
+
+
+
+ selectNode(null)}
+ onFocus={setFocusId}
+ onDossierPoster={(nodeId) => setPoster({ layout: 'dossier', subjectId: nodeId })}
+ onGapCategoryChange={setGapCategory}
+ onCloseGaps={() => setGapsOpen(false)}
+ />
+
+
+
+
+ setPoster(null)}
+ />
+
+ );
+}
diff --git a/client/src/components/universeBuilder/graph/UniverseGraphTab.test.jsx b/client/src/components/universeBuilder/graph/UniverseGraphTab.test.jsx
new file mode 100644
index 0000000000..a2d41ad3e4
--- /dev/null
+++ b/client/src/components/universeBuilder/graph/UniverseGraphTab.test.jsx
@@ -0,0 +1,156 @@
+import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
+import { MemoryRouter, Route, Routes, useLocation } from 'react-router';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+const apiMocks = vi.hoisted(() => ({ getUniverseGraph: vi.fn() }));
+vi.mock('../../../services/api', () => apiMocks);
+
+const { default: UniverseGraphTab } = await import('./UniverseGraphTab');
+
+const GRAPH = {
+ universeId: 'u1',
+ name: 'Example Universe',
+ totalIssues: 2,
+ series: [{ id: 'series:s1', recordId: 's1', name: 'First Arc' }],
+ issues: [
+ { id: 'issue:i1', recordId: 'i1', index: 0, name: 'First Arc #1', seriesId: 'series:s1' },
+ { id: 'issue:i2', recordId: 'i2', index: 1, name: 'First Arc #2', seriesId: 'series:s1' },
+ ],
+ appear: { 'character:c1': [0, 1], 'character:c2': [1] },
+ nodes: [
+ {
+ id: 'character:c1',
+ kind: 'character',
+ name: 'Alice Vane',
+ role: 'Tidewarden',
+ hasImage: true,
+ locked: true,
+ firstIssue: 0,
+ arcType: 'positive',
+ sliders: { proactivity: 7 },
+ framework: { ghost: 'Lost the key.' },
+ evolution: null,
+ },
+ { id: 'character:c2', kind: 'character', name: 'Bob Ashe', role: 'Foil', hasImage: false, firstIssue: 1 },
+ { id: 'place:p1', kind: 'place', name: 'The Vault', role: 'INT. VAULT', hasImage: true, firstIssue: 0 },
+ { id: 'series:s1', kind: 'series', name: 'First Arc', role: '2 issues', hasImage: false, firstIssue: 0 },
+ { id: 'issue:i1', kind: 'issue', name: 'First Arc #1', role: 'Issue 1', hasImage: false, firstIssue: 0 },
+ { id: 'issue:i2', kind: 'issue', name: 'First Arc #2', role: 'Issue 2', hasImage: false, firstIssue: 1 },
+ ],
+ edges: [
+ { source: 'character:c1', target: 'character:c2', type: 'rival', directed: true, since: 1 },
+ { source: 'character:c1', target: 'issue:i1', type: 'appearance', since: 0 },
+ { source: 'issue:i1', target: 'series:s1', type: 'membership', since: 0 },
+ ],
+};
+
+let lastSearch = '';
+const SearchProbe = () => { lastSearch = useLocation().search; return null; };
+
+const renderTab = async (props = {}, initialEntry = '/universes/u1?tab=graph') => {
+ const view = render(
+
+
+ >}
+ />
+
+ ,
+ );
+ await act(async () => {});
+ return view;
+};
+
+beforeEach(() => {
+ lastSearch = '';
+ apiMocks.getUniverseGraph.mockReset();
+ apiMocks.getUniverseGraph.mockResolvedValue(GRAPH);
+});
+
+describe('UniverseGraphTab', () => {
+ it('shows the overview with derived counts once the graph loads', async () => {
+ await renderTab();
+ expect(await screen.findByText('Example Universe')).toBeTruthy();
+ // Scoped to the inspector: the toolbar's kind filters carry the same labels.
+ const inspector = within(screen.getByLabelText('Graph inspector'));
+ expect(inspector.getByText('Most connected')).toBeTruthy();
+ // 2 characters, 1 place + 0 objects.
+ expect(inspector.getByText('Characters').previousSibling.textContent).toBe('2');
+ expect(inspector.getByText('Places · Objects').previousSibling.textContent).toBe('1 · 0');
+ });
+
+ it('owns its own error state instead of letting the shared toast report it', async () => {
+ apiMocks.getUniverseGraph.mockRejectedValue(new Error('graph exploded'));
+ await renderTab();
+ expect(await screen.findByText('graph exploded')).toBeTruthy();
+ expect(apiMocks.getUniverseGraph).toHaveBeenCalledWith('u1', { silent: true });
+ });
+
+ it('asks the user to save before graphing an unsaved universe', async () => {
+ await renderTab({ universeId: null });
+ expect(screen.getByText(/Save this universe/)).toBeTruthy();
+ expect(apiMocks.getUniverseGraph).not.toHaveBeenCalled();
+ });
+
+ it('points an empty universe at the canon tabs rather than drawing nothing', async () => {
+ apiMocks.getUniverseGraph.mockResolvedValue({ ...GRAPH, nodes: [], edges: [], appear: {} });
+ await renderTab();
+ expect(await screen.findByText(/Nothing to graph yet/)).toBeTruthy();
+ });
+
+ it('writes the selected node into the URL and renders its dossier', async () => {
+ await renderTab();
+ fireEvent.click(await screen.findByText('Alice Vane'));
+ await waitFor(() => expect(lastSearch).toContain('node=character%3Ac1'));
+ expect(screen.getByText('Tidewarden')).toBeTruthy();
+ expect(screen.getByText('Character framework')).toBeTruthy();
+ expect(screen.getByText('No evolution lens authored yet.')).toBeTruthy();
+ });
+
+ it('restores the selection from the URL on load', async () => {
+ await renderTab({}, '/universes/u1?tab=graph&node=place%3Ap1');
+ expect(await screen.findByText('INT. VAULT')).toBeTruthy();
+ });
+
+ it('drops a ?node= that points at a record the graph no longer has', async () => {
+ await renderTab({}, '/universes/u1?tab=graph&node=character%3Agone');
+ await waitFor(() => expect(lastSearch).not.toContain('node='));
+ expect(screen.getByText('Most connected')).toBeTruthy();
+ });
+
+ it('lists the derived gaps behind the Gaps toggle', async () => {
+ await renderTab();
+ fireEvent.click(await screen.findByRole('button', { name: /Gaps/ }));
+ expect(screen.getByText('Gaps & enrichment')).toBeTruthy();
+ // Bob has no render, and the c1 → c2 rival link has no reverse.
+ expect(screen.getByText('Bob Ashe has no render')).toBeTruthy();
+ expect(screen.getByText('Alice Vane → Bob Ashe is one-directional')).toBeTruthy();
+ });
+
+ it('narrows the gap list to one category', async () => {
+ await renderTab();
+ fireEvent.click(await screen.findByRole('button', { name: /Gaps/ }));
+ fireEvent.click(screen.getByRole('button', { name: /No render/ }));
+ expect(screen.getByText('Bob Ashe has no render')).toBeTruthy();
+ expect(screen.queryByText('Alice Vane → Bob Ashe is one-directional')).toBeNull();
+ });
+
+ it('scrubs the timeline to a single issue and back to the whole universe', async () => {
+ await renderTab();
+ const slider = await screen.findByLabelText('Timeline position');
+ fireEvent.change(slider, { target: { value: '0' } });
+ expect(slider.value).toBe('0');
+ expect(screen.getAllByText('First Arc #1').length).toBeGreaterThan(0);
+ fireEvent.click(screen.getByRole('button', { name: 'Whole universe' }));
+ // The far right of the track is "no scope", not the last issue.
+ expect(slider.value).toBe(String(GRAPH.totalIssues));
+ });
+
+ it('hides a kind from the canvas stats when its filter is switched off', async () => {
+ await renderTab();
+ expect(await screen.findByText(/^6 nodes/)).toBeTruthy();
+ fireEvent.click(screen.getByTitle('Hide Issues'));
+ expect(screen.getByText(/^4 nodes/)).toBeTruthy();
+ });
+});
diff --git a/client/src/hooks/useUniverseTabs.js b/client/src/hooks/useUniverseTabs.js
index 83ef6ade47..706b9cadde 100644
--- a/client/src/hooks/useUniverseTabs.js
+++ b/client/src/hooks/useUniverseTabs.js
@@ -5,6 +5,7 @@ import {
TAB_BIBLE,
TAB_CAST,
TAB_COMPOSITES,
+ TAB_GRAPH,
TAB_OBJECTS,
TAB_OTHER,
TAB_PLACES,
@@ -31,7 +32,7 @@ export default function useUniverseTabs(categories) {
const requestedTab = searchParams.get('tab');
const isValidTab = (tab) => (
tab === TAB_BIBLE || tab === TAB_CAST || tab === TAB_PLACES || tab === TAB_OBJECTS
- || tab === TAB_COMPOSITES || tab === TAB_RENDER
+ || tab === TAB_COMPOSITES || tab === TAB_RENDER || tab === TAB_GRAPH
|| (tab === TAB_OTHER && hasOtherBuckets)
);
const activeTab = isValidTab(requestedTab) ? requestedTab : TAB_BIBLE;
@@ -85,7 +86,8 @@ export default function useUniverseTabs(categories) {
// explicit allow, the chip's `setBucket(BUCKET_CANON)` flashed in the URL
// then immediately got stripped by this effect, hiding the canon-only view.
// Other tab buckets must validate against `bucketsByKind.other`; non-trunk
- // non-Other tabs (Bible / Composites / Render) have no valid bucket scope.
+ // non-Other tabs (Bible / Composites / Render / Graph) have no valid bucket
+ // scope.
useEffect(() => {
if (!activeBucket) return;
const trunk = TRUNK_BY_ID[activeTab];
diff --git a/client/src/lib/README.md b/client/src/lib/README.md
index 8e32488cc4..d6700ec5ec 100644
--- a/client/src/lib/README.md
+++ b/client/src/lib/README.md
@@ -238,6 +238,9 @@ grep -i "what you want to do" client/src/lib/README.md
| `threejsRig.js` | `summarizeThreejsArticulation(spec)` → `{ articulationReady, jointCount, socketCount, attachmentCount, anchoredAttachmentCount, unanchoredAttachmentCount, jointsByPartId }` — client mirror of the readiness rule in `server/lib/threejsModelRig.js`, used by the Three.js Models preview, which is handed a bare spec rather than the record's stored report (and gets none at all for a model generated before rig readiness shipped). A spec with no `articulation` key reads as a static assembly; a graph is ready only when it carries more than a lone root, every child joint names a pivot socket, and every declared attachment names what it hangs from (the anchored `attachments` list and the older anchor-less `attachmentPartIds` are merged into one count). `jointsByPartId` is null-prototype so a provider-authored part id cannot resolve to an inherited function. |
| `threejsSculpt.js` | Three.js construction helpers for the validated procedural sculpt spec (`server/lib/threejsModel.js`) used by the Three.js Models preview. `createSculptBufferGeometry(definition)` builds the schema forms r3f has no direct element for — `custom` (indexed BufferGeometry), `extrude` (closed 2D outline + holes swept to `depth`, optional bevel) and `tube` (CatmullRom path swept at `radius`) — returning null for primitives, which stay declarative; callers own disposal. `needsSculptBufferGeometry(definition)` is the matching predicate. `sculptMaterialProps(definition, envMapIntensity)` maps a material definition to its Three.js props, forwarding the physical-only channels (`clearcoat`, `ior`, `transmission`, `thickness`, `sheen`, `iridescence`, `anisotropy`) only for `type: physical`, and the spec-level `environment.intensity` as `envMapIntensity` on every lit material (`basic` is unlit and takes none). Mirrors the `createGeometry`/`createMaterial` bodies `buildThreejsFactorySource()` emits, so the preview and the exported standalone factory render the same scene. |
| `universeBuilderShared.js` | Shared constants + pure category/trunk/composite helpers for the Universe Builder page and its extracted tab/editor components (#2374): `TRUNK_TABS`/`TRUNK_BY_ID`/`TRUNK_BY_KIND`, `TAB_*` + `BUCKET_CANON`, `CATEGORY_LABELS`, `DEFAULT_RENDER_OPTS`, `COMPOSITE_BOARD_KINDS`; and helpers `groupBucketsByKind`, `normalizeCategoryKey`, `humanizeCategory`, `ensureDraftCategories`, `getCategoryKeys`, `compositeKindLabel`. No React state — safe to import from the page or any tab component without a cycle. |
+| `universeGraphModel.js` | Vocabulary + derivations for the Universe Builder Graph tab: `GRAPH_KINDS`/`GRAPH_KIND_ORDER`, `GRAPH_EDGE_TYPES`/`GRAPH_EDGE_GROUPS` (the seven `RELATIONSHIP_LINK_TYPES` plus the derived attachment/appearance/membership/imageref groups), `indexGraph` (node map + adjacency + degree/storyDegree), `computeUniverseGaps` (isolated / empty place / one-directional / no render / co-appearance + lens suggestions — all pure derivations, never an LLM call), `evolutionStageRows`, `neighbourIds`, `nodeInitials`, `hexToRgba`. |
+| `universeGraphLayout.js` | 2D positioning for the Universe Builder graph canvas: `seedPositions` (deterministic phyllotaxis), `stepLayout`/`settleLayout` (force sim with per-kind collision radius), `applyAnchors` (radial rings by kind, timeline lanes by first appearance), `computeFit`, `toScreen`/`toWorld`, `hitTest`, `radiusFor`, `GRAPH_LAYOUTS`. Distinct from `graphSimulation.js`, which settles the Brain/CoS graphs in 3D with no anchors, collision or screen fit. |
+| `universeGraphPoster.js` | Print-ready infographic posters drawn from an `indexGraph` result: `POSTER_LAYOUTS` (cast roster, character dossier, place atlas, timeline strip, relationship web), `POSTER_SIZES`, `POSTER_THEMES`, `posterDimensions`, and `renderPoster(canvas, opts)` — the same call drives the on-screen preview (scale 1) and the 2× PNG download, so they can't diverge. |
| `universeBuilderCounts.js` | Pure prompt-count helpers for the Universe Builder render buttons (#2374), mirroring the server's compile/skip rules so inline counts match what `/render` will enqueue: `totalVariationCount`, `canonEntryHasContent`, `countCanonWithContent`, `renderPromptCount`, `scopedPromptCount`. The server `compilePrompts`/`synthesizeCanonPrompt` copy is authoritative — keep in sync. |
| `videoTimelineModel.js` | Pure model helpers for the layered video timeline, mirroring `server/services/videoTimeline/segments.js` so the browser preview and the ffmpeg export make the same cut/geometry/mix decisions: `segmentDuration`/`timelineDuration`/`findSegmentAt` (project-time ↔ segment mapping), `fadeMultiplier` (linear, matching ffmpeg's default `tri` curve), `overlayOpacityAt`, `audioTrackStateAt`, `segmentVolumeAt` (clipVolume × segment volume × fade — the product the export writes into each segment audio chain, so the preview auditions the mix that renders), `assetUrl(assetKind, assetFile)`, the edit clamps `clampTrim` (keeps ≥ 1 frame, matching the server's CLIP_TOO_SHORT floor) and `fitFadePatch` (shrinks an over-long fade pair proportionally instead of letting the PATCH 400), the index summary `projectSummary(project, thumbnailFor)` (block count / duration / card thumbnail — reads the `segments` lane, NEVER the derived `clips` mirror, which omits stills), and the save helpers `stripKey`/`withKeys`/`timelinePatch`. Change a rule here and in the server module in the same commit. |
| `wrSceneCursor.js` | Resolve which script scene the editor caret sits in (`sceneAtCursor`, `sceneAnchorIndex`) — inverse of WorkEditor's jump-to-scene text search; drives the live render preview's "scene at cursor" target. |
diff --git a/client/src/lib/index.js b/client/src/lib/index.js
index df5960d227..e98425b16f 100644
--- a/client/src/lib/index.js
+++ b/client/src/lib/index.js
@@ -15,6 +15,9 @@ export * from './shotContinuity.js';
export * from './shotGrammar.js';
export * from './universeBuilderCounts.js';
export * from './universeBuilderShared.js';
+export * from './universeGraphLayout.js';
+export * from './universeGraphModel.js';
+export * from './universeGraphPoster.js';
export * from './universeMarkdownFilename.js';
export * from './universeMarkdownFilename.cases.js';
export * from './universeRunTag.js';
diff --git a/client/src/lib/universeBuilderShared.js b/client/src/lib/universeBuilderShared.js
index 97dedc68d7..0c14a004b2 100644
--- a/client/src/lib/universeBuilderShared.js
+++ b/client/src/lib/universeBuilderShared.js
@@ -16,8 +16,8 @@ export const CATEGORY_LABELS = {
vehicles: 'Vehicles',
};
-// Tab order in the Universe Builder. Bible / Composites / Render are always
-// visible; the three canon trunks (Cast / Places / Objects) render even when
+// Tab order in the Universe Builder. Bible / Composites / Render / Graph are
+// always visible; the three canon trunks (Cast / Places / Objects) render even when
// empty so the user has a discoverable target for canon+variation work; Other
// only renders when at least one un-kinded bucket exists.
export const TAB_BIBLE = 'bible';
@@ -27,6 +27,7 @@ export const TAB_OBJECTS = 'objects';
export const TAB_OTHER = 'other';
export const TAB_COMPOSITES = 'composites';
export const TAB_RENDER = 'render';
+export const TAB_GRAPH = 'graph';
// Pseudo-bucket key for the canon-only view inside a trunk. Overloads
// `?bucket=` (alongside real bucket keys) AND a `promptMode` value on the
diff --git a/client/src/lib/universeGraphLayout.js b/client/src/lib/universeGraphLayout.js
new file mode 100644
index 0000000000..c8b3d8a257
--- /dev/null
+++ b/client/src/lib/universeGraphLayout.js
@@ -0,0 +1,241 @@
+/**
+ * Positioning for the Universe Builder graph canvas: a 2D force simulation
+ * plus the two anchored layouts (radial rings by kind, timeline lanes by first
+ * appearance) and the viewport fit they share.
+ *
+ * Separate from `client/src/lib/graphSimulation.js`, which settles the Brain /
+ * CoS graphs in 3D for a three.js scene with no anchors, no per-kind collision
+ * radius and no screen fit — the two solve different problems and share no
+ * parameters. Mutates the node objects in place (`x/y/vx/vy/tx/ty`), the way a
+ * force simulation has to; callers own the array.
+ */
+
+import { GRAPH_KIND_ORDER, edgeDef, kindDef } from './universeGraphModel.js';
+
+export const GRAPH_LAYOUTS = Object.freeze([
+ { id: 'force', label: 'Force' },
+ { id: 'radial', label: 'Radial' },
+ { id: 'timeline', label: 'Timeline' },
+]);
+
+// Timeline lane geometry, in world units.
+const TIMELINE_WIDTH = 1300;
+const TIMELINE_LANE_HEIGHT = 62;
+const TIMELINE_TOP = -310;
+// Radial ring radii, indexed by `kindDef(kind).ring`.
+const RADIAL_RINGS = [0, 230, 380, 520];
+
+const REPULSION_FORCE = 2600;
+const REPULSION_ANCHORED = 500;
+// Characters carry a drawn label; give their collision radius room for it so
+// two names never sit on top of each other at rest.
+const COLLIDE_PAD = 14;
+const COLLIDE_PAD_LABELLED = 46;
+const DAMPING = 0.85;
+const ALPHA_DECAY = 0.985;
+const SETTLE_ITERATIONS = 320;
+const SETTLE_ALPHA_MIN = 0.004;
+
+export const radiusFor = (node, degree = 0) => kindDef(node.kind).radius + Math.min(9, degree * 0.45);
+
+/**
+ * Seed every node onto a phyllotaxis spiral — deterministic, so a reload puts
+ * the same universe in the same starting shape instead of a new random one.
+ */
+export function seedPositions(nodes) {
+ nodes.forEach((node, i) => {
+ const angle = i * 2.39996;
+ const radius = 40 + Math.sqrt(i) * 34;
+ node.x = Math.cos(angle) * radius;
+ node.y = Math.sin(angle) * radius;
+ node.vx = 0;
+ node.vy = 0;
+ node.tx = null;
+ node.ty = null;
+ });
+ return nodes;
+}
+
+/**
+ * Assign anchor targets (`tx`/`ty`) for the two non-force layouts. No-op for
+ * `force`, which clears them so the springs take over again.
+ */
+export function applyAnchors(nodes, layout, { totalIssues = 0 } = {}) {
+ if (layout === 'force') {
+ for (const node of nodes) { node.tx = null; node.ty = null; }
+ return nodes;
+ }
+ if (layout === 'radial') {
+ const rings = RADIAL_RINGS.map(() => []);
+ for (const node of nodes) rings[kindDef(node.kind).ring].push(node);
+ rings.forEach((ring, ri) => {
+ ring.sort((a, b) => a.kind.localeCompare(b.kind) || (b.degree || 0) - (a.degree || 0));
+ ring.forEach((node, i) => {
+ if (ri === 0) {
+ // The innermost ring is a disc, not a circle — a cast of 40 on one
+ // circle of radius 0 would all land on the same point.
+ const r = Math.sqrt(i / Math.max(1, ring.length)) * 130;
+ const a = i * 2.39996;
+ node.tx = Math.cos(a) * r;
+ node.ty = Math.sin(a) * r;
+ } else {
+ const a = (i / ring.length) * Math.PI * 2 - Math.PI / 2;
+ node.tx = Math.cos(a) * RADIAL_RINGS[ri];
+ node.ty = Math.sin(a) * RADIAL_RINGS[ri];
+ }
+ });
+ });
+ return nodes;
+ }
+ // timeline: x by first appearance, y by kind lane, with a small fan-out so
+ // entries introduced in the same issue don't stack into one dot.
+ const span = Math.max(1, totalIssues);
+ const perLane = new Map();
+ for (const node of nodes) {
+ const lane = GRAPH_KIND_ORDER.indexOf(node.kind);
+ const key = `${lane}:${node.firstIssue || 0}`;
+ const seen = perLane.get(key) || 0;
+ perLane.set(key, seen + 1);
+ node.tx = ((node.firstIssue || 0) / span) * TIMELINE_WIDTH - TIMELINE_WIDTH / 2
+ + (node.kind === 'issue' ? 0 : (seen % 3) * 10);
+ node.ty = lane * TIMELINE_LANE_HEIGHT + TIMELINE_TOP + (seen % 4) * 12 - 18;
+ }
+ return nodes;
+}
+
+export const timelineGeometry = Object.freeze({
+ width: TIMELINE_WIDTH,
+ laneHeight: TIMELINE_LANE_HEIGHT,
+ top: TIMELINE_TOP,
+});
+
+/**
+ * One simulation step. Returns the decayed alpha so the caller can drive the
+ * loop (and stop it) without owning the constants.
+ */
+export function stepLayout(nodes, edges, {
+ alpha = 1, layout = 'force', degree, dragId = null, aspect = 1,
+} = {}) {
+ const anchored = layout !== 'force';
+ const repulsion = anchored ? REPULSION_ANCHORED : REPULSION_FORCE;
+ const deg = (id) => (degree?.get(id) || 0);
+ const radius = (node) => radiusFor(node, deg(node.id));
+
+ for (let i = 0; i < nodes.length; i++) {
+ for (let j = i + 1; j < nodes.length; j++) {
+ const p = nodes[i];
+ const q = nodes[j];
+ let dx = q.x - p.x;
+ let dy = q.y - p.y;
+ let d2 = dx * dx + dy * dy;
+ if (d2 < 1) {
+ // Perfectly coincident nodes have no direction to push along; nudge
+ // them apart deterministically by index parity rather than randomly.
+ dx = i % 2 ? 0.7 : -0.7;
+ dy = j % 2 ? 0.7 : -0.7;
+ d2 = 1;
+ }
+ const d = Math.sqrt(d2);
+ const f = (repulsion / d2) * alpha;
+ p.vx -= (dx / d) * f; p.vy -= (dy / d) * f;
+ q.vx += (dx / d) * f; q.vy += (dy / d) * f;
+ const pad = p.kind === 'character' && q.kind === 'character' ? COLLIDE_PAD_LABELLED : COLLIDE_PAD;
+ const minD = radius(p) + radius(q) + pad;
+ if (d < minD) {
+ const push = (minD - d) * 0.5;
+ p.x -= (dx / d) * push; p.y -= (dy / d) * push;
+ q.x += (dx / d) * push; q.y += (dy / d) * push;
+ }
+ }
+ }
+
+ for (const edge of edges) {
+ const group = edgeDef(edge.type).group;
+ const ds = Math.max(1, deg(edge.source));
+ const dt = Math.max(1, deg(edge.target));
+ const rest = (group === 'relationship' ? 90 : group === 'imageref' ? 30 : 70)
+ * (1 + 0.18 * Math.sqrt(ds + dt));
+ const base = group === 'relationship' ? 0.02
+ : group === 'imageref' ? 0.05
+ : group === 'attachment' ? 0.012 : 0.004;
+ const k = (anchored ? 0.003 : base) / Math.sqrt(Math.min(ds, dt));
+ const dx = edge.targetNode.x - edge.sourceNode.x;
+ const dy = edge.targetNode.y - edge.sourceNode.y;
+ const d = Math.sqrt(dx * dx + dy * dy) || 1;
+ const f = (d - rest) * k * alpha;
+ edge.sourceNode.vx += (dx / d) * f; edge.sourceNode.vy += (dy / d) * f;
+ edge.targetNode.vx -= (dx / d) * f; edge.targetNode.vy -= (dy / d) * f;
+ }
+
+ for (const node of nodes) {
+ if (dragId && node.id === dragId) { node.vx = 0; node.vy = 0; continue; }
+ if (anchored && node.tx != null) {
+ node.vx += (node.tx - node.x) * 0.06 * alpha;
+ node.vy += (node.ty - node.y) * 0.06 * alpha;
+ } else {
+ node.vx -= node.x * 0.004 * alpha;
+ node.vy -= node.y * 0.004 * aspect * alpha;
+ }
+ node.vx *= DAMPING; node.vy *= DAMPING;
+ node.x += node.vx; node.y += node.vy;
+ }
+ return alpha * ALPHA_DECAY;
+}
+
+/**
+ * Run the simulation to rest synchronously so the first paint shows a settled
+ * graph rather than an expanding blob. Cheap enough at PortOS canon sizes
+ * (hundreds of nodes); the animation loop takes over for later interactions.
+ */
+export function settleLayout(nodes, edges, options = {}) {
+ let alpha = 1;
+ for (let i = 0; i < SETTLE_ITERATIONS && alpha > SETTLE_ALPHA_MIN; i++) {
+ alpha = stepLayout(nodes, edges, { ...options, alpha });
+ }
+ return nodes;
+}
+
+/**
+ * Viewport transform that fits every node with padding. Returns the identity-ish
+ * fallback when there is nothing to fit, so a caller can apply it unconditionally.
+ */
+export function computeFit(nodes, { width, height, layout = 'force', pad = 56 } = {}) {
+ if (!nodes.length || !width || !height) return { k: 1, x: width / 2 || 0, y: height / 2 || 0 };
+ const anchored = layout !== 'force';
+ let x0 = Infinity; let y0 = Infinity; let x1 = -Infinity; let y1 = -Infinity;
+ for (const node of nodes) {
+ const px = anchored && node.tx != null ? node.tx : node.x;
+ const py = anchored && node.ty != null ? node.ty : node.y;
+ x0 = Math.min(x0, px); y0 = Math.min(y0, py);
+ x1 = Math.max(x1, px); y1 = Math.max(y1, py);
+ }
+ if (layout === 'timeline') {
+ // Leave room for the lane labels drawn to the left of the first column.
+ x0 = Math.min(x0, -TIMELINE_WIDTH / 2 - 160);
+ y0 = Math.min(y0, TIMELINE_TOP - 40);
+ }
+ const k = Math.min(
+ 6,
+ (width - pad * 2) / Math.max(60, x1 - x0),
+ (height - pad * 2) / Math.max(60, y1 - y0),
+ );
+ return { k, x: width / 2 - ((x0 + x1) / 2) * k, y: height / 2 - ((y0 + y1) / 2) * k };
+}
+
+export const toScreen = (view, x, y) => [x * view.k + view.x, y * view.k + view.y];
+export const toWorld = (view, sx, sy) => [(sx - view.x) / view.k, (sy - view.y) / view.k];
+
+/** Nearest node under a screen point, or null. */
+export function hitTest(nodes, view, sx, sy, degree) {
+ const [wx, wy] = toWorld(view, sx, sy);
+ let best = null;
+ let bestD2 = Infinity;
+ for (const node of nodes) {
+ const r = radiusFor(node, degree?.get(node.id) || 0) + 4 / view.k;
+ const dx = node.x - wx;
+ const dy = node.y - wy;
+ const d2 = dx * dx + dy * dy;
+ if (d2 < r * r && d2 < bestD2) { bestD2 = d2; best = node; }
+ }
+ return best;
+}
diff --git a/client/src/lib/universeGraphLayout.test.js b/client/src/lib/universeGraphLayout.test.js
new file mode 100644
index 0000000000..6ad66731b6
--- /dev/null
+++ b/client/src/lib/universeGraphLayout.test.js
@@ -0,0 +1,122 @@
+import { describe, it, expect } from 'vitest';
+import { indexGraph } from './universeGraphModel';
+import {
+ applyAnchors, computeFit, hitTest, radiusFor, seedPositions, settleLayout, toWorld,
+} from './universeGraphLayout';
+
+const nodes = (n, kind = 'character') => Array.from({ length: n }, (_, i) => ({
+ id: `${kind}:${i}`, kind, name: `N${i}`, firstIssue: i % 3, degree: n - i,
+}));
+
+describe('seedPositions', () => {
+ it('is deterministic, so the same universe opens in the same shape twice', () => {
+ const a = seedPositions(nodes(6)).map((n) => [n.x, n.y]);
+ const b = seedPositions(nodes(6)).map((n) => [n.x, n.y]);
+ expect(a).toEqual(b);
+ });
+
+ it('gives every node a distinct starting point', () => {
+ const seeded = seedPositions(nodes(20));
+ expect(new Set(seeded.map((n) => `${n.x},${n.y}`)).size).toBe(20);
+ });
+});
+
+describe('settleLayout', () => {
+ it('separates two coincident nodes instead of leaving them stacked', () => {
+ const a = { id: 'a', kind: 'character', name: 'A', x: 0, y: 0, vx: 0, vy: 0, tx: null, ty: null, firstIssue: 0 };
+ const b = { id: 'b', kind: 'character', name: 'B', x: 0, y: 0, vx: 0, vy: 0, tx: null, ty: null, firstIssue: 0 };
+ settleLayout([a, b], [], { degree: new Map([['a', 0], ['b', 0]]) });
+ expect(Math.hypot(a.x - b.x, a.y - b.y)).toBeGreaterThan(radiusFor(a) + radiusFor(b));
+ expect(Number.isFinite(a.x) && Number.isFinite(b.y)).toBe(true);
+ });
+
+ it('pulls a spring-linked pair in from a long start; an unlinked pair only drifts', () => {
+ const pair = () => [
+ { id: 'character:0', kind: 'character', name: 'A', x: -400, y: 0, vx: 0, vy: 0, tx: null, ty: null, firstIssue: 0 },
+ { id: 'character:1', kind: 'character', name: 'B', x: 400, y: 0, vx: 0, vy: 0, tx: null, ty: null, firstIssue: 0 },
+ ];
+ const spread = (list) => Math.hypot(list[0].x - list[1].x, list[0].y - list[1].y);
+
+ const linked = pair();
+ const index = indexGraph({
+ nodes: linked,
+ edges: [{ source: 'character:0', target: 'character:1', type: 'ally', directed: true }],
+ });
+ settleLayout(index.nodes, index.edges, { degree: index.degree });
+
+ const loose = pair();
+ settleLayout(loose, [], { degree: new Map(loose.map((n) => [n.id, 0])) });
+
+ expect(spread(linked)).toBeLessThan(800);
+ expect(spread(linked)).toBeLessThan(spread(loose));
+ });
+});
+
+describe('applyAnchors', () => {
+ it('clears anchors for the force layout so springs take over again', () => {
+ const list = nodes(3);
+ applyAnchors(list, 'radial');
+ expect(list.every((n) => n.tx != null)).toBe(true);
+ applyAnchors(list, 'force');
+ expect(list.every((n) => n.tx === null && n.ty === null)).toBe(true);
+ });
+
+ it('lays timeline anchors out left to right by first appearance', () => {
+ const list = [
+ { id: 'a', kind: 'character', name: 'A', firstIssue: 0 },
+ { id: 'b', kind: 'character', name: 'B', firstIssue: 5 },
+ { id: 'c', kind: 'character', name: 'C', firstIssue: 10 },
+ ];
+ applyAnchors(list, 'timeline', { totalIssues: 10 });
+ expect(list[0].tx).toBeLessThan(list[1].tx);
+ expect(list[1].tx).toBeLessThan(list[2].tx);
+ });
+
+ it('separates kinds into their own timeline lanes', () => {
+ const list = [
+ { id: 'a', kind: 'character', name: 'A', firstIssue: 0 },
+ { id: 'b', kind: 'place', name: 'B', firstIssue: 0 },
+ ];
+ applyAnchors(list, 'timeline', { totalIssues: 4 });
+ expect(list[0].ty).not.toBe(list[1].ty);
+ });
+
+ it('survives totalIssues of 0 rather than dividing by zero', () => {
+ const list = nodes(3);
+ applyAnchors(list, 'timeline', { totalIssues: 0 });
+ expect(list.every((n) => Number.isFinite(n.tx) && Number.isFinite(n.ty))).toBe(true);
+ });
+});
+
+describe('computeFit', () => {
+ it('centres the bounding box in the viewport', () => {
+ const list = [
+ { id: 'a', kind: 'character', name: 'A', x: -100, y: -100 },
+ { id: 'b', kind: 'character', name: 'B', x: 100, y: 100 },
+ ];
+ const view = computeFit(list, { width: 800, height: 600 });
+ expect(view.x).toBeCloseTo(400, 5);
+ expect(view.y).toBeCloseTo(300, 5);
+ expect(view.k).toBeGreaterThan(0);
+ });
+
+ it('returns a usable transform for an empty graph instead of Infinity', () => {
+ const view = computeFit([], { width: 800, height: 600 });
+ expect(Number.isFinite(view.k) && Number.isFinite(view.x)).toBe(true);
+ });
+});
+
+describe('hitTest', () => {
+ const view = { x: 100, y: 100, k: 2 };
+ const list = [{ id: 'a', kind: 'character', name: 'A', x: 0, y: 0 }];
+ const degree = new Map([['a', 0]]);
+
+ it('picks the node under the cursor in screen space', () => {
+ expect(hitTest(list, view, 100, 100, degree)).toBe(list[0]);
+ expect(toWorld(view, 100, 100)).toEqual([0, 0]);
+ });
+
+ it('returns null on empty canvas', () => {
+ expect(hitTest(list, view, 400, 400, degree)).toBeNull();
+ });
+});
diff --git a/client/src/lib/universeGraphModel.js b/client/src/lib/universeGraphModel.js
new file mode 100644
index 0000000000..356cacf471
--- /dev/null
+++ b/client/src/lib/universeGraphModel.js
@@ -0,0 +1,279 @@
+/**
+ * Vocabulary + derivations for the Universe Builder's Graph tab.
+ *
+ * The server hands back `{ nodes, edges, series, issues, totalIssues, appear }`
+ * (see `server/services/universeGraph.js`); everything the view needs on top of
+ * that — the palette, an adjacency index, and the "gaps & enrichment" findings
+ * — is derived here so it stays pure and testable.
+ *
+ * Every gap is a DERIVATION over records the user already authored: no LLM call
+ * is made to produce one, and the "suggestion" category only points at a pair
+ * the data already links by co-appearance.
+ */
+
+import { EVOLUTION_STAGES, EVOLUTION_STAGE_LABELS } from './characterEvolution.js';
+
+export { EVOLUTION_STAGES, EVOLUTION_STAGE_LABELS };
+
+// Node kinds in draw + legend order. `ring` places the kind on a radial-layout
+// ring; `radius` is its base canvas radius before degree scaling.
+export const GRAPH_KINDS = Object.freeze({
+ character: { label: 'Characters', singular: 'Character', color: '#3b82f6', ring: 0, radius: 8 },
+ place: { label: 'Places', singular: 'Place', color: '#22c55e', ring: 1, radius: 6.5 },
+ object: { label: 'Objects', singular: 'Object', color: '#f59e0b', ring: 1, radius: 5.5 },
+ series: { label: 'Series', singular: 'Series', color: '#ec4899', ring: 2, radius: 9 },
+ issue: { label: 'Issues', singular: 'Issue', color: '#fb7185', ring: 3, radius: 4 },
+ image: { label: 'Images', singular: 'Image', color: '#14b8a6', ring: 3, radius: 3.5 },
+ composite: { label: 'Composite sheets', singular: 'Composite sheet', color: '#06b6d4', ring: 3, radius: 4.5 },
+ moodboard: { label: 'Mood board', singular: 'Mood board', color: '#eab308', ring: 3, radius: 4.5 },
+});
+export const GRAPH_KIND_ORDER = Object.freeze(Object.keys(GRAPH_KINDS));
+
+// The three canon trunks — the only kinds a gap can be reported against.
+const CANON_KINDS = Object.freeze(['character', 'place', 'object']);
+
+// Edge types keyed by the `type` the server emits. The seven relationship
+// values mirror `RELATIONSHIP_LINK_TYPES`; the rest are the derived groups.
+export const GRAPH_EDGE_TYPES = Object.freeze({
+ ally: { label: 'Ally', color: '#22c55e', group: 'relationship' },
+ antagonist: { label: 'Antagonist', color: '#ef4444', group: 'relationship' },
+ rival: { label: 'Rival', color: '#f97316', group: 'relationship' },
+ mentor: { label: 'Mentor', color: '#3b82f6', group: 'relationship' },
+ 'love-interest': { label: 'Love interest', color: '#ec4899', group: 'relationship' },
+ family: { label: 'Family', color: '#eab308', group: 'relationship' },
+ custom: { label: 'Custom', color: '#9ca3af', group: 'relationship' },
+ attachment: { label: 'Attachment', color: '#a855f7', group: 'attachment' },
+ appearance: { label: 'Appears in', color: '#6b7280', group: 'appearance', dashed: true },
+ membership: { label: 'Series membership', color: '#ec4899', group: 'membership' },
+ imageref: { label: 'Image reference', color: '#14b8a6', group: 'imageref' },
+});
+
+export const GRAPH_EDGE_GROUPS = Object.freeze([
+ { id: 'relationship', label: 'Typed relationships' },
+ { id: 'attachment', label: 'Attachments' },
+ { id: 'appearance', label: 'Appearances' },
+ { id: 'membership', label: 'Series membership' },
+ { id: 'imageref', label: 'Image references' },
+]);
+
+// An unknown type (older/newer peer, hand-edited record) still has to draw, so
+// resolve through `custom` rather than dereferencing undefined.
+export const edgeDef = (type) => GRAPH_EDGE_TYPES[type] || GRAPH_EDGE_TYPES.custom;
+export const kindDef = (kind) => GRAPH_KINDS[kind] || GRAPH_KINDS.object;
+
+// Up to two initials for the node avatar. Honorifics and articles carry no
+// identity, so they're dropped before the first two words are taken.
+const HONORIFICS = /^(the|old|aunt|uncle|brother|sister|father|mother|doctor|dr\.?|captain|magistrate|lord|lady|sir)\s+/i;
+export const nodeInitials = (name) => String(name || '?')
+ .replace(HONORIFICS, '')
+ .split(/[\s-]+/)
+ .filter(Boolean)
+ .slice(0, 2)
+ .map((word) => word[0])
+ .join('')
+ .toUpperCase() || '?';
+
+export const hexToRgba = (hex, alpha) => {
+ const n = parseInt(String(hex).slice(1), 16);
+ if (!Number.isFinite(n)) return `rgba(148,163,184,${alpha})`;
+ return `rgba(${(n >> 16) & 255},${(n >> 8) & 255},${n & 255},${alpha})`;
+};
+
+/**
+ * Index a graph payload for lookup: node-by-id, per-node adjacency, and a
+ * story degree that ignores the bookkeeping edges (image references and series
+ * membership) so "isolated" means narratively isolated, not unrendered.
+ */
+export function indexGraph(graph) {
+ const nodes = Array.isArray(graph?.nodes) ? graph.nodes : [];
+ const edges = Array.isArray(graph?.edges) ? graph.edges : [];
+ const byId = new Map(nodes.map((n) => [n.id, n]));
+ const adjacency = new Map(nodes.map((n) => [n.id, []]));
+ const resolved = [];
+ for (const edge of edges) {
+ const source = byId.get(edge.source);
+ const target = byId.get(edge.target);
+ if (!source || !target) continue;
+ const withEnds = { ...edge, sourceNode: source, targetNode: target };
+ resolved.push(withEnds);
+ adjacency.get(edge.source).push(withEnds);
+ adjacency.get(edge.target).push(withEnds);
+ }
+ const degree = new Map();
+ const storyDegree = new Map();
+ for (const node of nodes) {
+ const links = adjacency.get(node.id);
+ degree.set(node.id, links.filter((e) => e.type !== 'imageref').length);
+ storyDegree.set(node.id, links.filter((e) => e.type !== 'imageref' && e.type !== 'membership').length);
+ }
+ return {
+ nodes,
+ edges: resolved,
+ byId,
+ adjacency,
+ degree,
+ storyDegree,
+ appear: graph?.appear || {},
+ issues: Array.isArray(graph?.issues) ? graph.issues : [],
+ series: Array.isArray(graph?.series) ? graph.series : [],
+ totalIssues: graph?.totalIssues || 0,
+ name: graph?.name || '',
+ };
+}
+
+export const GAP_CATEGORIES = Object.freeze([
+ { id: 'all', label: 'All' },
+ { id: 'isolated', label: 'Isolated' },
+ { id: 'places', label: 'Empty places' },
+ { id: 'oneway', label: 'One-directional' },
+ { id: 'noimage', label: 'No render' },
+ { id: 'suggest', label: 'Suggestions' },
+]);
+
+const GAP_COLORS = Object.freeze({
+ isolated: '#ef4444',
+ places: '#22c55e',
+ oneway: '#f97316',
+ noimage: '#14b8a6',
+ suggest: '#a855f7',
+});
+
+// A pair has to share this many issues before "they co-appear but have no
+// typed relationship" is worth surfacing — below it, a shared issue is as
+// likely to be two matches in unrelated scenes.
+const CO_APPEARANCE_SUGGEST_MIN = 4;
+// Appearances a character needs before an unauthored evolution lens reads as a
+// gap rather than a walk-on with nothing to evolve through.
+const LENS_SUGGEST_MIN_APPEARANCES = 5;
+
+/**
+ * Derive the gaps & enrichment list from an indexed graph.
+ *
+ * @param {ReturnType} index
+ * @returns {Array<{cat,color,nodeId,otherId?,title,detail,action}>}
+ */
+export function computeUniverseGaps(index) {
+ const gaps = [];
+ const { nodes, edges, storyDegree, appear } = index;
+
+ for (const node of nodes) {
+ if (!CANON_KINDS.includes(node.kind)) continue;
+ if (storyDegree.get(node.id) === 0) {
+ gaps.push({
+ cat: 'isolated',
+ color: GAP_COLORS.isolated,
+ nodeId: node.id,
+ title: `${node.name} is isolated`,
+ detail: `${kindDef(node.kind).singular} with no relationships, attachments or appearances.`,
+ action: 'Link it or cut it',
+ });
+ }
+ if (!node.hasImage) {
+ gaps.push({
+ cat: 'noimage',
+ color: GAP_COLORS.noimage,
+ nodeId: node.id,
+ title: `${node.name} has no render`,
+ detail: 'Canon entry without a reference image.',
+ action: 'Render a reference',
+ });
+ }
+ }
+
+ // A place "has no cast" when no character is matched in any issue it appears
+ // in — the only character↔place signal the records actually carry.
+ const issuesOf = (id) => new Set(appear[id] || []);
+ const castIssues = new Set();
+ for (const node of nodes) {
+ if (node.kind !== 'character') continue;
+ for (const index of appear[node.id] || []) castIssues.add(index);
+ }
+ for (const node of nodes) {
+ if (node.kind !== 'place') continue;
+ const own = issuesOf(node.id);
+ if (own.size === 0) continue; // already reported as isolated
+ if ([...own].some((i) => castIssues.has(i))) continue;
+ gaps.push({
+ cat: 'places',
+ color: GAP_COLORS.places,
+ nodeId: node.id,
+ title: `${node.name} has no cast`,
+ detail: 'No character appears in any issue this place appears in.',
+ action: 'Put someone here',
+ });
+ }
+
+ const directed = new Set(edges.filter((e) => e.directed).map((e) => `${e.source}>${e.target}`));
+ for (const edge of edges) {
+ if (!edge.directed) continue;
+ if (directed.has(`${edge.target}>${edge.source}`)) continue;
+ gaps.push({
+ cat: 'oneway',
+ color: GAP_COLORS.oneway,
+ nodeId: edge.source,
+ otherId: edge.target,
+ title: `${edge.sourceNode.name} → ${edge.targetNode.name} is one-directional`,
+ detail: `${edge.sourceNode.name} calls ${edge.targetNode.name} "${edgeDef(edge.type).label.toLowerCase()}" but ${edge.targetNode.name} has no link back.`,
+ action: 'Author the reverse link',
+ });
+ }
+
+ const characters = nodes.filter((n) => n.kind === 'character');
+ for (let i = 0; i < characters.length; i++) {
+ const a = characters[i];
+ const mine = issuesOf(a.id);
+ for (let j = i + 1; j < characters.length; j++) {
+ const b = characters[j];
+ if (directed.has(`${a.id}>${b.id}`) || directed.has(`${b.id}>${a.id}`)) continue;
+ const shared = (appear[b.id] || []).filter((x) => mine.has(x)).length;
+ if (shared < CO_APPEARANCE_SUGGEST_MIN) continue;
+ gaps.push({
+ cat: 'suggest',
+ color: GAP_COLORS.suggest,
+ nodeId: a.id,
+ otherId: b.id,
+ title: `Define ${a.name} ↔ ${b.name}`,
+ detail: `They share ${shared} issues but have no typed relationship.`,
+ action: 'Add a relationship link',
+ });
+ }
+ }
+ for (const node of characters) {
+ const count = (appear[node.id] || []).length;
+ if (node.evolution || count < LENS_SUGGEST_MIN_APPEARANCES) continue;
+ gaps.push({
+ cat: 'suggest',
+ color: GAP_COLORS.suggest,
+ nodeId: node.id,
+ title: `${node.name} has no evolution lens`,
+ detail: `Appears in ${count} issues with no five-stage arc authored.`,
+ action: 'Author the lens',
+ });
+ }
+
+ // Stable ordering so the list doesn't reshuffle between renders: severity
+ // first (the order GAP_CATEGORIES declares), then by title.
+ const rank = new Map(GAP_CATEGORIES.map((c, i) => [c.id, i]));
+ return gaps.sort((a, b) => (rank.get(a.cat) - rank.get(b.cat)) || a.title.localeCompare(b.title));
+}
+
+/**
+ * The five canonical stages of a character's lens, marked with whether the
+ * author has written each one. The stored lens is sparse (only authored stages
+ * are persisted), so an unauthored stage is an absence, not an empty record.
+ */
+export function evolutionStageRows(evolution) {
+ const authored = new Map((evolution?.stages || []).map((s) => [s.stageId, s]));
+ return EVOLUTION_STAGES.map((stageId) => ({
+ stageId,
+ label: EVOLUTION_STAGE_LABELS[stageId] || stageId,
+ authored: authored.has(stageId),
+ stage: authored.get(stageId) || null,
+ }));
+}
+
+// `adjacency` neighbours of one node, as ids (the focus + highlight set).
+export function neighbourIds(index, nodeId) {
+ const links = index.adjacency.get(nodeId) || [];
+ return new Set([nodeId, ...links.map((e) => (e.source === nodeId ? e.target : e.source))]);
+}
diff --git a/client/src/lib/universeGraphModel.test.js b/client/src/lib/universeGraphModel.test.js
new file mode 100644
index 0000000000..5116d7b369
--- /dev/null
+++ b/client/src/lib/universeGraphModel.test.js
@@ -0,0 +1,168 @@
+import { describe, it, expect } from 'vitest';
+import {
+ computeUniverseGaps, edgeDef, evolutionStageRows, indexGraph, neighbourIds, nodeInitials,
+} from './universeGraphModel';
+
+const graph = (overrides = {}) => ({
+ name: 'Example Universe',
+ nodes: [],
+ edges: [],
+ issues: [],
+ series: [],
+ totalIssues: 0,
+ appear: {},
+ ...overrides,
+});
+
+const character = (id, extra = {}) => ({
+ id: `character:${id}`, kind: 'character', name: id, role: 'Lead', hasImage: true, firstIssue: 0, ...extra,
+});
+const place = (id, extra = {}) => ({
+ id: `place:${id}`, kind: 'place', name: id, role: 'Place', hasImage: true, firstIssue: 0, ...extra,
+});
+const issues = (n) => Array.from({ length: n }, (_, i) => ({
+ id: `issue:i${i}`, index: i, name: `#${i + 1}`, seriesId: 'series:s1',
+}));
+
+describe('indexGraph', () => {
+ it('drops an edge whose endpoint is missing rather than indexing a dangling link', () => {
+ const index = indexGraph(graph({
+ nodes: [character('alice')],
+ edges: [{ source: 'character:alice', target: 'character:ghost', type: 'ally', directed: true }],
+ }));
+ expect(index.edges).toHaveLength(0);
+ expect(index.degree.get('character:alice')).toBe(0);
+ });
+
+ it('excludes image references from degree and series membership from story degree', () => {
+ const index = indexGraph(graph({
+ nodes: [
+ character('alice'),
+ { id: 'image:0', kind: 'image', name: 'a.png', firstIssue: 0 },
+ { id: 'series:s1', kind: 'series', name: 'Arc', firstIssue: 0 },
+ ],
+ edges: [
+ { source: 'image:0', target: 'character:alice', type: 'imageref' },
+ { source: 'character:alice', target: 'series:s1', type: 'membership' },
+ ],
+ }));
+ expect(index.degree.get('character:alice')).toBe(1);
+ expect(index.storyDegree.get('character:alice')).toBe(0);
+ });
+});
+
+describe('computeUniverseGaps', () => {
+ it('reports a canon entry with only bookkeeping links as isolated', () => {
+ const gaps = computeUniverseGaps(indexGraph(graph({
+ nodes: [character('alice'), { id: 'image:0', kind: 'image', name: 'a.png', firstIssue: 0 }],
+ edges: [{ source: 'image:0', target: 'character:alice', type: 'imageref' }],
+ })));
+ expect(gaps.filter((g) => g.cat === 'isolated').map((g) => g.nodeId)).toEqual(['character:alice']);
+ });
+
+ it('reports a one-directional relationship but not a mutual pair', () => {
+ const gaps = computeUniverseGaps(indexGraph(graph({
+ nodes: [character('alice'), character('bob'), character('cass')],
+ edges: [
+ { source: 'character:alice', target: 'character:bob', type: 'ally', directed: true },
+ { source: 'character:bob', target: 'character:alice', type: 'ally', directed: true },
+ { source: 'character:alice', target: 'character:cass', type: 'rival', directed: true },
+ ],
+ })));
+ const oneway = gaps.filter((g) => g.cat === 'oneway');
+ expect(oneway).toHaveLength(1);
+ expect(oneway[0]).toMatchObject({ nodeId: 'character:alice', otherId: 'character:cass' });
+ });
+
+ it('flags a canon entry with no rendered reference', () => {
+ const gaps = computeUniverseGaps(indexGraph(graph({
+ nodes: [character('alice', { hasImage: false }), character('bob')],
+ edges: [
+ { source: 'character:alice', target: 'character:bob', type: 'ally', directed: true },
+ { source: 'character:bob', target: 'character:alice', type: 'ally', directed: true },
+ ],
+ })));
+ expect(gaps.filter((g) => g.cat === 'noimage').map((g) => g.nodeId)).toEqual(['character:alice']);
+ });
+
+ it('reports a place whose issues hold no cast, and stays quiet when one does', () => {
+ const base = {
+ nodes: [character('alice'), place('vault'), place('pier')],
+ edges: [{ source: 'character:alice', target: 'place:vault', type: 'attachment' }],
+ issues: issues(2),
+ totalIssues: 2,
+ };
+ const gaps = computeUniverseGaps(indexGraph(graph({
+ ...base,
+ appear: { 'character:alice': [0], 'place:vault': [0], 'place:pier': [1] },
+ })));
+ expect(gaps.filter((g) => g.cat === 'places').map((g) => g.nodeId)).toEqual(['place:pier']);
+ });
+
+ it('suggests a relationship only once a pair shares enough issues', () => {
+ const nodes = [character('alice'), character('bob'), character('cass')];
+ const withShared = (shared) => computeUniverseGaps(indexGraph(graph({
+ nodes,
+ issues: issues(6),
+ totalIssues: 6,
+ appear: {
+ 'character:alice': [0, 1, 2, 3, 4],
+ 'character:bob': shared,
+ 'character:cass': [5],
+ },
+ }))).filter((g) => g.cat === 'suggest' && g.title.startsWith('Define'));
+ expect(withShared([0, 1, 2])).toHaveLength(0);
+ expect(withShared([0, 1, 2, 3])).toHaveLength(1);
+ });
+
+ it('suggests an evolution lens only for a well-used character that lacks one', () => {
+ const gaps = computeUniverseGaps(indexGraph(graph({
+ nodes: [
+ character('alice'),
+ character('bob', { evolution: { outcome: 'full-change', stages: [] } }),
+ character('cass'),
+ ],
+ issues: issues(6),
+ totalIssues: 6,
+ appear: {
+ 'character:alice': [0, 1, 2, 3, 4],
+ 'character:bob': [0, 1, 2, 3, 4],
+ 'character:cass': [0],
+ },
+ })));
+ const lens = gaps.filter((g) => g.cat === 'suggest' && g.title.includes('evolution lens'));
+ expect(lens.map((g) => g.nodeId)).toEqual(['character:alice']);
+ });
+});
+
+describe('evolutionStageRows', () => {
+ it('renders all five canonical stages, marking only the authored ones', () => {
+ const rows = evolutionStageRows({ outcome: 'partial-open', stages: [{ stageId: 'cost-tested', testedBelief: 'x' }] });
+ expect(rows).toHaveLength(5);
+ expect(rows.filter((r) => r.authored).map((r) => r.stageId)).toEqual(['cost-tested']);
+ });
+
+ it('treats an absent lens as five unauthored stages rather than throwing', () => {
+ expect(evolutionStageRows(null).every((r) => !r.authored)).toBe(true);
+ });
+});
+
+describe('helpers', () => {
+ it('drops an honorific before taking initials', () => {
+ expect(nodeInitials('Doctor Ilse Varga')).toBe('IV');
+ expect(nodeInitials('The Pale Broker')).toBe('PB');
+ expect(nodeInitials('')).toBe('?');
+ });
+
+ it('resolves an unknown edge type through custom instead of returning undefined', () => {
+ expect(edgeDef('not-a-type')).toBe(edgeDef('custom'));
+ });
+
+ it('returns the node itself plus its neighbours', () => {
+ const index = indexGraph(graph({
+ nodes: [character('alice'), character('bob'), character('cass')],
+ edges: [{ source: 'character:alice', target: 'character:bob', type: 'ally', directed: true }],
+ }));
+ expect([...neighbourIds(index, 'character:alice')].sort()).toEqual(['character:alice', 'character:bob']);
+ });
+});
diff --git a/client/src/lib/universeGraphPoster.js b/client/src/lib/universeGraphPoster.js
new file mode 100644
index 0000000000..ce1a90991b
--- /dev/null
+++ b/client/src/lib/universeGraphPoster.js
@@ -0,0 +1,521 @@
+/**
+ * Print-ready infographic posters drawn from an indexed universe graph.
+ *
+ * Pure canvas drawing over the same `indexGraph` result the Graph tab already
+ * holds — no fetching, no React. `renderPoster` is called both for the on-screen
+ * preview (scale 1) and the 2× download, so the two can never diverge.
+ */
+
+import {
+ edgeDef, evolutionStageRows, hexToRgba, kindDef, nodeInitials,
+} from './universeGraphModel.js';
+
+export const POSTER_LAYOUTS = Object.freeze([
+ { id: 'roster', label: 'Cast roster', desc: 'Portrait grid of every character' },
+ { id: 'dossier', label: 'Character dossier', desc: 'One character: framework, links, lens' },
+ { id: 'atlas', label: 'Place atlas', desc: 'Locations and who moves through them' },
+ { id: 'timeline', label: 'Timeline strip', desc: 'Presence across series and issues' },
+ { id: 'web', label: 'Relationship web', desc: 'Typed links between the whole cast' },
+]);
+
+export const POSTER_SIZES = Object.freeze([
+ { id: 'portrait', label: 'Portrait', dims: [1200, 1800] },
+ { id: 'square', label: 'Square', dims: [1500, 1500] },
+ { id: 'landscape', label: 'Landscape', dims: [1800, 1200] },
+]);
+
+export const POSTER_THEMES = Object.freeze([
+ { id: 'midnight', label: 'Midnight' },
+ { id: 'paper', label: 'Paper' },
+]);
+
+const PALETTES = {
+ midnight: { bg: '#0f0f0f', card: '#1e1e1e', border: '#2a2a2a', ink: '#ffffff', muted: '#9ca3af', faint: '#6b7280' },
+ paper: { bg: '#f5f1e8', card: '#ffffff', border: '#d4d4d8', ink: '#1a1a1a', muted: '#52525b', faint: '#71717a' },
+};
+const ACCENT = '#2563eb';
+const WARN = '#f59e0b';
+const FONT = '-apple-system, BlinkMacSystemFont, Segoe UI, Roboto, sans-serif';
+
+export const posterDimensions = (sizeId) =>
+ (POSTER_SIZES.find((s) => s.id === sizeId) || POSTER_SIZES[0]).dims;
+
+const byDegreeDesc = (index) => (a, b) => (index.degree.get(b.id) || 0) - (index.degree.get(a.id) || 0);
+
+// Nodes visible at the poster's "as of" issue. `null` means the whole universe.
+const visibleNodes = (index, asOfIssue) => (
+ asOfIssue == null ? index.nodes : index.nodes.filter((n) => (n.firstIssue || 0) <= asOfIssue)
+);
+
+const issueLabel = (index, i) => {
+ const issue = index.issues[i];
+ return issue ? issue.name : '—';
+};
+
+/**
+ * Draw one poster onto `canvas`.
+ *
+ * @param {HTMLCanvasElement} canvas
+ * @param {object} options
+ * @param {ReturnType} options.index
+ * @param {string} options.layout one of POSTER_LAYOUTS ids
+ * @param {string} [options.subjectId] node id for the `dossier` layout
+ * @param {string} [options.size] one of POSTER_SIZES ids
+ * @param {string} [options.theme] one of POSTER_THEMES ids
+ * @param {number|null} [options.asOfIssue] issue index the poster is scoped to
+ * @param {number} [options.scale] 1 for preview, 2 for the print download
+ */
+export function renderPoster(canvas, {
+ index, layout = 'roster', subjectId = null, size = 'portrait',
+ theme = 'midnight', asOfIssue = null, scale = 1,
+} = {}) {
+ const [W, H] = posterDimensions(size);
+ canvas.width = W * scale;
+ canvas.height = H * scale;
+ const ctx = canvas.getContext('2d');
+ if (!ctx) return;
+ ctx.setTransform(scale, 0, 0, scale, 0, 0);
+
+ const dark = theme !== 'paper';
+ const P = PALETTES[dark ? 'midnight' : 'paper'];
+ const nodes = visibleNodes(index, asOfIssue);
+ const ids = new Set(nodes.map((n) => n.id));
+ const edges = index.edges.filter((e) => ids.has(e.source) && ids.has(e.target));
+ const characters = nodes.filter((n) => n.kind === 'character').sort(byDegreeDesc(index));
+
+ ctx.fillStyle = P.bg;
+ ctx.fillRect(0, 0, W, H);
+ if (dark) {
+ const glow = ctx.createRadialGradient(W / 2, -H * 0.1, 10, W / 2, 0, H * 0.9);
+ glow.addColorStop(0, 'rgba(37,99,235,0.14)');
+ glow.addColorStop(1, 'rgba(37,99,235,0)');
+ ctx.fillStyle = glow;
+ ctx.fillRect(0, 0, W, H);
+ }
+
+ const wrap = (value, x, y, maxW, lineH, font, color, maxLines = 99) => {
+ ctx.font = font;
+ ctx.fillStyle = color;
+ ctx.textAlign = 'left';
+ ctx.textBaseline = 'top';
+ const words = String(value ?? '').split(' ');
+ let line = '';
+ let ly = y;
+ let used = 0;
+ for (const word of words) {
+ const next = line ? `${line} ${word}` : word;
+ if (ctx.measureText(next).width > maxW && line) {
+ if (used === maxLines - 1) {
+ ctx.fillText(`${line.replace(/\s+\S*$/, '')}…`, x, ly);
+ return ly + lineH;
+ }
+ ctx.fillText(line, x, ly);
+ ly += lineH;
+ line = word;
+ used++;
+ } else line = next;
+ }
+ if (line) { ctx.fillText(line, x, ly); ly += lineH; }
+ return ly;
+ };
+
+ const avatar = (node, cx, cy, r) => {
+ const color = kindDef(node.kind).color;
+ if (node.hasImage) {
+ const g = ctx.createLinearGradient(cx - r, cy - r, cx + r, cy + r);
+ g.addColorStop(0, hexToRgba(color, 0.95));
+ g.addColorStop(1, hexToRgba(color, 0.45));
+ ctx.fillStyle = g;
+ } else ctx.fillStyle = hexToRgba(color, dark ? 0.22 : 0.16);
+ ctx.beginPath();
+ ctx.arc(cx, cy, r, 0, Math.PI * 2);
+ ctx.fill();
+ ctx.lineWidth = Math.max(2, r * 0.07);
+ ctx.strokeStyle = color;
+ ctx.stroke();
+ ctx.fillStyle = node.hasImage ? '#fff' : color;
+ ctx.font = `600 ${r * 0.8}px ${FONT}`;
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'middle';
+ ctx.fillText(nodeInitials(node.name), cx, cy + r * 0.04);
+ };
+
+ const header = (title, sub) => {
+ ctx.textAlign = 'left';
+ ctx.textBaseline = 'top';
+ ctx.fillStyle = ACCENT;
+ ctx.font = `600 ${W * 0.014}px ${FONT}`;
+ ctx.fillText(String(index.name || 'Universe').toUpperCase(), 72, 64);
+ ctx.fillStyle = P.ink;
+ ctx.font = `700 ${W * 0.048}px ${FONT}`;
+ ctx.fillText(title, 68, 64 + W * 0.02);
+ ctx.fillStyle = P.muted;
+ ctx.font = `${W * 0.016}px ${FONT}`;
+ ctx.fillText(sub, 72, 64 + W * 0.082);
+ ctx.fillStyle = P.border;
+ ctx.fillRect(72, 64 + W * 0.116, W - 144, 2);
+ return 64 + W * 0.116 + 40;
+ };
+
+ const footer = () => {
+ ctx.fillStyle = P.faint;
+ ctx.font = `${W * 0.011}px ${FONT}`;
+ ctx.textAlign = 'left';
+ ctx.textBaseline = 'bottom';
+ const scope = asOfIssue == null ? 'Whole universe' : `As of ${issueLabel(index, asOfIssue)}`;
+ ctx.fillText(`${scope} · ${nodes.length} entries · ${edges.length} links · PortOS Universe Builder`, 72, H - 52);
+ ctx.textAlign = 'right';
+ ctx.fillText(new Date().toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }), W - 72, H - 52);
+ };
+
+ const relationshipLegend = (y) => {
+ let x = 72;
+ ctx.font = `${W * 0.011}px ${FONT}`;
+ ctx.textBaseline = 'middle';
+ ctx.textAlign = 'left';
+ for (const type of ['ally', 'antagonist', 'rival', 'mentor', 'love-interest', 'family']) {
+ const def = edgeDef(type);
+ ctx.fillStyle = def.color;
+ ctx.fillRect(x, y - 1.5, 22, 3);
+ ctx.fillStyle = P.muted;
+ ctx.fillText(def.label, x + 30, y);
+ x += 30 + ctx.measureText(def.label).width + 26;
+ }
+ };
+
+ // Typed relationship edges touching a node, both directions.
+ const relationsOf = (node) => (index.adjacency.get(node.id) || [])
+ .filter((e) => e.directed && ids.has(e.source) && ids.has(e.target));
+
+ // Which characters share an issue with this place — the same signal the
+ // "no cast" gap uses, since places carry no structured cast link.
+ const castOfPlace = (place) => {
+ const own = new Set(index.appear[place.id] || []);
+ if (!own.size) return [];
+ return characters.filter((c) => (index.appear[c.id] || []).some((i) => own.has(i)));
+ };
+
+ if (layout === 'roster') {
+ const y = header('Cast roster', `${characters.length} characters · ordered by connection · ring marks a rendered reference`);
+ const cols = W > H ? 6 : 4;
+ const gap = 24;
+ const cw = (W - 144 - gap * (cols - 1)) / cols;
+ const rows = Math.max(1, Math.ceil(characters.length / cols));
+ const ch = Math.min(cw * 1.05, Math.max(80, (H - y - 120 - gap * (rows - 1)) / rows));
+ characters.forEach((node, i) => {
+ const cx = 72 + (i % cols) * (cw + gap);
+ const cy = y + Math.floor(i / cols) * (ch + gap);
+ ctx.fillStyle = P.card;
+ ctx.strokeStyle = node.locked ? hexToRgba(ACCENT, 0.5) : P.border;
+ ctx.lineWidth = 1.5;
+ ctx.beginPath();
+ ctx.roundRect(cx, cy, cw, ch, 8);
+ ctx.fill();
+ ctx.stroke();
+ const r = Math.min(cw * 0.22, ch * 0.22);
+ avatar(node, cx + cw / 2, cy + r + ch * 0.1, r);
+ ctx.font = `600 ${Math.min(22, cw * 0.085)}px ${FONT}`;
+ ctx.fillStyle = P.ink;
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'top';
+ ctx.fillText(node.name, cx + cw / 2, cy + r * 2 + ch * 0.16);
+ wrap(node.role, cx + 14, cy + r * 2 + ch * 0.16 + Math.min(22, cw * 0.085) * 1.4,
+ cw - 28, Math.min(18, cw * 0.07) * 1.3, `${Math.min(18, cw * 0.07)}px ${FONT}`, P.muted, 2);
+ const rels = relationsOf(node).slice(0, 12);
+ let lx = cx + cw / 2 - rels.length * 5;
+ for (const edge of rels) {
+ ctx.fillStyle = edgeDef(edge.type).color;
+ ctx.beginPath();
+ ctx.arc(lx + 5, cy + ch - 22, 4, 0, Math.PI * 2);
+ ctx.fill();
+ lx += 10;
+ }
+ });
+ relationshipLegend(H - 90);
+ footer();
+ } else if (layout === 'dossier') {
+ const node = index.byId.get(subjectId) || characters[0];
+ if (!node) { footer(); return; }
+ const y = header(node.name, node.role);
+ const colW = (W - 144 - 48) / 2;
+ avatar(node, 72 + colW * 0.28, y + colW * 0.28, colW * 0.26);
+ let ty = y + colW * 0.6;
+ const sliders = node.sliders || {};
+ for (const axis of ['proactivity', 'likability', 'competence']) {
+ ctx.font = `${W * 0.013}px ${FONT}`;
+ ctx.fillStyle = P.muted;
+ ctx.textAlign = 'left';
+ ctx.textBaseline = 'middle';
+ ctx.fillText(axis[0].toUpperCase() + axis.slice(1), 72, ty);
+ ctx.fillStyle = P.border;
+ ctx.fillRect(72 + colW * 0.3, ty - 4, colW * 0.6, 8);
+ const value = Number.isInteger(sliders[axis]) ? sliders[axis] : null;
+ if (value != null) {
+ ctx.fillStyle = kindDef('character').color;
+ ctx.fillRect(72 + colW * 0.3, ty - 4, colW * 0.6 * (value / 10), 8);
+ }
+ ctx.fillStyle = value == null ? P.faint : P.ink;
+ ctx.textAlign = 'right';
+ ctx.fillText(value == null ? '—' : String(value), 72 + colW, ty);
+ ty += W * 0.03;
+ }
+ ty += 20;
+ ctx.textAlign = 'left';
+ const framework = node.framework || {};
+ for (const field of ['ghost', 'wound', 'lie', 'need', 'want']) {
+ ctx.font = `600 ${W * 0.011}px ${FONT}`;
+ ctx.fillStyle = ACCENT;
+ ctx.textBaseline = 'top';
+ ctx.fillText(field.toUpperCase(), 72, ty);
+ ty = wrap(framework[field] || 'Not authored', 72, ty + W * 0.016, colW, W * 0.02,
+ `${W * 0.015}px ${FONT}`, framework[field] ? P.ink : P.faint) + 14;
+ }
+ const rx = 72 + colW + 48;
+ let ry = y;
+ const section = (title) => {
+ ctx.fillStyle = ACCENT;
+ ctx.font = `600 ${W * 0.011}px ${FONT}`;
+ ctx.textAlign = 'left';
+ ctx.textBaseline = 'top';
+ ctx.fillText(title, rx, ry);
+ ry += W * 0.024;
+ };
+ section('RELATIONSHIPS');
+ const rels = relationsOf(node).slice(0, 12);
+ for (const edge of rels) {
+ const other = edge.source === node.id ? edge.targetNode : edge.sourceNode;
+ const def = edgeDef(edge.type);
+ ctx.fillStyle = def.color;
+ ctx.beginPath();
+ ctx.arc(rx + 8, ry + W * 0.009, 6, 0, Math.PI * 2);
+ ctx.fill();
+ ctx.fillStyle = P.ink;
+ ctx.font = `${W * 0.014}px ${FONT}`;
+ ctx.textAlign = 'left';
+ ctx.fillText(other.name, rx + 26, ry);
+ ctx.fillStyle = def.color;
+ ctx.font = `${W * 0.011}px ${FONT}`;
+ ctx.textAlign = 'right';
+ ctx.fillText(`${edge.source === node.id ? '→' : '←'} ${def.label}`, W - 72, ry + 3);
+ ry += W * 0.024;
+ }
+ if (!rels.length) {
+ ctx.fillStyle = P.faint;
+ ctx.font = `${W * 0.013}px ${FONT}`;
+ ctx.textAlign = 'left';
+ ctx.fillText('No typed relationships authored.', rx, ry);
+ ry += W * 0.03;
+ }
+ ry += 24;
+ section(`EVOLUTION LENS${node.evolution?.outcome ? ` · ${node.evolution.outcome}` : ''}`);
+ const stepW = colW / 5;
+ evolutionStageRows(node.evolution).forEach((row, i) => {
+ const sx = rx + i * stepW;
+ ctx.fillStyle = P.border;
+ ctx.fillRect(sx, ry + 10, stepW, 2);
+ ctx.fillStyle = row.authored ? '#a855f7' : P.bg;
+ ctx.strokeStyle = '#a855f7';
+ ctx.lineWidth = 2;
+ ctx.beginPath();
+ ctx.arc(sx + 8, ry + 11, 7, 0, Math.PI * 2);
+ ctx.fill();
+ ctx.stroke();
+ wrap(row.label, sx, ry + 30, stepW - 10, W * 0.014, `${W * 0.0105}px ${FONT}`,
+ row.authored ? P.muted : P.faint, 3);
+ });
+ ry += W * 0.09;
+ ry += 24;
+ section('APPEARS IN');
+ const seen = new Map();
+ for (const i of index.appear[node.id] || []) {
+ const issue = index.issues[i];
+ if (!issue) continue;
+ seen.set(issue.seriesId, (seen.get(issue.seriesId) || 0) + 1);
+ }
+ for (const [seriesId, count] of seen) {
+ const series = index.byId.get(seriesId);
+ ctx.fillStyle = P.ink;
+ ctx.font = `${W * 0.014}px ${FONT}`;
+ ctx.textAlign = 'left';
+ ctx.fillText(series ? series.name : seriesId, rx, ry);
+ ctx.fillStyle = P.faint;
+ ctx.textAlign = 'right';
+ ctx.fillText(`${count} issue${count === 1 ? '' : 's'}`, W - 72, ry + 2);
+ ry += W * 0.022;
+ }
+ if (!seen.size) {
+ ctx.fillStyle = P.faint;
+ ctx.font = `${W * 0.013}px ${FONT}`;
+ ctx.textAlign = 'left';
+ ctx.fillText('Not yet used in any series.', rx, ry);
+ ry += W * 0.03;
+ }
+ ry += 24;
+ section('PLACES & OBJECTS');
+ for (const edge of index.adjacency.get(node.id) || []) {
+ if (edge.type !== 'attachment' || !ids.has(edge.source) || !ids.has(edge.target)) continue;
+ const other = edge.source === node.id ? edge.targetNode : edge.sourceNode;
+ ctx.fillStyle = kindDef(other.kind).color;
+ ctx.fillRect(rx, ry + 4, 8, 8);
+ ctx.fillStyle = P.ink;
+ ctx.font = `${W * 0.014}px ${FONT}`;
+ ctx.textAlign = 'left';
+ ctx.fillText(other.name, rx + 20, ry);
+ ctx.fillStyle = P.faint;
+ ctx.textAlign = 'right';
+ ctx.fillText(edge.label || '', W - 72, ry + 2);
+ ry += W * 0.022;
+ }
+ footer();
+ } else if (layout === 'atlas') {
+ const places = nodes.filter((n) => n.kind === 'place');
+ const y = header('Place atlas', `${places.length} locations and who moves through them`);
+ const cols = W > H ? 4 : 3;
+ const gap = 22;
+ const cw = (W - 144 - gap * (cols - 1)) / cols;
+ const rows = Math.max(1, Math.ceil(places.length / cols));
+ const ch = Math.min(cw * 0.95, Math.max(80, (H - y - 100 - gap * (rows - 1)) / rows));
+ places.forEach((place, i) => {
+ const cx = 72 + (i % cols) * (cw + gap);
+ const cy = y + Math.floor(i / cols) * (ch + gap);
+ ctx.fillStyle = P.card;
+ ctx.strokeStyle = P.border;
+ ctx.lineWidth = 1.5;
+ ctx.beginPath();
+ ctx.roundRect(cx, cy, cw, ch, 8);
+ ctx.fill();
+ ctx.stroke();
+ const band = ctx.createLinearGradient(cx, cy, cx, cy + ch * 0.42);
+ band.addColorStop(0, hexToRgba(kindDef('place').color, place.hasImage ? 0.55 : 0.12));
+ band.addColorStop(1, hexToRgba(kindDef('place').color, place.hasImage ? 0.2 : 0.04));
+ ctx.fillStyle = band;
+ ctx.beginPath();
+ ctx.roundRect(cx + 1, cy + 1, cw - 2, ch * 0.42, [7, 7, 0, 0]);
+ ctx.fill();
+ if (!place.hasImage) {
+ ctx.fillStyle = hexToRgba(kindDef('place').color, 0.6);
+ ctx.font = `${W * 0.01}px ${FONT}`;
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'middle';
+ ctx.fillText('no render yet', cx + cw / 2, cy + ch * 0.21);
+ }
+ ctx.textAlign = 'left';
+ ctx.textBaseline = 'top';
+ ctx.fillStyle = P.ink;
+ ctx.font = `600 ${Math.min(24, cw * 0.075)}px ${FONT}`;
+ ctx.fillText(place.name, cx + 16, cy + ch * 0.46);
+ ctx.fillStyle = P.muted;
+ ctx.font = `${Math.min(17, cw * 0.055)}px ${FONT}`;
+ ctx.fillText(place.role, cx + 16, cy + ch * 0.46 + Math.min(24, cw * 0.075) * 1.4);
+ const cast = castOfPlace(place);
+ const ar = Math.min(16, cw * 0.05);
+ let ax = cx + 16;
+ for (const member of cast.slice(0, 7)) { avatar(member, ax + ar, cy + ch - ar - 16, ar); ax += ar * 2.3; }
+ if (!cast.length) {
+ ctx.fillStyle = WARN;
+ ctx.font = `${Math.min(15, cw * 0.05)}px ${FONT}`;
+ ctx.textAlign = 'left';
+ ctx.textBaseline = 'bottom';
+ ctx.fillText('⚠ no cast in its issues', cx + 16, cy + ch - 16);
+ }
+ });
+ footer();
+ } else if (layout === 'timeline') {
+ const top = characters.slice(0, W > H ? 14 : 18);
+ const T = Math.max(1, index.totalIssues);
+ let y = header('Timeline strip', `Presence of ${top.length} characters across ${index.series.length} series`);
+ const lx = 72 + W * 0.17;
+ const gw = W - 72 - lx;
+ const rowH = Math.min(58, Math.max(20, (H - y - 130) / Math.max(1, top.length)));
+ const colW = gw / T;
+ index.series.forEach((series, i) => {
+ const issues = index.issues.filter((x) => x.seriesId === series.id);
+ if (!issues.length) return;
+ const x0 = lx + issues[0].index * colW;
+ ctx.fillStyle = hexToRgba(kindDef('series').color, 0.05 + (i % 2) * 0.05);
+ ctx.fillRect(x0, y, issues.length * colW, top.length * rowH + 30);
+ ctx.fillStyle = kindDef('series').color;
+ ctx.font = `600 ${W * 0.011}px ${FONT}`;
+ ctx.textAlign = 'left';
+ ctx.textBaseline = 'top';
+ ctx.fillText(series.name, x0 + 8, y + 6);
+ });
+ y += 30;
+ top.forEach((node, i) => {
+ const ry = y + i * rowH + rowH / 2;
+ avatar(node, 72 + rowH * 0.3, ry, rowH * 0.3);
+ ctx.fillStyle = P.ink;
+ ctx.font = `${Math.min(18, rowH * 0.34)}px ${FONT}`;
+ ctx.textAlign = 'left';
+ ctx.textBaseline = 'middle';
+ ctx.fillText(node.name, 72 + rowH * 0.75, ry);
+ ctx.fillStyle = P.border;
+ ctx.fillRect(lx, ry, gw, 1);
+ const seen = (index.appear[node.id] || []).filter((x) => asOfIssue == null || x <= asOfIssue);
+ if (seen.length > 1) {
+ ctx.strokeStyle = hexToRgba(kindDef('character').color, 0.5);
+ ctx.lineWidth = 3;
+ ctx.beginPath();
+ ctx.moveTo(lx + seen[0] * colW + colW / 2, ry);
+ ctx.lineTo(lx + seen[seen.length - 1] * colW + colW / 2, ry);
+ ctx.stroke();
+ }
+ for (const x of seen) {
+ ctx.fillStyle = kindDef('character').color;
+ ctx.beginPath();
+ ctx.arc(lx + x * colW + colW / 2, ry, Math.max(2, rowH * 0.13), 0, Math.PI * 2);
+ ctx.fill();
+ }
+ });
+ ctx.textAlign = 'left';
+ ctx.textBaseline = 'middle';
+ ctx.font = `${W * 0.01}px ${FONT}`;
+ ctx.fillStyle = kindDef('character').color;
+ ctx.beginPath();
+ ctx.arc(77, H - 92, 5, 0, Math.PI * 2);
+ ctx.fill();
+ ctx.fillStyle = P.muted;
+ ctx.fillText('appears in issue', 88, H - 92);
+ footer();
+ } else if (layout === 'web') {
+ const cast = characters.slice(0, 28);
+ const y = header('Relationship web', `${cast.length} characters · every typed link between them`);
+ const castIds = new Set(cast.map((n) => n.id));
+ const cx = W / 2;
+ const cy = y + (H - y - 140) / 2;
+ const R = Math.min(W, H - y - 140) * 0.38;
+ const at = new Map(cast.map((node, i) => {
+ const a = (i / Math.max(1, cast.length)) * Math.PI * 2 - Math.PI / 2;
+ return [node.id, [cx + Math.cos(a) * R, cy + Math.sin(a) * R, a]];
+ }));
+ ctx.lineCap = 'round';
+ for (const edge of edges) {
+ if (!edge.directed || !castIds.has(edge.source) || !castIds.has(edge.target)) continue;
+ const [ax, ay] = at.get(edge.source);
+ const [bx, by] = at.get(edge.target);
+ ctx.strokeStyle = hexToRgba(edgeDef(edge.type).color, 0.6);
+ ctx.lineWidth = 2.5;
+ ctx.beginPath();
+ ctx.moveTo(ax, ay);
+ // Bow every chord toward the centre so parallel links stay separable.
+ ctx.quadraticCurveTo(cx + (ax + bx - 2 * cx) * 0.22, cy + (ay + by - 2 * cy) * 0.22, bx, by);
+ ctx.stroke();
+ }
+ const ar = Math.max(14, Math.min(34, R * 0.13));
+ for (const node of cast) {
+ const [ax, ay, angle] = at.get(node.id);
+ avatar(node, ax, ay, ar);
+ ctx.save();
+ ctx.translate(ax + Math.cos(angle) * (ar + 10), ay + Math.sin(angle) * (ar + 10));
+ ctx.rotate(Math.abs(angle) > Math.PI / 2 ? angle + Math.PI : angle);
+ ctx.fillStyle = P.ink;
+ ctx.font = `${W * 0.011}px ${FONT}`;
+ ctx.textAlign = Math.abs(angle) > Math.PI / 2 ? 'right' : 'left';
+ ctx.textBaseline = 'middle';
+ ctx.fillText(node.name, 0, 0);
+ ctx.restore();
+ }
+ relationshipLegend(H - 90);
+ footer();
+ }
+}
diff --git a/client/src/lib/universeGraphPoster.test.js b/client/src/lib/universeGraphPoster.test.js
new file mode 100644
index 0000000000..e479888eca
--- /dev/null
+++ b/client/src/lib/universeGraphPoster.test.js
@@ -0,0 +1,87 @@
+import { describe, it, expect } from 'vitest';
+import { indexGraph } from './universeGraphModel';
+import { POSTER_LAYOUTS, POSTER_SIZES, posterDimensions, renderPoster } from './universeGraphPoster';
+
+// A small but structurally complete universe: two characters (one with a lens
+// and a framework, one with nothing authored), a place with cast and one
+// without, an object attachment, a series and its issues.
+const index = () => indexGraph({
+ name: 'Example Universe',
+ totalIssues: 2,
+ series: [{ id: 'series:s1', recordId: 's1', name: 'First Arc' }],
+ issues: [
+ { id: 'issue:i1', recordId: 'i1', index: 0, name: 'First Arc #1', seriesId: 'series:s1' },
+ { id: 'issue:i2', recordId: 'i2', index: 1, name: 'First Arc #2', seriesId: 'series:s1' },
+ ],
+ appear: { 'character:c1': [0, 1], 'character:c2': [1], 'place:p1': [0] },
+ nodes: [
+ {
+ id: 'character:c1',
+ kind: 'character',
+ name: 'Alice Vane',
+ role: 'Lead',
+ hasImage: true,
+ locked: true,
+ firstIssue: 0,
+ arcType: 'positive',
+ sliders: { proactivity: 7, competence: 4 },
+ framework: { ghost: 'Lost the key.', want: 'Get it back.' },
+ evolution: { outcome: 'partial-open', stages: [{ stageId: 'cost-tested', testedBelief: 'x' }] },
+ },
+ { id: 'character:c2', kind: 'character', name: 'Bob', role: 'Foil', hasImage: false, firstIssue: 1 },
+ { id: 'place:p1', kind: 'place', name: 'The Vault', role: 'INT. VAULT', hasImage: true, firstIssue: 0 },
+ { id: 'place:p2', kind: 'place', name: 'The Pier', role: 'EXT. PIER', hasImage: false, firstIssue: 0 },
+ { id: 'object:o1', kind: 'object', name: 'The Key', role: 'Macguffin', hasImage: false, firstIssue: 0 },
+ { id: 'series:s1', kind: 'series', name: 'First Arc', role: '2 issues', hasImage: false, firstIssue: 0 },
+ ],
+ edges: [
+ { source: 'character:c1', target: 'character:c2', type: 'rival', directed: true, since: 1 },
+ { source: 'object:o1', target: 'character:c1', type: 'attachment', label: 'talisman', since: 0 },
+ { source: 'character:c1', target: 'issue:i1', type: 'appearance', since: 0 },
+ ],
+});
+
+const canvas = () => document.createElement('canvas');
+
+describe('renderPoster', () => {
+ it.each(POSTER_LAYOUTS.map((l) => l.id))('draws the %s layout without throwing', (layout) => {
+ const el = canvas();
+ expect(() => renderPoster(el, { index: index(), layout, subjectId: 'character:c1' })).not.toThrow();
+ expect(el.width).toBe(posterDimensions('portrait')[0]);
+ });
+
+ it.each(POSTER_SIZES.map((s) => s.id))('sizes the %s canvas from its declared dimensions', (size) => {
+ const el = canvas();
+ const [w, h] = posterDimensions(size);
+ renderPoster(el, { index: index(), layout: 'roster', size });
+ expect([el.width, el.height]).toEqual([w, h]);
+ });
+
+ it('renders at 2× for the print download', () => {
+ const el = canvas();
+ const [w, h] = posterDimensions('portrait');
+ renderPoster(el, { index: index(), layout: 'roster', scale: 2 });
+ expect([el.width, el.height]).toEqual([w * 2, h * 2]);
+ });
+
+ it('draws the paper theme without throwing', () => {
+ expect(() => renderPoster(canvas(), { index: index(), layout: 'atlas', theme: 'paper' })).not.toThrow();
+ });
+
+ it('falls back to the most connected character when the dossier subject is unknown', () => {
+ expect(() => renderPoster(canvas(), { index: index(), layout: 'dossier', subjectId: 'character:gone' })).not.toThrow();
+ });
+
+ it('survives an empty universe rather than dividing by zero', () => {
+ const empty = indexGraph({ name: 'Empty', nodes: [], edges: [], issues: [], series: [], totalIssues: 0, appear: {} });
+ for (const layout of POSTER_LAYOUTS.map((l) => l.id)) {
+ expect(() => renderPoster(canvas(), { index: empty, layout })).not.toThrow();
+ }
+ });
+
+ it('scopes the drawing to the timeline position when one is given', () => {
+ // Bob is introduced in issue index 1, so an "as of issue 0" poster must not
+ // reach for him — the guard is that the layout still renders.
+ expect(() => renderPoster(canvas(), { index: index(), layout: 'timeline', asOfIssue: 0 })).not.toThrow();
+ });
+});
diff --git a/client/src/pollingConventions.test.js b/client/src/pollingConventions.test.js
index e85c5a5a29..6f543f29e0 100644
--- a/client/src/pollingConventions.test.js
+++ b/client/src/pollingConventions.test.js
@@ -70,6 +70,7 @@ const ALLOWED = {
'src/components/music/MusicGenPanel.jsx': 'elapsed-time clock for a running generation (the job itself polls via useAutoRefetch)',
'src/components/sprites/LoopTrimmer.jsx': 'advances the sprite playback frame; no I/O',
'src/components/sprites/WalkWorkflow.jsx': 'counts ticks to self-cancel a stale-queued attach after ~60s — useAutoRefetch does not model a bounded poll',
+ 'src/components/universeBuilder/graph/GraphTimeline.jsx': 'advances the universe-graph playback position one issue per tick; no I/O',
'src/components/voice/VoiceWidget.jsx': 'samples the in-memory VAD RMS level every 100ms; no I/O',
'src/components/writers-room/ExercisePanel.jsx': 'elapsed-time clock tick; no I/O',
'src/components/writers-room/WorkEditor.jsx': 'elapsed-time clock for the analysis-run banner; no I/O',
diff --git a/client/src/services/apiUniverseBuilder.js b/client/src/services/apiUniverseBuilder.js
index 18fb2d55a2..4e3e6f0924 100644
--- a/client/src/services/apiUniverseBuilder.js
+++ b/client/src/services/apiUniverseBuilder.js
@@ -430,6 +430,13 @@ export const applyCharacterAugmentation = (universeId, entryId, { fields, finger
export const getUniverseCanonUsage = (universeId) =>
request(`/universe-builder/${encodeURIComponent(universeId)}/canon-usage`);
+// Relationship graph for the Graph tab: `{ name, nodes, edges, series, issues,
+// totalIssues, appear }`. Nodes are namespaced by kind (`character:`)
+// and every `since` / `firstIssue` is an index into `issues`. Read-only
+// aggregation over canon + canon-usage; no writes, no LLM calls.
+export const getUniverseGraph = (universeId, options = {}) =>
+ request(`/universe-builder/${encodeURIComponent(universeId)}/graph`, options);
+
// Thin lookup: every series that links to this universe as `[{ id, name }]`.
// Use this when only the seriesId → seriesName mapping is needed — the full
// /canon-usage endpoint also runs prose-matching scans across every issue.
diff --git a/client/src/test/setup.js b/client/src/test/setup.js
index 5b301e82d3..c92c0b81e3 100644
--- a/client/src/test/setup.js
+++ b/client/src/test/setup.js
@@ -57,10 +57,19 @@ if (typeof HTMLCanvasElement !== 'undefined') {
rotate: () => {},
arc: () => {},
fill: () => {},
- measureText: () => ({ width: 0 }),
+ measureText: (text) => ({ width: String(text ?? '').length * 6 }),
transform: () => {},
rect: () => {},
clip: () => {},
+ // Path/text/gradient members the graph + poster canvases use. A gradient
+ // has to be an object with addColorStop, because callers assign it to
+ // fillStyle and keep drawing.
+ roundRect: () => {},
+ quadraticCurveTo: () => {},
+ strokeText: () => {},
+ setLineDash: () => {},
+ createLinearGradient: () => ({ addColorStop: () => {} }),
+ createRadialGradient: () => ({ addColorStop: () => {} }),
});
}
diff --git a/server/routes/universeBuilder/graph.js b/server/routes/universeBuilder/graph.js
new file mode 100644
index 0000000000..f7c7a185d2
--- /dev/null
+++ b/server/routes/universeBuilder/graph.js
@@ -0,0 +1,23 @@
+/**
+ * Universe relationship graph — the read model behind the Graph tab.
+ *
+ * Scoped under `/:id`, so mount order relative to crud.js doesn't matter.
+ */
+
+import { Router } from 'express';
+import { asyncHandler } from '../../lib/errorHandler.js';
+import { buildUniverseGraph } from '../../services/universeGraph.js';
+import { mapServiceError } from './shared.js';
+
+const router = Router();
+
+// Nodes + edges for one universe: canon entries, the links authored on them,
+// the series/issues they appear in, and their rendered references. Read-only
+// aggregation over records that already exist — no writes, no LLM calls.
+router.get('/:id/graph', asyncHandler(async (req, res) => {
+ const result = await buildUniverseGraph(req.params.id)
+ .catch((err) => { throw mapServiceError(err); });
+ res.json(result);
+}));
+
+export default router;
diff --git a/server/routes/universeBuilder/index.js b/server/routes/universeBuilder/index.js
index 27af31abca..e81439fdb9 100644
--- a/server/routes/universeBuilder/index.js
+++ b/server/routes/universeBuilder/index.js
@@ -28,6 +28,7 @@ import exportRoutes from './export.js';
import crudRoutes from './crud.js';
import renderRoutes from './render.js';
import canonRoutes from './canon.js';
+import graphRoutes from './graph.js';
const router = Router();
@@ -40,5 +41,6 @@ router.use(exportRoutes);
router.use(crudRoutes);
router.use(renderRoutes);
router.use(canonRoutes);
+router.use(graphRoutes);
export default router;
diff --git a/server/services/universeGraph.js b/server/services/universeGraph.js
new file mode 100644
index 0000000000..7956dc1e41
--- /dev/null
+++ b/server/services/universeGraph.js
@@ -0,0 +1,329 @@
+/**
+ * Universe relationship graph — the read model behind the Universe Builder's
+ * Graph tab.
+ *
+ * Pure aggregation over records that already exist: the universe's canon
+ * arrays (characters / places / objects), the structured links authored on
+ * them (`relationshipLinks`, object `attachments`), the prose cross-reference
+ * `canonUsage` already computes, and the rendered references each entry
+ * carries. No writes, no LLM calls — every node and edge is something the user
+ * authored or the matcher already derived.
+ *
+ * Node ids are namespaced by kind (`character:`) so a place and a
+ * character that share a canon id can never collide in the same graph.
+ */
+
+import { getUniverse, ERR_NOT_FOUND } from './universeBuilder.js';
+import { getUniverseCanonUsage } from './canonUsage.js';
+import { listSeries } from './pipeline/series.js';
+import { listAllIssues } from './pipeline/issues.js';
+import {
+ matchCharactersInText, matchPlacesInText, matchObjectsInText,
+} from '../lib/scenePrompt.js';
+import { ServerError } from '../lib/errorHandler.js';
+
+// Rendered references per canon entry that become their own `image` node. An
+// entry can hold up to BIBLE_LIMITS.IMAGE_REFS_PER_ENTRY_MAX of them; past a
+// handful they say nothing new about the graph's shape and drown the layout.
+export const IMAGE_NODES_PER_ENTRY_MAX = 3;
+
+// Canon trunks, in the order their nodes are emitted. `kind` is the canon-array
+// key on the universe; `node` is the graph node kind.
+const TRUNKS = Object.freeze([
+ { key: 'characters', node: 'character', match: matchCharactersInText },
+ { key: 'places', node: 'place', match: matchPlacesInText },
+ { key: 'objects', node: 'object', match: matchObjectsInText },
+]);
+
+const nodeId = (kind, id) => `${kind}:${id}`;
+const list = (value) => (Array.isArray(value) ? value : []);
+const text = (value) => (typeof value === 'string' ? value.trim() : '');
+
+// A canon entry's one-line subtitle. Each trunk carries its descriptive text
+// under a different key, and an entry with none still needs a label the
+// inspector and the poster can print.
+const roleFor = (kind, entry, name) => {
+ if (kind === 'character') return text(entry.role) || text(entry.coreTheme) || 'Character';
+ if (kind === 'place') {
+ // A place with no name displays under its slugline, so repeating it as the
+ // subtitle would print the same string twice.
+ const slugline = text(entry.slugline);
+ return (slugline === name ? '' : slugline) || text(entry.era) || 'Place';
+ }
+ return text(entry.significance) || 'Object';
+};
+
+// The Three Sliders round-trip as an always-present object whose unrated axes
+// are null. Only hand the client an object when at least one axis is rated —
+// `null` is the "never rated" signal the inspector renders as absent.
+const slidersFor = (entry) => {
+ const raw = entry.sliders;
+ if (!raw || typeof raw !== 'object') return null;
+ const rated = ['proactivity', 'likability', 'competence']
+ .filter((axis) => Number.isInteger(raw[axis]));
+ if (!rated.length) return null;
+ return Object.fromEntries(rated.map((axis) => [axis, raw[axis]]));
+};
+
+// Ghost → Wound → Lie → Need → Want. Null when the author has written none of
+// it, so the inspector shows the empty state instead of five blank rows.
+const frameworkFor = (entry) => {
+ const out = {};
+ for (const field of ['ghost', 'wound', 'lie', 'need', 'want']) {
+ const value = text(entry[field]);
+ if (value) out[field] = value;
+ }
+ return Object.keys(out).length ? out : null;
+};
+
+/**
+ * Build the graph payload for one universe.
+ *
+ * @param {string} universeId
+ * @returns {Promise} `{ universeId, name, nodes, edges, series, issues,
+ * totalIssues, appear }` — `appear` maps a node id to the ascending issue
+ * indices it appears in, which is what the timeline scrubber and the
+ * co-appearance suggestions read.
+ */
+export async function buildUniverseGraph(universeId) {
+ const universe = await getUniverse(universeId).catch((err) => {
+ if (err?.code === ERR_NOT_FOUND) {
+ throw new ServerError('Universe not found', { status: 404, code: 'UNIVERSE_NOT_FOUND' });
+ }
+ throw err;
+ });
+
+ // The cross-reference owns the prose scan (the expensive part, run once).
+ // It re-reads the series + issue rows this function also needs, which is the
+ // deliberate trade: the alternative is duplicating its matcher orchestration
+ // here, where it would drift. Both reads are indexed and history-free.
+ const usage = await getUniverseCanonUsage(universeId);
+ const allSeries = await listSeries();
+ const linked = allSeries.filter((s) => s.universeId === universeId);
+ const linkedIds = linked.map((s) => s.id);
+ const rawIssues = linkedIds.length
+ ? await listAllIssues({ seriesIds: linkedIds, withHistory: false })
+ : [];
+
+ // Timeline order is a single global issue index: series ordered by their
+ // earliest issue, issues by number within a series. Every `since` / anchor /
+ // firstIssue in the payload is an index into this one list.
+ const issuesBySeries = new Map(linkedIds.map((id) => [id, []]));
+ for (const issue of rawIssues) {
+ if (issuesBySeries.has(issue.seriesId)) issuesBySeries.get(issue.seriesId).push(issue);
+ }
+ for (const bucket of issuesBySeries.values()) {
+ bucket.sort((a, b) => (a.number || 0) - (b.number || 0));
+ }
+ const orderedSeries = linked
+ .slice()
+ .sort((a, b) => (a.createdAt || '').localeCompare(b.createdAt || '') || a.name.localeCompare(b.name));
+
+ // Built in GRAPH-id space (`issue:` / `series:`) with the
+ // record id kept alongside, so no field ever holds a raw id under a name
+ // whose sibling holds a namespaced one. `issueIndexById` stays keyed by
+ // RECORD id — that is what the canon-usage rows carry.
+ const issues = [];
+ const issueIndexById = new Map();
+ for (const series of orderedSeries) {
+ for (const issue of issuesBySeries.get(series.id) || []) {
+ const index = issues.length;
+ issueIndexById.set(issue.id, index);
+ issues.push({
+ id: nodeId('issue', issue.id),
+ recordId: issue.id,
+ index,
+ name: `${series.name} #${issue.number ?? index + 1}`,
+ title: text(issue.title),
+ seriesId: nodeId('series', series.id),
+ });
+ }
+ }
+ const totalIssues = issues.length;
+
+ const nodes = [];
+ const edges = [];
+ const appear = {};
+ const push = (node) => { nodes.push(node); return node; };
+ const link = (edge) => { edges.push(edge); return edge; };
+
+ // ---- series + issue nodes ----
+ const seriesFirstIssue = new Map();
+ for (const issue of issues) {
+ if (!seriesFirstIssue.has(issue.seriesId)) seriesFirstIssue.set(issue.seriesId, issue.index);
+ }
+ for (const series of orderedSeries) {
+ const count = (issuesBySeries.get(series.id) || []).length;
+ push({
+ id: nodeId('series', series.id),
+ kind: 'series',
+ name: series.name,
+ role: `${count} issue${count === 1 ? '' : 's'}`,
+ recordId: series.id,
+ hasImage: false,
+ firstIssue: seriesFirstIssue.get(nodeId('series', series.id)) ?? 0,
+ });
+ }
+ for (const issue of issues) {
+ push({
+ id: issue.id,
+ kind: 'issue',
+ name: issue.name,
+ role: issue.title || `Issue ${issue.index + 1}`,
+ recordId: issue.recordId,
+ seriesId: issue.seriesId,
+ index: issue.index,
+ hasImage: false,
+ firstIssue: issue.index,
+ });
+ link({ source: issue.id, target: issue.seriesId, type: 'membership', since: issue.index });
+ }
+
+ // ---- canon entries, their appearances, and their rendered references ----
+ const entryNodeByTrunk = { characters: new Map(), places: new Map(), objects: new Map() };
+ let imageSeq = 0;
+ for (const trunk of TRUNKS) {
+ for (const entry of list(universe[trunk.key])) {
+ if (!entry || typeof entry !== 'object' || !entry.id) continue;
+ const imageRefs = list(entry.imageRefs);
+ const primary = text(entry.primaryImageRef) || imageRefs[0] || null;
+ const id = nodeId(trunk.node, entry.id);
+ const usageRows = list(usage[trunk.key]?.[entry.id]);
+ const indices = [...new Set(
+ usageRows.flatMap((row) => list(row.issueIds).map((issueId) => issueIndexById.get(issueId))),
+ )].filter((i) => Number.isInteger(i)).sort((a, b) => a - b);
+ if (indices.length) appear[id] = indices;
+
+ const name = text(entry.name) || text(entry.slugline) || 'Untitled';
+ const node = push({
+ id,
+ kind: trunk.node,
+ name,
+ role: roleFor(trunk.node, entry, name),
+ recordId: entry.id,
+ hasImage: imageRefs.length > 0,
+ primaryImageRef: primary,
+ locked: entry.locked !== false,
+ firstIssue: indices.length ? indices[0] : 0,
+ ...(trunk.node === 'character'
+ ? {
+ arcType: text(entry.arcType) || null,
+ sliders: slidersFor(entry),
+ framework: frameworkFor(entry),
+ evolution: entry.evolution || null,
+ }
+ : {}),
+ });
+ entryNodeByTrunk[trunk.key].set(entry.id, { node, entry });
+
+ for (const index of indices) {
+ link({ source: id, target: issues[index].id, type: 'appearance', since: index });
+ }
+ for (const row of usageRows) {
+ if (!issuesBySeries.has(row.seriesId)) continue;
+ link({
+ source: id,
+ target: nodeId('series', row.seriesId),
+ type: 'membership',
+ since: indices.length ? indices[0] : 0,
+ });
+ }
+ imageRefs.slice(0, IMAGE_NODES_PER_ENTRY_MAX).forEach((ref) => {
+ const imgId = nodeId('image', `${imageSeq++}`);
+ push({
+ id: imgId,
+ kind: 'image',
+ name: ref,
+ role: ref === primary ? 'Primary reference' : 'Rendered reference',
+ hasImage: true,
+ imageRef: ref,
+ primary: ref === primary,
+ firstIssue: node.firstIssue,
+ });
+ link({ source: imgId, target: id, type: 'imageref', since: node.firstIssue });
+ });
+ }
+ }
+
+ // ---- authored relationship links (character → character, directed) ----
+ for (const { node, entry } of entryNodeByTrunk.characters.values()) {
+ for (const relLink of list(entry.relationshipLinks)) {
+ const target = entryNodeByTrunk.characters.get(relLink?.targetCharacterId);
+ if (!target || target.node.id === node.id) continue;
+ link({
+ source: node.id,
+ target: target.node.id,
+ type: relLink.type || 'custom',
+ directed: true,
+ label: text(relLink.description) || null,
+ since: Math.max(node.firstIssue, target.node.firstIssue),
+ });
+ }
+ }
+
+ // ---- authored object ↔ character attachments ----
+ for (const { node, entry } of entryNodeByTrunk.objects.values()) {
+ for (const attachment of list(entry.attachments)) {
+ const target = entryNodeByTrunk.characters.get(attachment?.characterId);
+ if (!target) continue;
+ link({
+ source: node.id,
+ target: target.node.id,
+ type: 'attachment',
+ label: attachment.role || 'custom',
+ since: Math.max(node.firstIssue, target.node.firstIssue),
+ });
+ }
+ }
+
+ // ---- composite sheets + the linked mood board ----
+ const sheets = list(universe.compositeSheets);
+ const canonByTrunk = sheets.length
+ ? Object.fromEntries(TRUNKS.map((t) => [t.key, list(universe[t.key]).filter((e) => e?.id)]))
+ : {};
+ for (const sheet of sheets) {
+ if (!sheet?.id) continue;
+ const id = nodeId('composite', sheet.id);
+ push({
+ id,
+ kind: 'composite',
+ name: sheet.label,
+ role: (sheet.kind || 'reference_sheet').replace(/_/g, ' '),
+ recordId: sheet.id,
+ hasImage: list(sheet.imageRefs).length > 0,
+ primaryImageRef: list(sheet.imageRefs).at(-1) || null,
+ firstIssue: 0,
+ });
+ // Which canon a sheet is *about* is not stored — the same prose matcher the
+ // cross-reference uses reads it back out of the sheet's own prompt.
+ const corpus = `${sheet.label}\n${sheet.prompt}`;
+ for (const trunk of TRUNKS) {
+ for (const matched of trunk.match(corpus, canonByTrunk[trunk.key])) {
+ const target = entryNodeByTrunk[trunk.key].get(matched.id);
+ if (target) link({ source: id, target: target.node.id, type: 'imageref', since: 0 });
+ }
+ }
+ }
+ if (universe.moodBoardId) {
+ push({
+ id: nodeId('moodboard', universe.moodBoardId),
+ kind: 'moodboard',
+ name: 'Linked mood board',
+ role: 'Style source for this universe',
+ recordId: universe.moodBoardId,
+ hasImage: true,
+ firstIssue: 0,
+ });
+ }
+
+ return {
+ universeId,
+ name: universe.name,
+ nodes,
+ edges,
+ series: orderedSeries.map((s) => ({ id: nodeId('series', s.id), recordId: s.id, name: s.name })),
+ issues,
+ totalIssues,
+ appear,
+ };
+}
diff --git a/server/services/universeGraph.test.js b/server/services/universeGraph.test.js
new file mode 100644
index 0000000000..119d4ac4e5
--- /dev/null
+++ b/server/services/universeGraph.test.js
@@ -0,0 +1,235 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+
+// The graph builder composes four read-only collaborators. Mock them so the
+// assertions are about the DERIVATION (node/edge shape, timeline indices)
+// rather than about storage.
+const mockUniverses = new Map();
+const mockSeriesList = [];
+const mockIssuesBySeries = new Map();
+let mockUsage = null;
+
+vi.mock('./universeBuilder.js', () => ({
+ ERR_NOT_FOUND: 'NOT_FOUND',
+ getUniverse: vi.fn(async (id) => {
+ const universe = mockUniverses.get(id);
+ if (!universe) throw Object.assign(new Error(`Universe not found: ${id}`), { code: 'NOT_FOUND' });
+ return universe;
+ }),
+}));
+
+vi.mock('./canonUsage.js', () => ({
+ getUniverseCanonUsage: vi.fn(async () => mockUsage),
+}));
+
+vi.mock('./pipeline/series.js', () => ({
+ listSeries: vi.fn(async () => [...mockSeriesList]),
+}));
+
+vi.mock('./pipeline/issues.js', () => ({
+ listAllIssues: vi.fn(async ({ seriesIds } = {}) => {
+ const wanted = Array.isArray(seriesIds) ? new Set(seriesIds) : null;
+ const out = [];
+ for (const [seriesId, issues] of mockIssuesBySeries) {
+ if (wanted && !wanted.has(seriesId)) continue;
+ for (const issue of issues) out.push({ ...issue, seriesId });
+ }
+ return out;
+ }),
+}));
+
+const { buildUniverseGraph, IMAGE_NODES_PER_ENTRY_MAX } = await import('./universeGraph.js');
+
+const emptyUsage = () => ({
+ characters: {}, places: {}, objects: {}, seriesNameMap: {}, seriesCount: 0, issueCount: 0,
+});
+
+beforeEach(() => {
+ mockUniverses.clear();
+ mockSeriesList.length = 0;
+ mockIssuesBySeries.clear();
+ mockUsage = emptyUsage();
+});
+
+const seedUniverse = (overrides = {}) => {
+ const universe = {
+ id: 'u1',
+ name: 'Example Universe',
+ characters: [],
+ places: [],
+ objects: [],
+ compositeSheets: [],
+ ...overrides,
+ };
+ mockUniverses.set('u1', universe);
+ return universe;
+};
+
+const nodeOf = (graph, id) => graph.nodes.find((n) => n.id === id);
+
+describe('buildUniverseGraph — canon nodes', () => {
+ it('does not print a slugline-only place name twice', async () => {
+ seedUniverse({ places: [{ id: 'p1', slugline: 'INT. VAULT' }] });
+ const node = nodeOf(await buildUniverseGraph('u1'), 'place:p1');
+ expect(node.name).toBe('INT. VAULT');
+ expect(node.role).toBe('Place');
+ });
+
+ it('emits kind-namespaced nodes so a place and a character can share a canon id', async () => {
+ seedUniverse({
+ characters: [{ id: 'shared', name: 'Alice', role: 'Lead', imageRefs: ['a.png'] }],
+ places: [{ id: 'shared', name: 'The Vault', slugline: 'INT. VAULT' }],
+ });
+ const graph = await buildUniverseGraph('u1');
+ expect(nodeOf(graph, 'character:shared').name).toBe('Alice');
+ expect(nodeOf(graph, 'place:shared').name).toBe('The Vault');
+ });
+
+ it('projects the character framework, sliders and arc onto the node', async () => {
+ seedUniverse({
+ characters: [{
+ id: 'c1',
+ name: 'Alice',
+ arcType: 'positive',
+ ghost: 'Lost the vault key.',
+ want: 'Get it back.',
+ sliders: { proactivity: 7, likability: null, competence: null },
+ }],
+ });
+ const node = nodeOf(await buildUniverseGraph('u1'), 'character:c1');
+ expect(node.arcType).toBe('positive');
+ expect(node.framework).toEqual({ ghost: 'Lost the vault key.', want: 'Get it back.' });
+ expect(node.sliders).toEqual({ proactivity: 7 });
+ });
+
+ it('reports an unrated slider set and an unwritten framework as absent, not empty', async () => {
+ seedUniverse({
+ characters: [{ id: 'c1', name: 'Alice', sliders: { proactivity: null, likability: null, competence: null } }],
+ });
+ const node = nodeOf(await buildUniverseGraph('u1'), 'character:c1');
+ expect(node.sliders).toBeNull();
+ expect(node.framework).toBeNull();
+ });
+
+ it('caps the image nodes it emits per entry', async () => {
+ const refs = Array.from({ length: IMAGE_NODES_PER_ENTRY_MAX + 4 }, (_, i) => `ref-${i}.png`);
+ seedUniverse({ characters: [{ id: 'c1', name: 'Alice', imageRefs: refs, primaryImageRef: 'ref-2.png' }] });
+ const graph = await buildUniverseGraph('u1');
+ const images = graph.nodes.filter((n) => n.kind === 'image');
+ expect(images).toHaveLength(IMAGE_NODES_PER_ENTRY_MAX);
+ expect(graph.edges.filter((e) => e.type === 'imageref')).toHaveLength(IMAGE_NODES_PER_ENTRY_MAX);
+ expect(images.find((n) => n.imageRef === 'ref-2.png').primary).toBe(true);
+ });
+});
+
+describe('buildUniverseGraph — authored links', () => {
+ it('keeps a one-directional relationship one-directional', async () => {
+ seedUniverse({
+ characters: [
+ { id: 'c1', name: 'Alice', relationshipLinks: [{ targetCharacterId: 'c2', type: 'rival', description: 'Wants her post.' }] },
+ { id: 'c2', name: 'Bob' },
+ ],
+ });
+ const graph = await buildUniverseGraph('u1');
+ const rels = graph.edges.filter((e) => e.directed);
+ expect(rels).toHaveLength(1);
+ expect(rels[0]).toMatchObject({
+ source: 'character:c1', target: 'character:c2', type: 'rival', label: 'Wants her post.',
+ });
+ });
+
+ it('drops a relationship link whose target is not in this universe', async () => {
+ seedUniverse({
+ characters: [{ id: 'c1', name: 'Alice', relationshipLinks: [{ targetCharacterId: 'gone', type: 'ally' }] }],
+ });
+ expect((await buildUniverseGraph('u1')).edges.filter((e) => e.directed)).toHaveLength(0);
+ });
+
+ it('turns an object attachment into an object ↔ character edge carrying its role', async () => {
+ seedUniverse({
+ characters: [{ id: 'c1', name: 'Alice' }],
+ objects: [{ id: 'o1', name: 'The Key', attachments: [{ characterId: 'c1', role: 'talisman' }] }],
+ });
+ const edge = (await buildUniverseGraph('u1')).edges.find((e) => e.type === 'attachment');
+ expect(edge).toMatchObject({ source: 'object:o1', target: 'character:c1', label: 'talisman' });
+ });
+
+ it('links a composite sheet to the canon its own prompt names', async () => {
+ seedUniverse({
+ characters: [{ id: 'c1', name: 'Alice' }, { id: 'c2', name: 'Bob' }],
+ compositeSheets: [{ id: 'sheet1', label: 'Lineup', kind: 'reference_sheet', prompt: 'Alice standing at the gate', imageRefs: ['s.png'] }],
+ });
+ const graph = await buildUniverseGraph('u1');
+ const links = graph.edges.filter((e) => e.source === 'composite:sheet1');
+ expect(links).toHaveLength(1);
+ expect(links[0].target).toBe('character:c1');
+ });
+});
+
+describe('buildUniverseGraph — timeline', () => {
+ beforeEach(() => {
+ mockSeriesList.push(
+ { id: 's1', name: 'First Arc', universeId: 'u1', createdAt: '2026-01-01T00:00:00Z' },
+ { id: 's2', name: 'Second Arc', universeId: 'u1', createdAt: '2026-02-01T00:00:00Z' },
+ { id: 'other', name: 'Unrelated', universeId: 'u2', createdAt: '2026-01-01T00:00:00Z' },
+ );
+ // Deliberately out of order so the builder has to sort by issue number.
+ mockIssuesBySeries.set('s1', [{ id: 'i2', number: 2, title: 'Two' }, { id: 'i1', number: 1, title: 'One' }]);
+ mockIssuesBySeries.set('s2', [{ id: 'i3', number: 1, title: 'Three' }]);
+ });
+
+ it('numbers issues globally by series order then issue number', async () => {
+ seedUniverse();
+ const graph = await buildUniverseGraph('u1');
+ expect(graph.totalIssues).toBe(3);
+ expect(graph.issues.map((i) => [i.recordId, i.index])).toEqual([['i1', 0], ['i2', 1], ['i3', 2]]);
+ expect(graph.series.map((s) => s.recordId)).toEqual(['s1', 's2']);
+ });
+
+ it('anchors a series node to the index of its own first issue', async () => {
+ seedUniverse();
+ const graph = await buildUniverseGraph('u1');
+ expect(nodeOf(graph, 'series:s1').firstIssue).toBe(0);
+ expect(nodeOf(graph, 'series:s2').firstIssue).toBe(2);
+ });
+
+ it('keeps every id in the payload namespaced, so a node id resolves against the node list', async () => {
+ seedUniverse();
+ const graph = await buildUniverseGraph('u1');
+ const ids = new Set(graph.nodes.map((n) => n.id));
+ // The issue NODE's seriesId and the issues[] row's seriesId must name the
+ // same id space — a raw record id under either would silently miss lookups.
+ for (const node of graph.nodes.filter((n) => n.kind === 'issue')) {
+ expect(ids.has(node.seriesId)).toBe(true);
+ }
+ for (const issue of graph.issues) {
+ expect(ids.has(issue.id)).toBe(true);
+ expect(ids.has(issue.seriesId)).toBe(true);
+ }
+ });
+
+ it('anchors an entry firstIssue to its earliest appearance and records every appearance', async () => {
+ seedUniverse({ characters: [{ id: 'c1', name: 'Alice' }] });
+ mockUsage = {
+ ...emptyUsage(),
+ characters: { c1: [{ seriesId: 's2', seriesName: 'Second Arc', issueIds: ['i3'], issueCount: 1 }, { seriesId: 's1', seriesName: 'First Arc', issueIds: ['i2'], issueCount: 1 }] },
+ };
+ const graph = await buildUniverseGraph('u1');
+ expect(nodeOf(graph, 'character:c1').firstIssue).toBe(1);
+ expect(graph.appear['character:c1']).toEqual([1, 2]);
+ expect(graph.edges.filter((e) => e.type === 'appearance').map((e) => e.target))
+ .toEqual(['issue:i2', 'issue:i3']);
+ expect(graph.edges.filter((e) => e.type === 'membership' && e.source === 'character:c1'))
+ .toHaveLength(2);
+ });
+
+ it('gives an entry with no appearances firstIssue 0 so it is never hidden by the scrubber', async () => {
+ seedUniverse({ places: [{ id: 'p1', name: 'The Vault' }] });
+ expect(nodeOf(await buildUniverseGraph('u1'), 'place:p1').firstIssue).toBe(0);
+ });
+});
+
+describe('buildUniverseGraph — errors', () => {
+ it('maps a missing universe to a 404', async () => {
+ await expect(buildUniverseGraph('nope')).rejects.toMatchObject({ status: 404, code: 'UNIVERSE_NOT_FOUND' });
+ });
+});