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 && ( + + )} +
+
+ + + +
+
+ + {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 }) => ( +
  • + +
  • +); + +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) => ( + + ))} +
    + {shown.length === 0 ? ( +

    Nothing outstanding in this category.

    + ) : ( + + )} + + ); +} + +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
    +
    +
    +
    + +
    + + ); +} + +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} + + ))} +
    +
    + +
    + +
    + + {node.kind === 'character' && ( + + )} +
    + + {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) => ( +
    1. + + + {i < all.length - 1 && } + + + {row.label} + {row.authored ? 'authored' : 'not authored'} + +
    2. + ))} +
    + ) : ( +
    + No evolution lens authored yet. +
    + )} +
    + +
    {relationships.length}}> + {relationships.length ? ( + + ) : ( +
    + No typed relationships. +
    + )} +
    + + )} + +
    {connected.length}}> + {connected.length ? ( + + ) : ( +

    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 ( + + ); +} 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 ( +
    +
    + +
    + {current ? current.name : 'Whole universe'} + · {current ? `${seriesName} · ${entriesIntroduced} entries so far` : seriesName} +
    +
    + + + 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 ( + + ); + })} +
    + + { + 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 ( +
    +
    + + + 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) => ( + + ))} +
    + +
    + {GRAPH_KIND_ORDER.filter((k) => kindCounts[k]).map((k) => { + const on = kinds.has(k); + return ( + + ); + })} +
    + +
    + +
    + {GRAPH_EDGE_GROUPS.map((g) => { + const on = groups.has(g.id); + const color = groupColor(g.id); + return ( + + ); + })} +
    + +
    + + + + +
    + ); +} 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) => ( + + ))} +
    +
    +); + +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 +

    + +
    +
    +
    +
    +
    Layout
    +
    + {POSTER_LAYOUTS.map((option) => ( + + ))} +
    +
    + + {layout === 'dossier' && ( +
    + + +
    + )} + + + + + + +

    + Rendered at 2× for print. Entries without a render show initials. +

    + + +
    +
    + +
    +
    +
    + ); +} 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' }); + }); +});