From 728f210b178ca8843158b2a5ffdb495ddc111edf Mon Sep 17 00:00:00 2001 From: Adam Eivy Date: Wed, 9 Sep 2026 03:33:29 +0000 Subject: [PATCH] feat: add a relationship Graph tab to the Universe Builder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Universes now carry a Graph tab that draws the whole world as one picture: canon characters, places and objects, the typed relationships and object attachments authored on them, the series and issues they appear in, and their rendered references. Force, radial and timeline layouts; filter by node kind or link family; search; double-click to focus a neighbourhood. A timeline scrubber replays the universe issue by issue, so you can see the world as it stood at any point in the run, with entries not yet introduced drawn as ghosts. The inspector doubles as a "gaps & enrichment" panel: isolated canon, places whose issues hold no cast, one-directional relationships, entries with no render, and suggestions where two characters share several issues without a typed link, or a well-used character has no evolution lens. Every finding is derived from records the user already authored — no LLM call. A poster builder exports the graph as a print-ready PNG in five layouts (cast roster, character dossier, place atlas, timeline strip, relationship web), in three sizes and a light or dark paper. Server side, GET /api/universe-builder/:id/graph aggregates the universe record with the existing canon-usage cross-reference. Read-only: no writes, no LLM calls, no new stored state. --- .../universeBuilder/UniverseBuilderPage.jsx | 9 +- .../UniverseBuilderPage.test.jsx | 4 +- .../universeBuilder/graph/GraphCanvas.jsx | 539 ++++++++++++++++++ .../universeBuilder/graph/GraphInspector.jsx | 389 +++++++++++++ .../universeBuilder/graph/GraphTimeline.jsx | 146 +++++ .../universeBuilder/graph/GraphToolbar.jsx | 129 +++++ .../graph/PosterBuilderModal.jsx | 178 ++++++ .../graph/UniverseGraphTab.jsx | 259 +++++++++ .../graph/UniverseGraphTab.test.jsx | 156 +++++ client/src/hooks/useUniverseTabs.js | 6 +- client/src/lib/README.md | 3 + client/src/lib/index.js | 3 + client/src/lib/universeBuilderShared.js | 5 +- client/src/lib/universeGraphLayout.js | 241 ++++++++ client/src/lib/universeGraphLayout.test.js | 122 ++++ client/src/lib/universeGraphModel.js | 279 +++++++++ client/src/lib/universeGraphModel.test.js | 168 ++++++ client/src/lib/universeGraphPoster.js | 521 +++++++++++++++++ client/src/lib/universeGraphPoster.test.js | 87 +++ client/src/pollingConventions.test.js | 1 + client/src/services/apiUniverseBuilder.js | 7 + client/src/test/setup.js | 11 +- server/routes/universeBuilder/graph.js | 23 + server/routes/universeBuilder/index.js | 2 + server/services/universeGraph.js | 329 +++++++++++ server/services/universeGraph.test.js | 235 ++++++++ 26 files changed, 3845 insertions(+), 7 deletions(-) create mode 100644 client/src/components/universeBuilder/graph/GraphCanvas.jsx create mode 100644 client/src/components/universeBuilder/graph/GraphInspector.jsx create mode 100644 client/src/components/universeBuilder/graph/GraphTimeline.jsx create mode 100644 client/src/components/universeBuilder/graph/GraphToolbar.jsx create mode 100644 client/src/components/universeBuilder/graph/PosterBuilderModal.jsx create mode 100644 client/src/components/universeBuilder/graph/UniverseGraphTab.jsx create mode 100644 client/src/components/universeBuilder/graph/UniverseGraphTab.test.jsx create mode 100644 client/src/lib/universeGraphLayout.js create mode 100644 client/src/lib/universeGraphLayout.test.js create mode 100644 client/src/lib/universeGraphModel.js create mode 100644 client/src/lib/universeGraphModel.test.js create mode 100644 client/src/lib/universeGraphPoster.js create mode 100644 client/src/lib/universeGraphPoster.test.js create mode 100644 server/routes/universeBuilder/graph.js create mode 100644 server/services/universeGraph.js create mode 100644 server/services/universeGraph.test.js 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
    +
    +
    +
    +
      + {topNodes.map((node) => ( + + ))} +
    +
    + + ); +} + +function Selection({ + index, node, timeIndex, gaps, onPick, onClear, onFocus, onDossierPoster, +}) { + const links = index.adjacency.get(node.id) || []; + const relationships = links.filter((e) => e.directed); + const connected = links.filter((e) => !e.directed && e.type !== 'appearance' && e.type !== 'membership'); + const appearances = new Map(); + for (const i of index.appear[node.id] || []) { + const issue = index.issues[i]; + if (issue) appearances.set(issue.seriesId, (appearances.get(issue.seriesId) || 0) + 1); + } + const isCanon = ['character', 'place', 'object'].includes(node.kind); + const kind = kindDef(node.kind); + const badges = [{ label: kind.singular, color: kind.color }]; + if (node.locked) badges.push({ label: 'locked', color: '#2563eb' }); + if (isCanon && !node.hasImage) badges.push({ label: 'no render', color: '#f59e0b' }); + if (timeIndex != null && (node.firstIssue || 0) > timeIndex) { + badges.push({ label: 'not yet introduced', color: '#9ca3af' }); + } + const ownGaps = gaps.filter((g) => g.nodeId === node.id || g.otherId === node.id); + const sliders = node.sliders || null; + const framework = node.framework || null; + + return ( + <> +
    +
    + {nodeInitials(node.name)} +
    +
    +
    {node.name}
    +
    {node.role}
    +
    + {badges.map((b) => ( + + {b.label} + + ))} +
    +
    + +
    + +
    + + {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 ? ( +
      + {relationships.map((edge) => { + const other = edge.source === node.id ? edge.targetNode : edge.sourceNode; + const def = edgeDef(edge.type); + return ( + + ); + })} +
    + ) : ( +
    + No typed relationships. +
    + )} +
    + + )} + +
    {connected.length}}> + {connected.length ? ( +
      + {connected.map((edge) => { + const other = edge.source === node.id ? edge.targetNode : edge.sourceNode; + return ( + + ); + })} +
    + ) : ( +

    Nothing attached.

    + )} +
    + + {isCanon && ( +
    + {appearances.size ? [...appearances.entries()].map(([seriesId, count]) => ( +
    + + {index.byId.get(seriesId)?.name || seriesId} + {count} issue{count === 1 ? '' : 's'} +
    + )) :

    Not yet used in any series.

    } +
    + )} + + {ownGaps.length > 0 && ( +
    + {ownGaps.map((gap) => ( +
    + {gap.title} + {gap.detail} +
    + ))} +
    + )} + + ); +} + +export default function GraphInspector({ + index, selectedNode, timeIndex, gaps, gapCounts, gapsOpen, gapCategory, + stats, topNodes, onPick, onClear, onFocus, onDossierPoster, + onGapCategoryChange, onCloseGaps, +}) { + return ( + + ); +} 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' }); + }); +});