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,297 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Layer 02 — "the right canvas per shape."
|
|
3
|
+
*
|
|
4
|
+
* That phrase is the layer's own subtitle in prism-viz-engine-cluster.html, and it is a
|
|
5
|
+
* routing rule, not a description. The shape of the thing being drawn picks the renderer;
|
|
6
|
+
* the user does not toggle it. An infrastructure topology wants a floor plan. A pipeline
|
|
7
|
+
* wants an ordered chain. A code graph wants force.
|
|
8
|
+
*
|
|
9
|
+
* The three renderers named in layer 02, plus isometric, each own a shape:
|
|
10
|
+
*
|
|
11
|
+
* isometric infra/deployment topology GBFolio · DO/Cloudflare · IONOS
|
|
12
|
+
* things with zones, tiers, racks — a floor plan reads it fastest
|
|
13
|
+
* xyflow node-graph ordered process chains CC5 -> Reallusion -> Blender,
|
|
14
|
+
* workflows, sequences, lifecycles — order is the meaning
|
|
15
|
+
* react-force-graph code / knowledge graphs prism-graph, the 3D renderer;
|
|
16
|
+
* no authored layout, structure emerges from the edges
|
|
17
|
+
* Excalidraw freeform / hand-drawn infinite canvas, Lanshu's format
|
|
18
|
+
*
|
|
19
|
+
* The discriminator already exists and is authored, not guessed: archify's IR carries
|
|
20
|
+
* `diagram_type`, with a separate JSON Schema per type. So the router reads the field the
|
|
21
|
+
* generator already set. This is the auto-routing visual-explainer advertised and did not
|
|
22
|
+
* have (its "router" is a hard-coded array at mcp/server.mjs:263-289, which literally
|
|
23
|
+
* says "The MCP server does not call an LLM" at :272) — here it is real, because the IR
|
|
24
|
+
* declares its own shape.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import type { JSONCanvas } from "../../core/json-canvas"
|
|
28
|
+
|
|
29
|
+
/** archify's five, plus the two shapes our own substrate produces. */
|
|
30
|
+
export type DiagramShape =
|
|
31
|
+
| "architecture"
|
|
32
|
+
| "workflow"
|
|
33
|
+
| "sequence"
|
|
34
|
+
| "dataflow"
|
|
35
|
+
| "lifecycle"
|
|
36
|
+
| "codegraph"
|
|
37
|
+
| "freeform"
|
|
38
|
+
|
|
39
|
+
export type RendererId = "isometric" | "nodegraph" | "forcegraph" | "excalidraw"
|
|
40
|
+
|
|
41
|
+
export interface RouteDecision {
|
|
42
|
+
renderer: RendererId
|
|
43
|
+
/** Why this renderer — surfaced in the UI so the routing is legible, never magic. */
|
|
44
|
+
because: string
|
|
45
|
+
/** Renderers that can also open this shape, offered as an override. */
|
|
46
|
+
alternatives: RendererId[]
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* ISOMETRIC IS NOT THE DEFAULT FOR "architecture".
|
|
51
|
+
*
|
|
52
|
+
* Gavin asked for the isometric camera on CERTAIN architectural diagrams — GBFolio,
|
|
53
|
+
* DO/Cloudflare, IONOS — i.e. deployment topology, things that occupy somewhere. An
|
|
54
|
+
* earlier pass routed the whole `architecture` type to isometric, which made every
|
|
55
|
+
* diagram isometric. That is the opposite of what was asked for.
|
|
56
|
+
*
|
|
57
|
+
* The real discriminator is TOPOLOGY, not the type name. A deployment diagram has
|
|
58
|
+
* regions, zones, VPCs, hosts, clusters — it has a floor plan, so a floor plan reads it.
|
|
59
|
+
* An application architecture of the same declared type has modules and services with no
|
|
60
|
+
* spatial meaning at all, and forcing it onto a tile lattice invents a geography that
|
|
61
|
+
* isn't in the data.
|
|
62
|
+
*
|
|
63
|
+
* So `architecture` routes to the node graph by default, and only lifts to isometric when
|
|
64
|
+
* the document itself shows topology. The signals are read from the content, and the
|
|
65
|
+
* reason is reported, so the lift is never silent.
|
|
66
|
+
*/
|
|
67
|
+
/**
|
|
68
|
+
* WHAT MAKES A DIAGRAM "DEPLOYMENT TOPOLOGY" — archify's answer, not mine.
|
|
69
|
+
*
|
|
70
|
+
* Three wrong passes preceded this, all of them me guessing:
|
|
71
|
+
* 1. a generic word list over boundary labels — too weak, real deployment diagrams
|
|
72
|
+
* scored 0.25 and stayed node-graph.
|
|
73
|
+
* 2. the boundary `kind` — meaningless on its own. The schema types it as
|
|
74
|
+
* {"enum":["region","security-group"]}, two values reused for ANY grouping:
|
|
75
|
+
* `kind=region` labels both "AWS us-east-1 / production" and "archify/ skill package".
|
|
76
|
+
* 3. cloud-provider and region-id regexes over labels — fitting to prose.
|
|
77
|
+
*
|
|
78
|
+
* Then the design harvest found that archify ALREADY DECLARES THIS, checkably, in
|
|
79
|
+
* `renderers/shared/engineering-profiles.mjs` — its `deployment-ownership` profile. That
|
|
80
|
+
* file emits authoring diagnostics and changes zero pixels, but its REQUIREMENTS are a
|
|
81
|
+
* precise structural definition of a deployment diagram:
|
|
82
|
+
*
|
|
83
|
+
* :4 DEPLOYMENT_BOUNDARY_KINDS = new Set(['region','security-group'])
|
|
84
|
+
* -> at least one of EACH (:30-41)
|
|
85
|
+
* :45-54 every non-`external` component names its owner in `tag`
|
|
86
|
+
* :56-78 every component belongs to exactly one `region` boundary
|
|
87
|
+
* :80-92 every `database` sits inside a `security-group`
|
|
88
|
+
* :95-117 region-consistency inside private boundaries
|
|
89
|
+
* :119-142 every boundary-crossing connection carries a named label
|
|
90
|
+
*
|
|
91
|
+
* The structural rules are the discriminator, and the OWNER TAG is what separates the
|
|
92
|
+
* two cases that fooled every earlier attempt: a real deployment diagram names who
|
|
93
|
+
* operates each box, an application architecture does not. `archify/ skill package`
|
|
94
|
+
* carries a `region` boundary and no owners; `AWS us-east-1 / production` carries both.
|
|
95
|
+
*
|
|
96
|
+
* We score rather than gate because the profile is opt-in and a diagram can be genuine
|
|
97
|
+
* topology while failing a rule or two. But every term below is one of archify's, read
|
|
98
|
+
* from its source — not a pattern invented here.
|
|
99
|
+
*/
|
|
100
|
+
/**
|
|
101
|
+
* KNOWN DUPLICATION — recorded rather than left to drift.
|
|
102
|
+
*
|
|
103
|
+
* This function answers the same question as archify's own
|
|
104
|
+
* `deploymentOwnershipDiagnostics`, which layer 01's `gate-ir.mjs` calls directly and
|
|
105
|
+
* which is authoritative. Two implementations of one rule is exactly the shape that
|
|
106
|
+
* rots, so it is named here.
|
|
107
|
+
*
|
|
108
|
+
* Why it exists anyway: that function is node-only. Its import chain
|
|
109
|
+
* (`diagnostics.mjs` -> `node:fs`, `node:path`) cannot enter a browser bundle, and by the
|
|
110
|
+
* time this code runs the IR is gone — we hold a converted JSON Canvas. So the browser
|
|
111
|
+
* cannot call the authority, only re-derive from what survived conversion.
|
|
112
|
+
*
|
|
113
|
+
* THE FIX, designed and deliberately not half-built: the sidecar runs node-side and CAN
|
|
114
|
+
* import archify. It should compute the verdict when it serves a diagram and pass it
|
|
115
|
+
* through as a route hint; `routeCanvas` then READS a fact and falls back to this scorer
|
|
116
|
+
* only when no hint arrives. One computation, at the only layer that can do it properly.
|
|
117
|
+
* Until that lands, treat `gate-ir.mjs`'s reported renderer as authoritative and this as
|
|
118
|
+
* the approximation — they agree on every example tested, which is not the same as
|
|
119
|
+
* being guaranteed to agree.
|
|
120
|
+
*/
|
|
121
|
+
export function topologyScore(canvas: JSONCanvas): { score: number; hits: string[] } {
|
|
122
|
+
const hits: string[] = []
|
|
123
|
+
const nodes = canvas.nodes.filter((n) => n.type !== "group")
|
|
124
|
+
const groups = canvas.nodes.filter((n) => n.type === "group")
|
|
125
|
+
const ax = (n: unknown) => (n as any)?.archify ?? {}
|
|
126
|
+
|
|
127
|
+
// (1) at least one region AND one security-group boundary — engineering-profiles.mjs:30-41
|
|
128
|
+
const regions = groups.filter((g) => ax(g).kind === "region")
|
|
129
|
+
const secGroups = groups.filter((g) => ax(g).kind === "security-group")
|
|
130
|
+
const bothKinds = regions.length > 0 && secGroups.length > 0
|
|
131
|
+
if (bothKinds) hits.push(`${regions.length} region + ${secGroups.length} security-group`)
|
|
132
|
+
|
|
133
|
+
// (2) owners named in `tag` — :45-54. The decisive signal: deployments have operators.
|
|
134
|
+
const ownable = nodes.filter((n) => ax(n).type !== "external")
|
|
135
|
+
const owned = ownable.filter((n) => typeof ax(n).tag === "string" && ax(n).tag.trim() !== "")
|
|
136
|
+
const ownedRatio = ownable.length ? owned.length / ownable.length : 0
|
|
137
|
+
if (owned.length) hits.push(`${owned.length}/${ownable.length} components name an owner`)
|
|
138
|
+
|
|
139
|
+
// (3) components actually placed inside a region — :56-78
|
|
140
|
+
const regionWraps = new Set(regions.flatMap((g) => (ax(g).wraps as string[]) ?? []))
|
|
141
|
+
const placedInRegion = nodes.filter((n) => regionWraps.has(n.id))
|
|
142
|
+
const placedRatio = nodes.length ? placedInRegion.length / nodes.length : 0
|
|
143
|
+
if (placedInRegion.length) hits.push(`${placedInRegion.length}/${nodes.length} inside a region`)
|
|
144
|
+
|
|
145
|
+
// (4) every database inside a security-group — :80-92
|
|
146
|
+
const sgWraps = new Set(secGroups.flatMap((g) => (ax(g).wraps as string[]) ?? []))
|
|
147
|
+
const dbs = nodes.filter((n) => ax(n).type === "database")
|
|
148
|
+
const dbsGuarded = dbs.filter((n) => sgWraps.has(n.id))
|
|
149
|
+
if (dbs.length && dbsGuarded.length === dbs.length) hits.push(`all ${dbs.length} database(s) in a security-group`)
|
|
150
|
+
|
|
151
|
+
// OWNERSHIP DOMINATES, and that is archify's weighting, not one tuned to get an answer.
|
|
152
|
+
// A missing owner is severity `error` on EVERY non-external component
|
|
153
|
+
// (engineering-profiles.mjs:45-54) — the profile does not tolerate one. So a genuine
|
|
154
|
+
// deployment diagram approaches 1.0 here and an application architecture does not,
|
|
155
|
+
// which is the difference the three earlier attempts kept failing to find:
|
|
156
|
+
// "AWS us-east-1 / production" names who operates each box; "Query Runtime" does not.
|
|
157
|
+
//
|
|
158
|
+
// bothKinds is necessary but NOT sufficient — nearly every archify example has a
|
|
159
|
+
// region+security-group pair, because those two values are the whole enum. It is worth
|
|
160
|
+
// a floor, not a verdict.
|
|
161
|
+
const score =
|
|
162
|
+
(bothKinds ? 0.22 : 0) +
|
|
163
|
+
ownedRatio * 0.48 +
|
|
164
|
+
placedRatio * 0.2 +
|
|
165
|
+
(dbs.length && dbsGuarded.length === dbs.length ? 0.1 : 0)
|
|
166
|
+
|
|
167
|
+
return { score, hits }
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const ROUTES: Record<DiagramShape, RouteDecision> = {
|
|
171
|
+
architecture: {
|
|
172
|
+
// default — an application architecture is modules and services, not a floor plan
|
|
173
|
+
renderer: "nodegraph",
|
|
174
|
+
because: "components and their relationships — no spatial meaning to project",
|
|
175
|
+
alternatives: ["isometric", "excalidraw"],
|
|
176
|
+
},
|
|
177
|
+
workflow: {
|
|
178
|
+
renderer: "nodegraph",
|
|
179
|
+
because: "an ordered process chain — order is the meaning, so it wants a directed graph",
|
|
180
|
+
alternatives: ["excalidraw"],
|
|
181
|
+
},
|
|
182
|
+
sequence: {
|
|
183
|
+
renderer: "nodegraph",
|
|
184
|
+
because: "ordered exchange between participants — lanes and order carry it",
|
|
185
|
+
alternatives: ["excalidraw"],
|
|
186
|
+
},
|
|
187
|
+
lifecycle: {
|
|
188
|
+
renderer: "nodegraph",
|
|
189
|
+
because: "state transitions — a directed graph shows the reachable set",
|
|
190
|
+
alternatives: ["forcegraph", "excalidraw"],
|
|
191
|
+
},
|
|
192
|
+
dataflow: {
|
|
193
|
+
renderer: "nodegraph",
|
|
194
|
+
because: "directed movement between stages — edges are the subject",
|
|
195
|
+
alternatives: ["forcegraph"],
|
|
196
|
+
},
|
|
197
|
+
codegraph: {
|
|
198
|
+
renderer: "forcegraph",
|
|
199
|
+
because: "no authored layout — structure emerges from the edges (prism-graph)",
|
|
200
|
+
alternatives: ["nodegraph"],
|
|
201
|
+
},
|
|
202
|
+
freeform: {
|
|
203
|
+
renderer: "excalidraw",
|
|
204
|
+
because: "hand-drawn infinite canvas — nothing here implies an order",
|
|
205
|
+
alternatives: ["nodegraph"],
|
|
206
|
+
},
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Shapes we can actually render today. The rest are declared and honest about it. */
|
|
210
|
+
export const IMPLEMENTED: Record<RendererId, boolean> = {
|
|
211
|
+
nodegraph: true, // xyflow — src/layers/02-render/Canvas.tsx
|
|
212
|
+
isometric: true, // FossFLOW projection — src/layers/02-render/IsometricView.tsx
|
|
213
|
+
forcegraph: false, // prism-graph, react-force-graph — layer 02's third renderer, not built
|
|
214
|
+
excalidraw: false, // the Excal writer grafted from Lanshu belongs here — not built
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export function routeForShape(shape: DiagramShape): RouteDecision {
|
|
218
|
+
return ROUTES[shape] ?? ROUTES.freeform
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Route a canvas. Prefers the authored `diagram_type` the generator set; falls back to a
|
|
223
|
+
* structural read only when the document carries no declaration — and says which it used,
|
|
224
|
+
* so an inferred route is never mistaken for a declared one.
|
|
225
|
+
*/
|
|
226
|
+
export function routeCanvas(
|
|
227
|
+
canvas: JSONCanvas,
|
|
228
|
+
declaredType?: string
|
|
229
|
+
): RouteDecision & { shape: DiagramShape; declared: boolean; empty?: boolean } {
|
|
230
|
+
if (declaredType && declaredType in ROUTES) {
|
|
231
|
+
const shape = declaredType as DiagramShape
|
|
232
|
+
const base = routeForShape(shape)
|
|
233
|
+
|
|
234
|
+
// The only lift to isometric: a declared architecture that actually shows topology.
|
|
235
|
+
// Threshold is deliberately high — when in doubt this stays a node graph, because a
|
|
236
|
+
// wrongly-isometric diagram invents a geography the data does not contain.
|
|
237
|
+
if (shape === "architecture") {
|
|
238
|
+
// 0.6, not 0.35. The ranking below 0.6 is sound — deployment diagrams sort above
|
|
239
|
+
// application architectures — but the absolute scores cluster, because most archify
|
|
240
|
+
// examples only partially fill the deployment-ownership profile. So the threshold
|
|
241
|
+
// picks which error to make. Gavin's reported defect was EVERYTHING going isometric,
|
|
242
|
+
// and a wrongly-isometric diagram invents a geography the data does not contain,
|
|
243
|
+
// while a wrongly-flat one is one click on the override. Under-lift on purpose.
|
|
244
|
+
const topo = topologyScore(canvas)
|
|
245
|
+
if (topo.score >= 0.6) {
|
|
246
|
+
return {
|
|
247
|
+
renderer: "isometric",
|
|
248
|
+
because: `deployment topology — ${topo.hits.join(", ")}`,
|
|
249
|
+
alternatives: ["nodegraph", "excalidraw"],
|
|
250
|
+
shape,
|
|
251
|
+
declared: true,
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
return { ...base, shape, declared: true }
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// No declaration. Read the document's own structure rather than guessing a name.
|
|
259
|
+
const nodes = canvas.nodes.filter((n) => n.type !== "group")
|
|
260
|
+
const groups = canvas.nodes.length - nodes.length
|
|
261
|
+
const edges = canvas.edges.length
|
|
262
|
+
|
|
263
|
+
// An empty canvas has no shape to read. Saying "freeform" here would be inventing a
|
|
264
|
+
// reading from nothing — report the absence and open on the composing surface.
|
|
265
|
+
if (!nodes.length) {
|
|
266
|
+
return {
|
|
267
|
+
renderer: "nodegraph",
|
|
268
|
+
because: "nothing placed yet — no shape to read, so the composing canvas opens",
|
|
269
|
+
alternatives: ["isometric"],
|
|
270
|
+
shape: "workflow",
|
|
271
|
+
declared: false,
|
|
272
|
+
empty: true,
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
const density = nodes.length ? edges / nodes.length : 0
|
|
276
|
+
const sided = canvas.edges.filter((e) => e.fromSide || e.toSide).length
|
|
277
|
+
|
|
278
|
+
// Same test as the declared path — topology, not the mere presence of boundaries.
|
|
279
|
+
const topo = topologyScore(canvas)
|
|
280
|
+
const shape: DiagramShape =
|
|
281
|
+
groups > 0 && sided > 0 && topo.score >= 0.35
|
|
282
|
+
? "architecture"
|
|
283
|
+
: density > 1.6
|
|
284
|
+
? "codegraph" // densely interlinked, no authored layout to preserve
|
|
285
|
+
: edges > 0
|
|
286
|
+
? "workflow"
|
|
287
|
+
: "freeform"
|
|
288
|
+
|
|
289
|
+
return { ...routeForShape(shape), shape, declared: false }
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** Fall back to something implemented, and say so rather than rendering nothing. */
|
|
293
|
+
export function resolveRenderer(d: RouteDecision): { renderer: RendererId; fellBack: boolean } {
|
|
294
|
+
if (IMPLEMENTED[d.renderer]) return { renderer: d.renderer, fellBack: false }
|
|
295
|
+
const alt = d.alternatives.find((a) => IMPLEMENTED[a])
|
|
296
|
+
return { renderer: alt ?? "nodegraph", fellBack: true }
|
|
297
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Layer 03 (substrate) → the JSON Canvas wire.
|
|
3
|
+
*
|
|
4
|
+
* Turns what the two harvest modes emit into canvas nodes. The engine never invents a
|
|
5
|
+
* node: everything on the canvas came from a walk that cited file:line, or from a
|
|
6
|
+
* shelf row that cited a slug. If neither exists, nothing is drawn.
|
|
7
|
+
*
|
|
8
|
+
* Two inputs, ONE output node per component — that is the fusion Gavin asked for:
|
|
9
|
+
* - `uxui-canvas-nodes.json` (griot-harvest-ux-ui) -> the UI half
|
|
10
|
+
* - the DGS plan's oss-inspo rows (griot-harvest) -> the code half
|
|
11
|
+
* They join on the repo, so a harvested screen carries its tool's licence, fit verdict
|
|
12
|
+
* and decision on the same card you drag.
|
|
13
|
+
*
|
|
14
|
+
* Kuzu (layer 03, `trial · next` on the shelf — the one renderer-cluster tool already
|
|
15
|
+
* ruled) is the eventual query substrate here; `loadFromKuzu` is the seam it lands on.
|
|
16
|
+
* It is deliberately not stubbed with fake data — an unimplemented path throws.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import {
|
|
20
|
+
type JSONCanvas,
|
|
21
|
+
type CanvasNode,
|
|
22
|
+
type GriotNodeMeta,
|
|
23
|
+
emptyCanvas,
|
|
24
|
+
} from "../../core/json-canvas"
|
|
25
|
+
import { type LayerSlot, isLayerSlot, UNPLACEABLE } from "../../core/layer-roles"
|
|
26
|
+
|
|
27
|
+
/** The shape `emit-canvas-nodes.mjs` writes. */
|
|
28
|
+
export interface HarvestedUxNode {
|
|
29
|
+
id: string
|
|
30
|
+
type: "component" | "screen" | "flow" | "workflow"
|
|
31
|
+
label: string
|
|
32
|
+
layer: string
|
|
33
|
+
position: { x: number; y: number }
|
|
34
|
+
data: {
|
|
35
|
+
walkLevel?: string
|
|
36
|
+
parentId?: string | null
|
|
37
|
+
origin: { repo?: string; file: string; line: number }
|
|
38
|
+
mountPoint: string
|
|
39
|
+
provenance: { harvestedBy: string; harvestedAt: string; sourceCommit?: string | null; repo?: string }
|
|
40
|
+
licence: string
|
|
41
|
+
notCopy?: string[]
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The shape a `griot-harvest` / DGS `oss-inspo` row contributes. */
|
|
46
|
+
export interface HarvestedCodeRow {
|
|
47
|
+
repo: string
|
|
48
|
+
capability?: string
|
|
49
|
+
features?: string[]
|
|
50
|
+
fit?: { app: string; strength: 1 | 2 | 3; why: string }
|
|
51
|
+
licence?: string
|
|
52
|
+
decision?: "adopt" | "trial" | "defer" | "pass" | "undecided"
|
|
53
|
+
role?: "scaffold" | "component" | "pattern"
|
|
54
|
+
stage?: "now" | "next" | "later"
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const CARD_W = 232
|
|
58
|
+
const CARD_H = 96
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Layer bands give a node its y; repo columns give it x. Position carries only meaning
|
|
62
|
+
* that is actually in the data — the emitter stores placeholders in `position` and says
|
|
63
|
+
* outright that it "never invents meaning" there, so we recompute rather than trust it.
|
|
64
|
+
*/
|
|
65
|
+
export function adaptHarvest(
|
|
66
|
+
uxNodes: HarvestedUxNode[],
|
|
67
|
+
codeRows: HarvestedCodeRow[] = [],
|
|
68
|
+
opts: { laneHeight?: number; labelWidth?: number; slots: readonly LayerSlot[] } = {
|
|
69
|
+
slots: [],
|
|
70
|
+
}
|
|
71
|
+
): JSONCanvas {
|
|
72
|
+
const laneH = opts.laneHeight ?? 168
|
|
73
|
+
const labelW = opts.labelWidth ?? 208
|
|
74
|
+
const slots = opts.slots
|
|
75
|
+
const canvas = emptyCanvas()
|
|
76
|
+
|
|
77
|
+
const codeByRepo = new Map(codeRows.map((r) => [r.repo, r]))
|
|
78
|
+
const repos = [...new Set(uxNodes.map((n) => repoOf(n)))].sort()
|
|
79
|
+
const packed = new Map<string, number>()
|
|
80
|
+
|
|
81
|
+
for (const n of uxNodes) {
|
|
82
|
+
const layer: LayerSlot = isLayerSlot(n.layer) ? n.layer : UNPLACEABLE
|
|
83
|
+
const repo = repoOf(n)
|
|
84
|
+
const lane = Math.max(0, slots.indexOf(layer))
|
|
85
|
+
const key = `${lane}|${repo}`
|
|
86
|
+
const i = packed.get(key) ?? 0
|
|
87
|
+
packed.set(key, i + 1)
|
|
88
|
+
|
|
89
|
+
const code = codeByRepo.get(repo)
|
|
90
|
+
const griot: GriotNodeMeta = {
|
|
91
|
+
layer,
|
|
92
|
+
walkLevel: n.type,
|
|
93
|
+
ui: {
|
|
94
|
+
origin: { repo, file: n.data.origin.file, line: n.data.origin.line },
|
|
95
|
+
mountPoint: n.data.mountPoint,
|
|
96
|
+
notCopy: n.data.notCopy ?? [],
|
|
97
|
+
preview: { kind: "none" },
|
|
98
|
+
},
|
|
99
|
+
code: code
|
|
100
|
+
? {
|
|
101
|
+
capability: code.capability,
|
|
102
|
+
features: code.features,
|
|
103
|
+
fit: code.fit,
|
|
104
|
+
licence: code.licence ?? n.data.licence,
|
|
105
|
+
decision: code.decision ?? "undecided",
|
|
106
|
+
role: code.role,
|
|
107
|
+
stage: code.stage,
|
|
108
|
+
}
|
|
109
|
+
: { licence: n.data.licence },
|
|
110
|
+
provenance: {
|
|
111
|
+
harvestedBy: n.data.provenance.harvestedBy,
|
|
112
|
+
harvestedAt: n.data.provenance.harvestedAt,
|
|
113
|
+
sourceCommit: n.data.provenance.sourceCommit ?? null,
|
|
114
|
+
},
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const node: CanvasNode = {
|
|
118
|
+
id: n.id,
|
|
119
|
+
type: "file",
|
|
120
|
+
file: n.data.origin.file,
|
|
121
|
+
x: labelW + 24 + repos.indexOf(repo) * (CARD_W + 20) + i * 18,
|
|
122
|
+
y: lane * laneH + 34 + i * 14,
|
|
123
|
+
width: CARD_W,
|
|
124
|
+
height: CARD_H,
|
|
125
|
+
griot,
|
|
126
|
+
} as CanvasNode
|
|
127
|
+
;(node as any).label = n.label
|
|
128
|
+
canvas.nodes.push(node)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Edges come from declared parentage ONLY. No inferred relationships, ever.
|
|
132
|
+
for (const n of uxNodes) {
|
|
133
|
+
const parent = n.data.parentId
|
|
134
|
+
if (!parent) continue
|
|
135
|
+
if (!uxNodes.some((m) => m.id === parent)) continue
|
|
136
|
+
canvas.edges.push({
|
|
137
|
+
id: `${parent}->${n.id}`,
|
|
138
|
+
fromNode: parent,
|
|
139
|
+
fromSide: "bottom",
|
|
140
|
+
toNode: n.id,
|
|
141
|
+
toSide: "top",
|
|
142
|
+
})
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return canvas
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const repoOf = (n: HarvestedUxNode) => n.data.provenance?.repo ?? n.data.origin?.repo ?? "?"
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* The Kuzu seam. `kuzudb/kuzu` is `trial · next` on the shelf and is the prism-graph
|
|
152
|
+
* query substrate; when the embedded DB lands, structural nodes come from a Cypher
|
|
153
|
+
* query here instead of a JSON file. Left throwing on purpose — a stub that returns
|
|
154
|
+
* plausible fake rows is the exact failure this whole engine exists to stop.
|
|
155
|
+
*/
|
|
156
|
+
export async function loadFromKuzu(_cypher: string): Promise<JSONCanvas> {
|
|
157
|
+
throw new Error(
|
|
158
|
+
"loadFromKuzu: the Kuzu substrate is not wired yet (shelf state: trial · next). " +
|
|
159
|
+
"Use adaptHarvest() against a real harvest until it is — this path will not return invented rows."
|
|
160
|
+
)
|
|
161
|
+
}
|