prism-viz-engine 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,306 @@
1
+ /**
2
+ * Layer 04 — the interactive shell. The Waku mold, wired.
3
+ *
4
+ * waku-agent is on the shelf as "the model": a click-any-box LIVE architecture diagram
5
+ * where every box is a real module file. That is what this shell is — palette left,
6
+ * canvas centre, inspector right, and every box opens its real source.
7
+ *
8
+ * The renderer is NOT a user toggle. Layer 02's own rule is "the right canvas per
9
+ * shape": an infra topology routes to isometric, a process chain routes to the node
10
+ * graph. `route.ts` decides from the IR's declared `diagram_type`, and the decision is
11
+ * shown in the bar so the routing is legible rather than magic. An override exists for
12
+ * when the shape is genuinely ambiguous — it never silently overrules the declaration.
13
+ */
14
+
15
+ import { useCallback, useEffect, useMemo, useState } from "react"
16
+ import {
17
+ Canvas,
18
+ applyNodeChanges,
19
+ applyEdgeChanges,
20
+ addEdge,
21
+ LANE_H,
22
+ type Node,
23
+ type Edge,
24
+ type NodeChange,
25
+ type EdgeChange,
26
+ type Connection,
27
+ } from "../02-render/Canvas"
28
+ import { IsometricView } from "../02-render/IsometricView"
29
+ import { routeCanvas, resolveRenderer, IMPLEMENTED, type RendererId } from "../02-render/route"
30
+ import type { ComponentNodeData } from "../02-render/ComponentNode"
31
+ import { Palette } from "./Palette"
32
+ import { Inspector } from "./Inspector"
33
+ import { Gallery } from "./Gallery"
34
+ import {
35
+ type JSONCanvas,
36
+ type CanvasNode,
37
+ type GriotNodeMeta,
38
+ serialize,
39
+ validate,
40
+ explainViolations,
41
+ emptyCanvas,
42
+ } from "../../core/json-canvas"
43
+ import { ALL_SLOTS, type LayerSlot } from "../../core/layer-roles"
44
+ import { drive, type MountOptions, type VizHost } from "../../core/mount"
45
+ import { motionCssVars, type MotionMode } from "../../core/motion"
46
+
47
+ export interface VizSource {
48
+ id: string
49
+ label: string
50
+ note?: string
51
+ canvas: JSONCanvas
52
+ /** archify's declared diagram_type, when the source came from an IR. */
53
+ diagramType?: string
54
+ /** Sources with a palette are composable; example diagrams are view-only. */
55
+ composable?: boolean
56
+ }
57
+
58
+ export interface ShellProps {
59
+ host: VizHost
60
+ library: CanvasNode[]
61
+ sources?: VizSource[]
62
+ onChange?: MountOptions["onChange"]
63
+ reveal?: MountOptions["reveal"]
64
+ subscribeTrace?: MountOptions["subscribeTrace"]
65
+ registerHandle?: (h: { load: (c: JSONCanvas) => void; snapshot: () => JSONCanvas }) => void
66
+ }
67
+
68
+ const RENDERER_LABEL: Record<RendererId, string> = {
69
+ isometric: "isometric",
70
+ nodegraph: "node-graph",
71
+ forcegraph: "prism-graph 3D",
72
+ excalidraw: "excalidraw",
73
+ }
74
+
75
+ const toFlowNode = (
76
+ n: CanvasNode,
77
+ reveal: ShellProps["reveal"],
78
+ tracing: boolean
79
+ ): Node<ComponentNodeData> => ({
80
+ id: n.id,
81
+ type: "griot",
82
+ position: { x: n.x, y: n.y },
83
+ data: { label: (n as any).label ?? n.id, griot: n.griot as GriotNodeMeta, reveal, tracing },
84
+ })
85
+
86
+ export function Shell({ host, library, sources = [], onChange, reveal, subscribeTrace, registerHandle }: ShellProps) {
87
+ const [sourceId, setSourceId] = useState<string>(sources[0]?.id ?? "")
88
+ const [nodes, setNodes] = useState<Node<ComponentNodeData>[]>([])
89
+ const [edges, setEdges] = useState<Edge[]>([])
90
+ const [selected, setSelected] = useState<string | null>(null)
91
+ const [tracingIds, setTracingIds] = useState<Set<string>>(new Set())
92
+ const [override, setOverride] = useState<RendererId | null>(null)
93
+ /**
94
+ * `reveal`, not `none`.
95
+ *
96
+ * animation.md's default is `none`, and that is right for a PUBLISHED figure — it
97
+ * protects a reader who never asked for motion. This engine is not publishing; it is an
98
+ * instrument for comparing how systems move. Defaulting to silence means comparing still
99
+ * frames of things whose value IS the movement, which is most of what Lanshu is.
100
+ *
101
+ * `reveal` is animation.md's own sanctioned autoplay — one deterministic run that ends
102
+ * complete, never restarting on viewport re-entry. So this honours the law and still
103
+ * lets you see the thing. prefers-reduced-motion still wins over all of it.
104
+ */
105
+ const [motionMode, setMotionMode] = useState<MotionMode>("reveal")
106
+ const [status, setStatus] = useState("")
107
+ // The reference gallery is a MODE, not a source: those files are rendered HTML, not IR,
108
+ // so they can never be a canvas. Making them a fake source would be a category error.
109
+ const [gallery, setGallery] = useState(false)
110
+
111
+ const source = useMemo(() => sources.find((s) => s.id === sourceId) ?? sources[0], [sources, sourceId])
112
+ const composable = source?.composable ?? false
113
+
114
+ const load = useCallback(
115
+ (c: JSONCanvas) => {
116
+ setNodes(c.nodes.filter((n) => n.griot).map((n) => toFlowNode(n, reveal, false)))
117
+ setEdges(c.edges.map((e) => ({ id: e.id, source: e.fromNode, target: e.toNode, label: e.label })))
118
+ },
119
+ [reveal]
120
+ )
121
+
122
+ // A composable source starts empty (palette is the shelf); an example opens rendered.
123
+ useEffect(() => {
124
+ if (!source) return
125
+ setSelected(null)
126
+ setOverride(null)
127
+ load(source.composable ? emptyCanvas() : source.canvas)
128
+ }, [source, load])
129
+
130
+ const snapshot = useCallback((): JSONCanvas => {
131
+ const pool = source?.composable ? library : (source?.canvas.nodes ?? [])
132
+ return {
133
+ nodes: nodes.map((n) => {
134
+ const base = pool.find((l) => l.id === n.id)
135
+ return {
136
+ ...(base ?? ({ id: n.id, type: "text", text: n.data.label } as any)),
137
+ id: n.id,
138
+ x: Math.round(n.position.x),
139
+ y: Math.round(n.position.y),
140
+ width: base?.width ?? 232,
141
+ height: base?.height ?? 96,
142
+ griot: n.data.griot,
143
+ label: n.data.label,
144
+ } as CanvasNode
145
+ }),
146
+ edges: edges.map((e) => ({
147
+ id: e.id,
148
+ fromNode: e.source,
149
+ fromSide: "bottom" as const,
150
+ toNode: e.target,
151
+ toSide: "top" as const,
152
+ label: typeof e.label === "string" ? e.label : undefined,
153
+ })),
154
+ }
155
+ }, [nodes, edges, library, source])
156
+
157
+ useEffect(() => { registerHandle?.({ load, snapshot }) }, [registerHandle, load, snapshot])
158
+ useEffect(() => { onChange?.(snapshot()) }, [nodes, edges]) // eslint-disable-line react-hooks/exhaustive-deps
159
+
160
+ useEffect(() => {
161
+ if (!subscribeTrace) return
162
+ return subscribeTrace((nodeId) => {
163
+ setTracingIds((p) => new Set(p).add(nodeId))
164
+ setTimeout(() => setTracingIds((p) => { const n = new Set(p); n.delete(nodeId); return n }), 1400)
165
+ })
166
+ }, [subscribeTrace])
167
+
168
+ useEffect(() => {
169
+ setNodes((ns) => ns.map((n) => (n.data.tracing === tracingIds.has(n.id) ? n : { ...n, data: { ...n.data, tracing: tracingIds.has(n.id) } })))
170
+ }, [tracingIds])
171
+
172
+ const onNodesChange = useCallback((c: NodeChange[]) => setNodes((ns) => applyNodeChanges(c, ns) as Node<ComponentNodeData>[]), [])
173
+ const onEdgesChange = useCallback((c: EdgeChange[]) => setEdges((es) => applyEdgeChanges(c, es)), [])
174
+ const onConnect = useCallback((c: Connection) => setEdges((es) => addEdge({ ...c, id: `${c.source}->${c.target}` }, es)), [])
175
+
176
+ const onReroute = useCallback((nodeId: string, layer: LayerSlot) => {
177
+ setNodes((ns) => ns.map((n) => (n.id === nodeId ? { ...n, data: { ...n.data, griot: { ...n.data.griot, layer } } } : n)))
178
+ setStatus(`re-routed → ${layer}`)
179
+ }, [])
180
+
181
+ const onDropComponent = useCallback((payload: string, position: { x: number; y: number }, layer: LayerSlot) => {
182
+ let node: CanvasNode
183
+ try { node = JSON.parse(payload) } catch { return }
184
+ setNodes((ns) => {
185
+ if (ns.some((n) => n.id === node.id)) return ns
186
+ const griot = { ...(node.griot as GriotNodeMeta), layer }
187
+ return [...ns, { ...toFlowNode({ ...node, griot } as CanvasNode, reveal, false), position }]
188
+ })
189
+ setStatus(`placed ${(node as any).label} → ${layer}`)
190
+ }, [reveal])
191
+
192
+ const exportCanvas = useCallback(() => {
193
+ const c = snapshot()
194
+ const problems = validate(c)
195
+ if (problems.length) { setStatus(`${problems.length} violation(s) — nothing written`); console.warn(explainViolations(problems)); return }
196
+ const text = serialize(c)
197
+ try {
198
+ const url = URL.createObjectURL(new Blob([text], { type: "application/json" }))
199
+ const a = document.createElement("a"); a.href = url; a.download = `${source?.id ?? "prism-viz"}.canvas.json`; a.click()
200
+ setTimeout(() => URL.revokeObjectURL(url), 1500)
201
+ setStatus(`exported ${c.nodes.length} nodes · ${c.edges.length} edges — validated clean`)
202
+ } catch { void navigator.clipboard?.writeText(text); setStatus("export copied to clipboard") }
203
+ }, [snapshot, source])
204
+
205
+ const wake = useCallback(async () => {
206
+ const { rung, ok } = await drive("commit_canvas", { source: source?.id, nodes: nodes.length })
207
+ setStatus(ok ? `woke the agent via ${rung}` : "no drive rung on this host")
208
+ }, [nodes.length, source])
209
+
210
+ // ── the routing decision ───────────────────────────────────────────────────
211
+ const live = useMemo(
212
+ () => (composable ? snapshot() : (source?.canvas ?? emptyCanvas())),
213
+ [composable, snapshot, source]
214
+ )
215
+ const decision = useMemo(() => routeCanvas(live, source?.diagramType), [live, source])
216
+ const resolved = useMemo(() => resolveRenderer(decision), [decision])
217
+ const renderer: RendererId = override ?? resolved.renderer
218
+
219
+ const placed = useMemo(() => new Set(nodes.map((n) => n.id)), [nodes])
220
+ const selectedNode = useMemo(() => nodes.find((n) => n.id === selected) ?? null, [nodes, selected])
221
+ const filled = useMemo(() => ALL_SLOTS.filter((s) => nodes.some((n) => n.data.griot.layer === s)).length, [nodes])
222
+
223
+ return (
224
+ <div className="vz-shell" style={{ ["--lane-h" as string]: `${LANE_H}px`, ...motionCssVars() }}>
225
+ <header className="vz-bar">
226
+ <span className="vz-brand">prism-viz-engine</span>
227
+ <span className="vz-host">{host}</span>
228
+
229
+ <select className="vz-picker" value={source?.id ?? ""} onChange={(e) => setSourceId(e.target.value)}>
230
+ {sources.map((s) => (
231
+ <option key={s.id} value={s.id}>{s.label}</option>
232
+ ))}
233
+ </select>
234
+
235
+ {/* the routing decision, made legible */}
236
+ <span className="vz-route" title={decision.because}>
237
+ <b>{RENDERER_LABEL[renderer]}</b>
238
+ <em>{decision.empty ? "nothing placed" : decision.declared ? `declared ${decision.shape}` : `read as ${decision.shape}`}</em>
239
+ {resolved.fellBack && !override && (
240
+ <i className="vz-fellback">{RENDERER_LABEL[decision.renderer]} not built — using {RENDERER_LABEL[resolved.renderer]}</i>
241
+ )}
242
+ </span>
243
+
244
+ <span className="vz-stat">
245
+ {composable ? `${nodes.length} placed · ${filled}/11 layers · ` : ""}
246
+ {live.nodes.length} nodes · {live.edges.length} edges
247
+ </span>
248
+
249
+ <span className="vz-spacer" />
250
+ {status && <span className="vz-status">{status}</span>}
251
+
252
+ <select className="vz-picker" value={motionMode} onChange={(e) => setMotionMode(e.target.value as MotionMode)} title="diagram-design animation.md — one mode per figure">
253
+ <option value="none">motion: none</option>
254
+ <option value="reveal">reveal</option>
255
+ <option value="step">step</option>
256
+ <option value="loop">loop</option>
257
+ </select>
258
+ <select className="vz-picker" value={override ?? ""} onChange={(e) => setOverride((e.target.value || null) as RendererId | null)}>
259
+ <option value="">auto</option>
260
+ {(Object.keys(IMPLEMENTED) as RendererId[]).filter((r) => IMPLEMENTED[r]).map((r) => (
261
+ <option key={r} value={r}>{RENDERER_LABEL[r]}</option>
262
+ ))}
263
+ </select>
264
+
265
+ <button className={gallery ? "on" : ""} onClick={() => setGallery((g) => !g)}>
266
+ {gallery ? "← canvas" : "Reference"}
267
+ </button>
268
+ <button onClick={wake}>Wake agent</button>
269
+ <button className="vz-pri" onClick={exportCanvas}>Export .canvas.json</button>
270
+ </header>
271
+
272
+ <div className="vz-body">
273
+ {gallery ? (
274
+ <Gallery sidecar={(import.meta as any).env?.VITE_SIDECAR ?? "http://127.0.0.1:5178"} />
275
+ ) : (
276
+ <>
277
+ {composable && <Palette library={library} placed={placed} onReveal={reveal} />}
278
+
279
+ {renderer === "isometric" ? (
280
+ <IsometricView
281
+ canvas={live}
282
+ motionMode={motionMode}
283
+ selectedId={selected}
284
+ onSelect={setSelected}
285
+ onReveal={reveal}
286
+ />
287
+ ) : (
288
+ <Canvas
289
+ nodes={nodes}
290
+ edges={edges}
291
+ onNodesChange={onNodesChange}
292
+ onEdgesChange={onEdgesChange}
293
+ onConnect={onConnect}
294
+ onReroute={onReroute}
295
+ onDropComponent={onDropComponent}
296
+ onSelect={setSelected}
297
+ />
298
+ )}
299
+
300
+ <Inspector node={selectedNode} reveal={reveal} onRelayer={onReroute} />
301
+ </>
302
+ )}
303
+ </div>
304
+ </div>
305
+ )
306
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * The React half of the mount seam, kept behind a dynamic import so a host that only
3
+ * wants the format helpers never pays for React or xyflow.
4
+ */
5
+
6
+ import { createRoot, type Root } from "react-dom/client"
7
+ import { StrictMode } from "react"
8
+ import { Shell } from "./Shell"
9
+ import type { MountOptions, VizEngineHandle } from "../../core/mount"
10
+ import { emptyCanvas, type JSONCanvas, type CanvasNode } from "../../core/json-canvas"
11
+ import "../../styles.css"
12
+
13
+ export function mountReact(opts: MountOptions & { host: NonNullable<MountOptions["host"]> }): VizEngineHandle {
14
+ const root: Root = createRoot(opts.element)
15
+ const initial = opts.canvas ?? emptyCanvas()
16
+
17
+ // The palette shows everything harvested; the canvas shows what has been placed.
18
+ // They are the same objects — the library is the shelf, not a second copy.
19
+ const library: CanvasNode[] = initial.nodes.filter((n) => n.griot)
20
+
21
+ let handle: { load: (c: JSONCanvas) => void; snapshot: () => JSONCanvas } | null = null
22
+
23
+ root.render(
24
+ <StrictMode>
25
+ <Shell
26
+ host={opts.host}
27
+ library={library}
28
+ sources={(opts.sources as any) ?? []}
29
+ onChange={opts.onChange}
30
+ reveal={opts.reveal}
31
+ subscribeTrace={opts.subscribeTrace}
32
+ registerHandle={(h) => (handle = h)}
33
+ />
34
+ </StrictMode>
35
+ )
36
+
37
+ return {
38
+ load: (c) => handle?.load(c),
39
+ snapshot: () => handle?.snapshot() ?? emptyCanvas(),
40
+ destroy: () => root.unmount(),
41
+ }
42
+ }
package/src/main.tsx ADDED
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Standalone entry — host #1 of three.
3
+ *
4
+ * Builds the source list the engine opens with. Two kinds, deliberately distinct:
5
+ *
6
+ * composable the Djeli UX/UI harvest — a palette you compose from. ONE source among
7
+ * several, not the engine's identity.
8
+ * examples archify's own IR files, straight out of the harvested repo. The engine
9
+ * demonstrates itself on the cluster's real data, so a broken adapter
10
+ * shows up as a broken example instead of a green fixture.
11
+ *
12
+ * Nothing is bundled. If the sidecar is down the palette stays empty and says so.
13
+ */
14
+
15
+ import { mountVizEngine } from "./core/mount"
16
+ import { adaptHarvest, type HarvestedUxNode, type HarvestedCodeRow } from "./layers/03-substrate/harvest-adapter"
17
+ import { archifyToCanvas, type ArchifyIR } from "./layers/01-generate/archify-ir"
18
+ import { ALL_SLOTS } from "./core/layer-roles"
19
+ import { emptyCanvas, merge } from "./core/json-canvas"
20
+ import type { VizSource } from "./layers/04-shell/Shell"
21
+
22
+ const SIDECAR = import.meta.env.VITE_SIDECAR ?? "http://127.0.0.1:5178"
23
+
24
+ async function getJSON(path: string) {
25
+ const res = await fetch(`${SIDECAR}${path}`)
26
+ if (!res.ok) throw new Error(`${path} -> ${res.status}`)
27
+ return res.json()
28
+ }
29
+
30
+ async function boot() {
31
+ const el = document.getElementById("root")!
32
+ const sources: VizSource[] = []
33
+ const notes: string[] = []
34
+
35
+ // ── the harvest (composable) ────────────────────────────────────────────────
36
+ let harvest = emptyCanvas()
37
+ try {
38
+ const b = await getJSON("/api/harvest")
39
+ const ux: HarvestedUxNode[] = b.ux ?? []
40
+ const code: HarvestedCodeRow[] = b.code ?? []
41
+ if (ux.length) {
42
+ harvest = adaptHarvest(ux, code, { slots: ALL_SLOTS })
43
+ sources.push({
44
+ id: "djeli-harvest",
45
+ label: `Djeli UX/UI harvest (${ux.length})`,
46
+ note: "composable — drag from the palette",
47
+ canvas: harvest,
48
+ composable: true,
49
+ })
50
+ }
51
+ if (b.notes?.length) notes.push(...b.notes)
52
+ } catch (e) {
53
+ notes.push(`harvest unavailable: ${(e as Error).message}`)
54
+ }
55
+
56
+ // ── archify's own examples (view-only) ──────────────────────────────────────
57
+ try {
58
+ const b = await getJSON("/api/examples")
59
+ for (const ex of b.examples ?? []) {
60
+ const ir = ex.ir as ArchifyIR
61
+ sources.push({
62
+ id: ex.id,
63
+ label: `${ex.title} · ${ex.diagramType}`,
64
+ note: `${ex.components} components · ${ex.connections} connections · ${ex.boundaries} boundaries`,
65
+ canvas: archifyToCanvas(ir, { harvestedBy: "archify-ir", harvestedAt: "2026-09-11" }),
66
+ diagramType: ex.diagramType,
67
+ composable: false,
68
+ })
69
+ }
70
+ if (b.note) notes.push(b.note)
71
+ } catch (e) {
72
+ notes.push(`examples unavailable: ${(e as Error).message}`)
73
+ }
74
+
75
+ const handle = await mountVizEngine({
76
+ element: el,
77
+ host: "standalone",
78
+ canvas: harvest, // the palette library comes from here
79
+ sources,
80
+ reveal: async (origin) => {
81
+ await fetch(`${SIDECAR}/api/reveal`, {
82
+ method: "POST",
83
+ headers: { "content-type": "application/json" },
84
+ body: JSON.stringify(origin),
85
+ })
86
+ },
87
+ onChange: (c) => {
88
+ try { localStorage.setItem("prism-viz-engine:canvas", JSON.stringify(c)) } catch {}
89
+ },
90
+ })
91
+
92
+ if (!sources.length) {
93
+ const b = document.createElement("div")
94
+ b.style.cssText =
95
+ "position:fixed;bottom:14px;left:50%;transform:translateX(-50%);z-index:99;max-width:760px;" +
96
+ "background:#1a1206;border:1px solid #f59e0b;color:#f6d79a;border-radius:10px;padding:10px 14px;" +
97
+ "font:12px/1.5 Inter,system-ui,sans-serif"
98
+ b.textContent =
99
+ `No sources loaded. Start the sidecar: npm run dev:reveal. ` +
100
+ `Nothing is being invented to fill the gap. ${notes.join(" · ")}`
101
+ document.body.appendChild(b)
102
+ }
103
+
104
+ void merge // kept exported-in-use for host code that merges canvases
105
+ ;(window as any).__vizEngine = handle
106
+ }
107
+
108
+ void boot()