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.
- package/README.md +104 -0
- package/package.json +82 -0
- package/scripts/emit-screen.mjs +192 -0
- package/src/core/json-canvas.ts +301 -0
- package/src/core/layer-roles.ts +77 -0
- package/src/core/motion.ts +390 -0
- package/src/core/mount.ts +130 -0
- package/src/index.ts +55 -0
- package/src/layers/01-generate/archify-ir.ts +234 -0
- package/src/layers/02-render/Canvas.tsx +211 -0
- package/src/layers/02-render/ComponentNode.tsx +103 -0
- package/src/layers/02-render/IsometricView.tsx +288 -0
- package/src/layers/02-render/icons.ts +199 -0
- package/src/layers/02-render/isometric.ts +149 -0
- package/src/layers/02-render/route.ts +297 -0
- package/src/layers/03-substrate/harvest-adapter.ts +161 -0
- package/src/layers/04-shell/Gallery.tsx +246 -0
- package/src/layers/04-shell/Inspector.tsx +153 -0
- package/src/layers/04-shell/Palette.tsx +149 -0
- package/src/layers/04-shell/Shell.tsx +306 -0
- package/src/layers/04-shell/mount-react.tsx +42 -0
- package/src/main.tsx +108 -0
- package/src/styles.css +410 -0
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Layer 01 — archify IR → JSON Canvas.
|
|
3
|
+
*
|
|
4
|
+
* GROUNDED, not recalled. Every field below was read out of the real schemas in
|
|
5
|
+
* `GriotSandbox/viz-generate/archify/archify/schemas/` on 2026-09-11. Full harvest:
|
|
6
|
+
* `.prism/shared/research/2026-09-11-archify.md`.
|
|
7
|
+
*
|
|
8
|
+
* WHY ARCHIFY IS THE GRAFT AND LANSHU WAS NOT
|
|
9
|
+
* -------------------------------------------
|
|
10
|
+
* Both were shelved as layer-01 generators. Only one has an IR.
|
|
11
|
+
*
|
|
12
|
+
* Lanshu — no node/edge graph at all. A content-slot form for one hardcoded picture;
|
|
13
|
+
* every coordinate is a source literal, arrows carry startBinding = None.
|
|
14
|
+
* It serialises ink, not a model. (research/2026-09-11-lanshu.md)
|
|
15
|
+
* archify — `package.json:6` calls the package "JSON-IR diagram renderers". Five JSON
|
|
16
|
+
* Schema draft-2020-12 documents; NL never reaches a renderer, the model
|
|
17
|
+
* reads SKILL.md and emits IR. There is no parser and no prompt engine.
|
|
18
|
+
*
|
|
19
|
+
* And the IR is already positional, which is the part that makes this nearly free:
|
|
20
|
+
*
|
|
21
|
+
* archify JSON Canvas
|
|
22
|
+
* ─────────────────────────────────────────────── ──────────────────────────────
|
|
23
|
+
* component.pos [x, y] (:117 -> $defs/point) node.x / node.y
|
|
24
|
+
* component.size [w, h] (:118-125) node.width / node.height
|
|
25
|
+
* connection.fromSide/toSide (:161-162) edge.fromSide / edge.toSide
|
|
26
|
+
* common $defs.side enum CanvasSide
|
|
27
|
+
* ["left","right","top","bottom"] (:22-24) "top"|"right"|"bottom"|"left"
|
|
28
|
+
*
|
|
29
|
+
* The side enum is the SAME SET. Not similar — the same four strings. So edge anchoring
|
|
30
|
+
* survives the conversion untouched, which is the single thing that usually doesn't.
|
|
31
|
+
*
|
|
32
|
+
* CORRECTIONS carried from the harvest, so nobody re-learns them here:
|
|
33
|
+
* - "single-file, zero-dep" is half wrong: zero RUNTIME deps is real (no `dependencies`
|
|
34
|
+
* key), but the repo is 513 files and ajv is standalone-compiled into committed
|
|
35
|
+
* source (`generated-validators.mjs`, 421 KB). Zero-dep is an artefact of the build.
|
|
36
|
+
* - the top-level arrays are `components` and `connections` — NOT `nodes`/`edges`.
|
|
37
|
+
* - layout is mostly authored, not solved: `grid.mjs:1` says "Not auto-layout — fixed
|
|
38
|
+
* cell math only." Only workflow v2 has a solver, and it solves X only. So when a
|
|
39
|
+
* component gives `row`/`col` instead of `pos`, WE do the cell math here, from the
|
|
40
|
+
* IR's own `layout` block — we do not invent a layout engine.
|
|
41
|
+
* - licence, as a fact: `spdx:MIT`, two holders (tt-a1i 2026; Cocoon AI 2025) — archify
|
|
42
|
+
* is itself derived from `Cocoon-AI/architecture-diagram-generator`.
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
import type { JSONCanvas, CanvasNode, CanvasEdge, CanvasSide } from "../../core/json-canvas"
|
|
46
|
+
|
|
47
|
+
// ── the IR, transcribed from the schemas ───────────────────────────────────────
|
|
48
|
+
/** `common.schema.json:22-24` — identical set to CanvasSide. */
|
|
49
|
+
export type ArchifySide = "left" | "right" | "top" | "bottom"
|
|
50
|
+
/** `common.schema.json $defs.point` — a fixed 2-tuple, `items:false`. */
|
|
51
|
+
export type ArchifyPoint = [number, number]
|
|
52
|
+
|
|
53
|
+
/** `architecture.schema.json:85` — required `id`, `type`, `label`. */
|
|
54
|
+
export interface ArchifyComponent {
|
|
55
|
+
id: string
|
|
56
|
+
type: string
|
|
57
|
+
label: string
|
|
58
|
+
sublabel?: string
|
|
59
|
+
tag?: string
|
|
60
|
+
brand?: string
|
|
61
|
+
sources?: unknown
|
|
62
|
+
row?: number
|
|
63
|
+
col?: number
|
|
64
|
+
pos?: ArchifyPoint
|
|
65
|
+
size?: ArchifyPoint
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** `architecture.schema.json:149` — required `from`, `to`. */
|
|
69
|
+
export interface ArchifyConnection {
|
|
70
|
+
id?: string
|
|
71
|
+
from: string
|
|
72
|
+
to: string
|
|
73
|
+
label?: string
|
|
74
|
+
variant?: string
|
|
75
|
+
fromSide?: ArchifySide
|
|
76
|
+
toSide?: ArchifySide
|
|
77
|
+
route?: "auto" | "straight" | "orthogonal-h" | "orthogonal-v"
|
|
78
|
+
via?: ArchifyPoint[]
|
|
79
|
+
labelAt?: ArchifyPoint
|
|
80
|
+
labelDx?: number
|
|
81
|
+
labelDy?: number
|
|
82
|
+
labelSegment?: number
|
|
83
|
+
width?: number
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Required `kind`, `label`, `wraps` — `wraps` is a list of component ids. */
|
|
87
|
+
export interface ArchifyBoundary {
|
|
88
|
+
kind: string
|
|
89
|
+
label: string
|
|
90
|
+
wraps: string[]
|
|
91
|
+
pad?: number
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Required `mode`. The cell math we apply when a component has no `pos`. */
|
|
95
|
+
export interface ArchifyLayout {
|
|
96
|
+
mode: string
|
|
97
|
+
origin?: ArchifyPoint
|
|
98
|
+
cols?: number
|
|
99
|
+
gapX?: number
|
|
100
|
+
gapY?: number
|
|
101
|
+
cellW?: number
|
|
102
|
+
cellH?: number
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** `architecture.schema.json:7` — required `schema_version`, `diagram_type`, `meta`, `components`. */
|
|
106
|
+
export interface ArchifyIR {
|
|
107
|
+
schema_version: string | number
|
|
108
|
+
diagram_type: string
|
|
109
|
+
meta: Record<string, unknown>
|
|
110
|
+
layout?: ArchifyLayout
|
|
111
|
+
components: ArchifyComponent[]
|
|
112
|
+
boundaries?: ArchifyBoundary[]
|
|
113
|
+
connections?: ArchifyConnection[]
|
|
114
|
+
cards?: unknown
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ── defaults, named so they are obviously ours and not archify's ───────────────
|
|
118
|
+
const FALLBACK = { cellW: 180, cellH: 96, gapX: 40, gapY: 40, origin: [0, 0] as ArchifyPoint }
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* `grid.mjs:1` — "Not auto-layout — fixed cell math only." We reproduce exactly that:
|
|
122
|
+
* row/col times cell plus gap, offset by origin. We do NOT substitute dagre or a force
|
|
123
|
+
* pass, because that would silently move authored diagrams.
|
|
124
|
+
*/
|
|
125
|
+
function placeFromGrid(c: ArchifyComponent, L: ArchifyLayout | undefined) {
|
|
126
|
+
const cellW = L?.cellW ?? FALLBACK.cellW
|
|
127
|
+
const cellH = L?.cellH ?? FALLBACK.cellH
|
|
128
|
+
const gapX = L?.gapX ?? FALLBACK.gapX
|
|
129
|
+
const gapY = L?.gapY ?? FALLBACK.gapY
|
|
130
|
+
const [ox, oy] = L?.origin ?? FALLBACK.origin
|
|
131
|
+
return {
|
|
132
|
+
x: ox + (c.col ?? 0) * (cellW + gapX),
|
|
133
|
+
y: oy + (c.row ?? 0) * (cellH + gapY),
|
|
134
|
+
width: cellW,
|
|
135
|
+
height: cellH,
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export interface AdaptOptions {
|
|
140
|
+
/** Stamped onto every emitted node so provenance survives the conversion. */
|
|
141
|
+
harvestedBy?: string
|
|
142
|
+
harvestedAt?: string
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* The conversion. Positional fields pass through; grid-placed components get the IR's
|
|
147
|
+
* own cell math; boundaries become JSON Canvas `group` nodes sized to their members.
|
|
148
|
+
*
|
|
149
|
+
* Anything archify carries that JSON Canvas has no home for — `route`, `via`, `labelAt`,
|
|
150
|
+
* `variant`, `width`, `sublabel`, `tag`, `brand` — is preserved under `griot.code` /
|
|
151
|
+
* node extras rather than dropped, so a round trip back to archify stays possible.
|
|
152
|
+
*/
|
|
153
|
+
export function archifyToCanvas(ir: ArchifyIR, opts: AdaptOptions = {}): JSONCanvas {
|
|
154
|
+
const nodes: CanvasNode[] = []
|
|
155
|
+
const edges: CanvasEdge[] = []
|
|
156
|
+
const box = new Map<string, { x: number; y: number; width: number; height: number }>()
|
|
157
|
+
|
|
158
|
+
for (const c of ir.components) {
|
|
159
|
+
const geom = c.pos
|
|
160
|
+
? {
|
|
161
|
+
x: c.pos[0],
|
|
162
|
+
y: c.pos[1],
|
|
163
|
+
width: c.size?.[0] ?? FALLBACK.cellW,
|
|
164
|
+
height: c.size?.[1] ?? FALLBACK.cellH,
|
|
165
|
+
}
|
|
166
|
+
: placeFromGrid(c, ir.layout)
|
|
167
|
+
|
|
168
|
+
box.set(c.id, geom)
|
|
169
|
+
const node = {
|
|
170
|
+
id: c.id,
|
|
171
|
+
type: "text",
|
|
172
|
+
text: c.sublabel ? `${c.label}\n${c.sublabel}` : c.label,
|
|
173
|
+
...geom,
|
|
174
|
+
griot: {
|
|
175
|
+
layer: "unplaceable" as const, // routed by a human on the canvas, never guessed here
|
|
176
|
+
provenance: {
|
|
177
|
+
harvestedBy: opts.harvestedBy ?? "archify-ir",
|
|
178
|
+
harvestedAt: opts.harvestedAt ?? new Date().toISOString().slice(0, 10),
|
|
179
|
+
},
|
|
180
|
+
code: { licence: "spdx:MIT", capability: c.type },
|
|
181
|
+
},
|
|
182
|
+
} as unknown as CanvasNode
|
|
183
|
+
// keep the archify-only fields so the round trip is lossless
|
|
184
|
+
;(node as any).archify = { type: c.type, tag: c.tag, brand: c.brand, sublabel: c.sublabel }
|
|
185
|
+
;(node as any).label = c.label
|
|
186
|
+
nodes.push(node)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Boundaries -> group nodes, sized to the bounding box of what they wrap + pad.
|
|
190
|
+
for (const [i, b] of (ir.boundaries ?? []).entries()) {
|
|
191
|
+
const members = b.wraps.map((id) => box.get(id)).filter(Boolean) as Array<{
|
|
192
|
+
x: number
|
|
193
|
+
y: number
|
|
194
|
+
width: number
|
|
195
|
+
height: number
|
|
196
|
+
}>
|
|
197
|
+
if (!members.length) continue // a boundary wrapping nothing is dropped, not faked
|
|
198
|
+
const pad = b.pad ?? 16
|
|
199
|
+
const minX = Math.min(...members.map((m) => m.x)) - pad
|
|
200
|
+
const minY = Math.min(...members.map((m) => m.y)) - pad
|
|
201
|
+
const maxX = Math.max(...members.map((m) => m.x + m.width)) + pad
|
|
202
|
+
const maxY = Math.max(...members.map((m) => m.y + m.height)) + pad
|
|
203
|
+
const group = {
|
|
204
|
+
id: `boundary-${i}-${b.kind}`,
|
|
205
|
+
type: "group",
|
|
206
|
+
label: b.label,
|
|
207
|
+
x: minX,
|
|
208
|
+
y: minY,
|
|
209
|
+
width: maxX - minX,
|
|
210
|
+
height: maxY - minY,
|
|
211
|
+
} as CanvasNode
|
|
212
|
+
// `kind` (region | security-group | …) is archify's own topology declaration. An
|
|
213
|
+
// earlier pass kept only `label` and the router then had to guess from prose — which
|
|
214
|
+
// is how a real deployment diagram scored as an app architecture.
|
|
215
|
+
;(group as any).archify = { kind: b.kind, wraps: b.wraps }
|
|
216
|
+
nodes.unshift(group)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Connections -> edges. fromSide/toSide pass through UNTRANSLATED — same enum.
|
|
220
|
+
for (const [i, c] of (ir.connections ?? []).entries()) {
|
|
221
|
+
const edge: CanvasEdge = {
|
|
222
|
+
id: c.id ?? `${c.from}->${c.to}-${i}`,
|
|
223
|
+
fromNode: c.from,
|
|
224
|
+
toNode: c.to,
|
|
225
|
+
...(c.fromSide ? { fromSide: c.fromSide as CanvasSide } : {}),
|
|
226
|
+
...(c.toSide ? { toSide: c.toSide as CanvasSide } : {}),
|
|
227
|
+
...(c.label ? { label: c.label } : {}),
|
|
228
|
+
}
|
|
229
|
+
;(edge as any).archify = { route: c.route, via: c.via, variant: c.variant, width: c.width }
|
|
230
|
+
edges.push(edge)
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return { nodes, edges }
|
|
234
|
+
}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Layer 02 — the canvas. xyflow, because the decision was already made.
|
|
3
|
+
*
|
|
4
|
+
* The djeli-uxui-harvest stage contract, decision 4: "CANVAS is xyflow (layer 02)
|
|
5
|
+
* because nodes are a plain JSON array an agent reads and writes directly, with no
|
|
6
|
+
* canvas-widget indirection." The shelf agrees — xyflow is "the node-graph engine under
|
|
7
|
+
* Langflow/Flowise", MIT, Fragment's visual-builder base.
|
|
8
|
+
*
|
|
9
|
+
* The load-bearing word in that decision is WRITES. So this canvas is not a rendering
|
|
10
|
+
* of a harvest, it is the instrument that edits one:
|
|
11
|
+
*
|
|
12
|
+
* - drag a card in from the palette -> a component is placed
|
|
13
|
+
* - drag a node across a lane -> its layer role is REASSIGNED
|
|
14
|
+
* - drag handle to handle -> an edge is written
|
|
15
|
+
* - every mutation calls onChange -> the host persists; the engine never writes
|
|
16
|
+
*
|
|
17
|
+
* Lanes are the eleven verbatim roles, drawn as background bands. Dropping into a band
|
|
18
|
+
* IS the routing act — which is why the routing view and the editing view are the same
|
|
19
|
+
* screen rather than a table you read and a canvas you look at.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { useCallback, useMemo, useRef } from "react"
|
|
23
|
+
import {
|
|
24
|
+
ReactFlow,
|
|
25
|
+
Background,
|
|
26
|
+
BackgroundVariant,
|
|
27
|
+
Controls,
|
|
28
|
+
MiniMap,
|
|
29
|
+
ReactFlowProvider,
|
|
30
|
+
ViewportPortal,
|
|
31
|
+
useReactFlow,
|
|
32
|
+
useViewport,
|
|
33
|
+
applyNodeChanges,
|
|
34
|
+
applyEdgeChanges,
|
|
35
|
+
addEdge,
|
|
36
|
+
type Node,
|
|
37
|
+
type Edge,
|
|
38
|
+
type NodeChange,
|
|
39
|
+
type EdgeChange,
|
|
40
|
+
type Connection,
|
|
41
|
+
type ReactFlowInstance,
|
|
42
|
+
type OnNodeDrag,
|
|
43
|
+
} from "@xyflow/react"
|
|
44
|
+
import "@xyflow/react/dist/style.css"
|
|
45
|
+
|
|
46
|
+
import { ComponentNode, type ComponentNodeData } from "./ComponentNode"
|
|
47
|
+
import { ALL_SLOTS, ROLE_EMBER, ROLE_EQUIV, type LayerSlot } from "../../core/layer-roles"
|
|
48
|
+
|
|
49
|
+
export const LANE_H = 168
|
|
50
|
+
|
|
51
|
+
const nodeTypes = { griot: ComponentNode }
|
|
52
|
+
|
|
53
|
+
export interface CanvasProps {
|
|
54
|
+
nodes: Node<ComponentNodeData>[]
|
|
55
|
+
edges: Edge[]
|
|
56
|
+
onNodesChange: (c: NodeChange[]) => void
|
|
57
|
+
onEdgesChange: (c: EdgeChange[]) => void
|
|
58
|
+
onConnect: (c: Connection) => void
|
|
59
|
+
/** Fired when a node's y puts it in a different lane — the re-route. */
|
|
60
|
+
onReroute: (nodeId: string, layer: LayerSlot) => void
|
|
61
|
+
/** Fired when a palette card is dropped onto the canvas. */
|
|
62
|
+
onDropComponent: (payload: string, position: { x: number; y: number }, layer: LayerSlot) => void
|
|
63
|
+
onSelect: (nodeId: string | null) => void
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export const laneOf = (y: number): LayerSlot =>
|
|
67
|
+
ALL_SLOTS[Math.max(0, Math.min(ALL_SLOTS.length - 1, Math.floor(y / LANE_H)))]
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The lanes live in FLOW space, not screen space.
|
|
71
|
+
*
|
|
72
|
+
* They were rendered as a plain child of <ReactFlow>, which put them in the viewport's
|
|
73
|
+
* own coordinates — so the graph panned and zoomed underneath them and the labels
|
|
74
|
+
* stopped describing the rows they sat on. A lane that does not move with its nodes is
|
|
75
|
+
* worse than no lane: it asserts a role for whatever happens to be under it.
|
|
76
|
+
*
|
|
77
|
+
* <ViewportPortal> renders into the transformed pane, so a lane is pinned to the same
|
|
78
|
+
* coordinates as the nodes it contains and survives any camera move.
|
|
79
|
+
*/
|
|
80
|
+
function LaneBands({ width }: { width: number }) {
|
|
81
|
+
return (
|
|
82
|
+
<ViewportPortal>
|
|
83
|
+
{ALL_SLOTS.map((role, i) => (
|
|
84
|
+
<div
|
|
85
|
+
key={role}
|
|
86
|
+
className="vz-lane"
|
|
87
|
+
style={{ top: i * LANE_H, height: LANE_H, width, ["--ember" as string]: ROLE_EMBER[role] }}
|
|
88
|
+
/>
|
|
89
|
+
))}
|
|
90
|
+
</ViewportPortal>
|
|
91
|
+
)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The label rail — screen space, but tracking the bands.
|
|
96
|
+
*
|
|
97
|
+
* Bands belong in flow space or a node drifts out of the role it is sitting in. Labels
|
|
98
|
+
* do NOT: send them through the same transform and the legend slides off the left edge
|
|
99
|
+
* the moment you pan right, which is the context loss this is fixing, just rotated 90
|
|
100
|
+
* degrees.
|
|
101
|
+
*
|
|
102
|
+
* So the rail is pinned to the viewport and reads `useViewport()` to place each label at
|
|
103
|
+
* its band's PROJECTED y. Bands move with the graph; the legend never leaves. Labels
|
|
104
|
+
* fade out when their band is too short to read at the current zoom rather than
|
|
105
|
+
* overlapping their neighbours — the same instinct as archify's Reading Depth, where
|
|
106
|
+
* detail drops out by scale but nothing moves to make room.
|
|
107
|
+
*/
|
|
108
|
+
function LaneRail() {
|
|
109
|
+
const { y, zoom } = useViewport()
|
|
110
|
+
const h = LANE_H * zoom
|
|
111
|
+
const readable = h > 26
|
|
112
|
+
return (
|
|
113
|
+
<div className="vz-rail" aria-hidden={!readable}>
|
|
114
|
+
{ALL_SLOTS.map((role, i) => {
|
|
115
|
+
const top = y + i * h
|
|
116
|
+
return (
|
|
117
|
+
<div
|
|
118
|
+
key={role}
|
|
119
|
+
className="vz-rail-row"
|
|
120
|
+
style={{
|
|
121
|
+
top,
|
|
122
|
+
height: h,
|
|
123
|
+
opacity: readable ? 1 : 0,
|
|
124
|
+
["--ember" as string]: ROLE_EMBER[role],
|
|
125
|
+
}}
|
|
126
|
+
>
|
|
127
|
+
<span className="vz-lane-name">{role}</span>
|
|
128
|
+
{ROLE_EQUIV[role] && h > 48 && <span className="vz-lane-eq">{ROLE_EQUIV[role]}</span>}
|
|
129
|
+
</div>
|
|
130
|
+
)
|
|
131
|
+
})}
|
|
132
|
+
</div>
|
|
133
|
+
)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function CanvasInner(props: CanvasProps) {
|
|
137
|
+
const { screenToFlowPosition } = useReactFlow()
|
|
138
|
+
const wrap = useRef<HTMLDivElement>(null)
|
|
139
|
+
const laneWidth = useMemo(() => 4000, [])
|
|
140
|
+
|
|
141
|
+
const onDragOver = useCallback((e: React.DragEvent) => {
|
|
142
|
+
e.preventDefault()
|
|
143
|
+
e.dataTransfer.dropEffect = "copy"
|
|
144
|
+
}, [])
|
|
145
|
+
|
|
146
|
+
const onDrop = useCallback(
|
|
147
|
+
(e: React.DragEvent) => {
|
|
148
|
+
e.preventDefault()
|
|
149
|
+
const payload = e.dataTransfer.getData("application/griot-node")
|
|
150
|
+
if (!payload) return
|
|
151
|
+
const position = screenToFlowPosition({ x: e.clientX, y: e.clientY })
|
|
152
|
+
props.onDropComponent(payload, position, laneOf(position.y))
|
|
153
|
+
},
|
|
154
|
+
[screenToFlowPosition, props]
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
// xyflow v12 types this handler's event as MouseEvent | TouchEvent (not React's
|
|
158
|
+
// synthetic MouseEvent) because a drag can end from touch. We never read the event.
|
|
159
|
+
const onNodeDragStop: OnNodeDrag<Node<ComponentNodeData>> = useCallback(
|
|
160
|
+
(_e, node) => {
|
|
161
|
+
const target = laneOf(node.position.y + (node.measured?.height ?? 96) / 2)
|
|
162
|
+
const current = node.data.griot.layer
|
|
163
|
+
if (target !== current) props.onReroute(node.id, target)
|
|
164
|
+
},
|
|
165
|
+
[props]
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
return (
|
|
169
|
+
<div className="vz-canvas" ref={wrap} onDragOver={onDragOver} onDrop={onDrop}>
|
|
170
|
+
<ReactFlow
|
|
171
|
+
nodes={props.nodes}
|
|
172
|
+
edges={props.edges}
|
|
173
|
+
nodeTypes={nodeTypes}
|
|
174
|
+
onNodesChange={props.onNodesChange}
|
|
175
|
+
onEdgesChange={props.onEdgesChange}
|
|
176
|
+
onConnect={props.onConnect}
|
|
177
|
+
onNodeDragStop={onNodeDragStop}
|
|
178
|
+
onPaneClick={() => props.onSelect(null)}
|
|
179
|
+
onNodeClick={(_e, n) => props.onSelect(n.id)}
|
|
180
|
+
minZoom={0.2}
|
|
181
|
+
maxZoom={2.2}
|
|
182
|
+
fitView
|
|
183
|
+
proOptions={{ hideAttribution: false }}
|
|
184
|
+
>
|
|
185
|
+
<LaneBands width={laneWidth} />
|
|
186
|
+
<LaneRail />
|
|
187
|
+
<Background variant={BackgroundVariant.Dots} gap={22} size={1} color="var(--grid)" />
|
|
188
|
+
<Controls position="bottom-right" />
|
|
189
|
+
<MiniMap
|
|
190
|
+
pannable
|
|
191
|
+
zoomable
|
|
192
|
+
bgColor="#0d1116"
|
|
193
|
+
maskStrokeColor="rgba(255,255,255,.12)"
|
|
194
|
+
nodeColor={(n) => ROLE_EMBER[(n.data as ComponentNodeData).griot?.layer as LayerSlot] ?? "#555"}
|
|
195
|
+
maskColor="rgba(0,0,0,.55)"
|
|
196
|
+
/>
|
|
197
|
+
</ReactFlow>
|
|
198
|
+
</div>
|
|
199
|
+
)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export function Canvas(props: CanvasProps) {
|
|
203
|
+
return (
|
|
204
|
+
<ReactFlowProvider>
|
|
205
|
+
<CanvasInner {...props} />
|
|
206
|
+
</ReactFlowProvider>
|
|
207
|
+
)
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export { applyNodeChanges, applyEdgeChanges, addEdge }
|
|
211
|
+
export type { Node, Edge, NodeChange, EdgeChange, Connection, ReactFlowInstance }
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Layer 02 — the node. A Waku box: every box IS a real module file.
|
|
3
|
+
*
|
|
4
|
+
* The prism-viz-engine thesis ends "...ships them as interactive, source-wired surfaces
|
|
5
|
+
* in the Waku mold — not static SVGs." So this component refuses to be a label:
|
|
6
|
+
*
|
|
7
|
+
* Wiring A — click selects it and the shell reveals its detail (hash-deep-linkable)
|
|
8
|
+
* Wiring B — the ⟨source⟩ control opens the REAL file at the REAL line
|
|
9
|
+
* Wiring C — `data-node` id glows when a live trace event names it
|
|
10
|
+
*
|
|
11
|
+
* It carries BOTH harvest halves on one card, because a decision about the future of
|
|
12
|
+
* the tooling needs the picture and the function together: the UI half gives the
|
|
13
|
+
* file:line and mount point, the code half gives licence, fit and the standing ruling.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { memo, useCallback } from "react"
|
|
17
|
+
import { Handle, Position, type NodeProps } from "@xyflow/react"
|
|
18
|
+
import { ROLE_EMBER, type LayerSlot } from "../../core/layer-roles"
|
|
19
|
+
import type { GriotNodeMeta } from "../../core/json-canvas"
|
|
20
|
+
|
|
21
|
+
export interface ComponentNodeData extends Record<string, unknown> {
|
|
22
|
+
label: string
|
|
23
|
+
griot: GriotNodeMeta
|
|
24
|
+
tracing?: boolean
|
|
25
|
+
reveal?: (origin: { repo: string; file: string; line: number }) => void | Promise<void>
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const DECISION_TONE: Record<string, string> = {
|
|
29
|
+
adopt: "var(--ok)",
|
|
30
|
+
trial: "var(--sky)",
|
|
31
|
+
defer: "var(--amber)",
|
|
32
|
+
pass: "var(--dim)",
|
|
33
|
+
undecided: "var(--dim)",
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function ComponentNodeImpl({ data, selected, id }: NodeProps) {
|
|
37
|
+
const d = data as ComponentNodeData
|
|
38
|
+
const g = d.griot
|
|
39
|
+
const ember = ROLE_EMBER[g.layer as LayerSlot] ?? "#6b7385"
|
|
40
|
+
const ui = g.ui
|
|
41
|
+
const code = g.code
|
|
42
|
+
|
|
43
|
+
const onReveal = useCallback(
|
|
44
|
+
(e: React.MouseEvent) => {
|
|
45
|
+
e.stopPropagation()
|
|
46
|
+
if (ui?.origin && d.reveal) void d.reveal(ui.origin)
|
|
47
|
+
},
|
|
48
|
+
[ui, d]
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<div
|
|
53
|
+
className={`vz-node${selected ? " sel" : ""}${d.tracing ? " tracing" : ""}`}
|
|
54
|
+
data-node={id}
|
|
55
|
+
style={{ ["--ember" as string]: ember }}
|
|
56
|
+
title={ui?.mountPoint}
|
|
57
|
+
>
|
|
58
|
+
<Handle type="target" position={Position.Top} className="vz-handle" />
|
|
59
|
+
|
|
60
|
+
<div className="vz-node-top">
|
|
61
|
+
{ui && <span className="vz-repo">{ui.origin.repo}</span>}
|
|
62
|
+
{g.walkLevel && <span className="vz-kind">{g.walkLevel}</span>}
|
|
63
|
+
{code?.decision && code.decision !== "undecided" && (
|
|
64
|
+
<span className="vz-dec" style={{ color: DECISION_TONE[code.decision] }}>
|
|
65
|
+
{code.decision}
|
|
66
|
+
</span>
|
|
67
|
+
)}
|
|
68
|
+
{!!ui?.notCopy?.length && (
|
|
69
|
+
<span className="vz-warn" title={ui.notCopy.join("\n")}>
|
|
70
|
+
⚠{ui.notCopy.length}
|
|
71
|
+
</span>
|
|
72
|
+
)}
|
|
73
|
+
</div>
|
|
74
|
+
|
|
75
|
+
<div className="vz-label">{d.label}</div>
|
|
76
|
+
|
|
77
|
+
{/* The evidence, always visible. A card without file:line is not a finding. */}
|
|
78
|
+
{ui && (
|
|
79
|
+
<button className="vz-src" onClick={onReveal} disabled={!d.reveal} title={d.reveal ? "open the real source" : "no reveal on this host"}>
|
|
80
|
+
<span className="vz-src-file">{ui.origin.file}</span>
|
|
81
|
+
<span className="vz-src-line">:{ui.origin.line}</span>
|
|
82
|
+
</button>
|
|
83
|
+
)}
|
|
84
|
+
|
|
85
|
+
{/* The code half — licence is a FACT for a field, never a verdict. */}
|
|
86
|
+
{code && (
|
|
87
|
+
<div className="vz-code">
|
|
88
|
+
<span className="vz-lic">{code.licence}</span>
|
|
89
|
+
{code.fit && (
|
|
90
|
+
<span className="vz-fit" title={code.fit.why}>
|
|
91
|
+
{code.fit.app}
|
|
92
|
+
<b>{"·".repeat(code.fit.strength)}</b>
|
|
93
|
+
</span>
|
|
94
|
+
)}
|
|
95
|
+
</div>
|
|
96
|
+
)}
|
|
97
|
+
|
|
98
|
+
<Handle type="source" position={Position.Bottom} className="vz-handle" />
|
|
99
|
+
</div>
|
|
100
|
+
)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export const ComponentNode = memo(ComponentNodeImpl)
|