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,288 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Layer 02 — the isometric canvas. Third renderer, same document.
|
|
3
|
+
*
|
|
4
|
+
* xyflow draws the graph; this draws the same JSON Canvas as an isometric scene, which
|
|
5
|
+
* is the read cloud and server architectures want — racks, tiers, zones with depth.
|
|
6
|
+
* FossFLOW's projection, our document, our motion clock.
|
|
7
|
+
*
|
|
8
|
+
* It shares the document rather than converting it: switching views never mutates the
|
|
9
|
+
* canvas, so positions survive a round trip exactly. The tile quantisation happens on
|
|
10
|
+
* the way in (isometric.ts `pixelToTile`) and is thrown away on the way out.
|
|
11
|
+
*
|
|
12
|
+
* Motion here obeys the same law as everywhere else: the camera eases at
|
|
13
|
+
* MOTION.camera (FossFLOW's 0.25s), decorative pulse is `loop`-mode only, aria-hidden,
|
|
14
|
+
* and the first thing dropped under prefers-reduced-motion.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
|
18
|
+
import {
|
|
19
|
+
isoBox,
|
|
20
|
+
pixelToTile,
|
|
21
|
+
depthSort,
|
|
22
|
+
PROJECTED_TILE_SIZE,
|
|
23
|
+
type Tile,
|
|
24
|
+
} from "./isometric"
|
|
25
|
+
import type { JSONCanvas, CanvasNode, GriotNodeMeta } from "../../core/json-canvas"
|
|
26
|
+
import { ROLE_EMBER, type LayerSlot } from "../../core/layer-roles"
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Colour of last resort. archify components arrive `unplaceable` on purpose — a layer
|
|
30
|
+
* role is Gavin's to assign and must never be guessed — so an example diagram would
|
|
31
|
+
* otherwise render as a uniform grey field. Falling back to the component's own
|
|
32
|
+
* declared `type` keeps the read without inventing a routing.
|
|
33
|
+
*/
|
|
34
|
+
const TYPE_TINT: Record<string, string> = {
|
|
35
|
+
user: "#38bdf8", client: "#38bdf8", ui: "#38bdf8", desktop: "#38bdf8",
|
|
36
|
+
service: "#3b82f6", process: "#3b82f6", api: "#3b82f6", gateway: "#818cf8",
|
|
37
|
+
store: "#10ffba", storage: "#10ffba", db: "#10ffba", cache: "#10ffba",
|
|
38
|
+
model: "#f472b6", provider: "#f472b6", llm: "#f472b6",
|
|
39
|
+
tool: "#f59e0b", runtime: "#f59e0b", worker: "#f59e0b",
|
|
40
|
+
security: "#fb7185", policy: "#fb7185", permission: "#fb7185",
|
|
41
|
+
}
|
|
42
|
+
function tintFor(griot: GriotNodeMeta | undefined, node: CanvasNode): string {
|
|
43
|
+
const routed = griot?.layer && griot.layer !== "unplaceable"
|
|
44
|
+
if (routed) return ROLE_EMBER[griot!.layer as LayerSlot]
|
|
45
|
+
const t = String((node as any).archify?.type ?? griot?.code?.capability ?? "").toLowerCase()
|
|
46
|
+
for (const k of Object.keys(TYPE_TINT)) if (t.includes(k)) return TYPE_TINT[k]
|
|
47
|
+
return "#64748b"
|
|
48
|
+
}
|
|
49
|
+
import { MOTION, pulseIntensity, activeIndex, decorativeAttrs, type MotionMode } from "../../core/motion"
|
|
50
|
+
import { loadIcons, resolveIcon, placeIcon, type IsoIcon } from "./icons"
|
|
51
|
+
|
|
52
|
+
export interface IsometricViewProps {
|
|
53
|
+
canvas: JSONCanvas
|
|
54
|
+
/** Pixel footprint one tile stands for. Larger spreads the lattice. */
|
|
55
|
+
cell?: number
|
|
56
|
+
/** Extrusion depth — what turns a floor tile into a machine. */
|
|
57
|
+
boxHeight?: number
|
|
58
|
+
motionMode?: MotionMode
|
|
59
|
+
selectedId?: string | null
|
|
60
|
+
onSelect?: (id: string | null) => void
|
|
61
|
+
onReveal?: (origin: { repo: string; file: string; line: number }) => void | Promise<void>
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
interface Placed {
|
|
65
|
+
node: CanvasNode
|
|
66
|
+
griot?: GriotNodeMeta
|
|
67
|
+
tile: Tile
|
|
68
|
+
box: ReturnType<typeof isoBox>
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Break on word boundaries, never mid-word — a clipped label is worse than two lines. */
|
|
72
|
+
function wrapLabel(s: string, max = 16, maxLines = 2): string[] {
|
|
73
|
+
const words = s.split(/\s+/)
|
|
74
|
+
const lines: string[] = []
|
|
75
|
+
let cur = ""
|
|
76
|
+
for (const w of words) {
|
|
77
|
+
if (!cur) { cur = w; continue }
|
|
78
|
+
if ((cur + " " + w).length <= max) cur += " " + w
|
|
79
|
+
else { lines.push(cur); cur = w; if (lines.length === maxLines - 1) break }
|
|
80
|
+
}
|
|
81
|
+
if (cur && lines.length < maxLines) lines.push(cur)
|
|
82
|
+
if (lines.length === maxLines && words.join(" ").length > lines.join(" ").length)
|
|
83
|
+
lines[maxLines - 1] = lines[maxLines - 1].slice(0, max - 1) + "…"
|
|
84
|
+
return lines
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function IsometricView({
|
|
88
|
+
canvas,
|
|
89
|
+
cell = 96,
|
|
90
|
+
boxHeight = 26,
|
|
91
|
+
motionMode = "none",
|
|
92
|
+
selectedId,
|
|
93
|
+
onSelect,
|
|
94
|
+
onReveal,
|
|
95
|
+
}: IsometricViewProps) {
|
|
96
|
+
const [cam, setCam] = useState({ x: 0, y: 0, zoom: 1 })
|
|
97
|
+
// 1062 base64 icons load lazily — a canvas that needs none pays nothing.
|
|
98
|
+
const [icons, setIcons] = useState<IsoIcon[]>([])
|
|
99
|
+
useEffect(() => { loadIcons().then(setIcons).catch(() => {}) }, [])
|
|
100
|
+
const [elapsed, setElapsed] = useState(0)
|
|
101
|
+
const wrap = useRef<HTMLDivElement>(null)
|
|
102
|
+
const pan = useRef<{ x: number; y: number; cx: number; cy: number } | null>(null)
|
|
103
|
+
|
|
104
|
+
// Decorative clock. Only runs in loop mode, and never under reduced motion —
|
|
105
|
+
// animation.md:76 drops [data-motion-decorative] entirely there.
|
|
106
|
+
useEffect(() => {
|
|
107
|
+
if (motionMode !== "loop") return
|
|
108
|
+
if (typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches)
|
|
109
|
+
return
|
|
110
|
+
let raf = 0
|
|
111
|
+
const t0 = performance.now()
|
|
112
|
+
const tick = (now: number) => {
|
|
113
|
+
setElapsed(now - t0)
|
|
114
|
+
raf = requestAnimationFrame(tick)
|
|
115
|
+
}
|
|
116
|
+
raf = requestAnimationFrame(tick)
|
|
117
|
+
return () => cancelAnimationFrame(raf)
|
|
118
|
+
}, [motionMode])
|
|
119
|
+
|
|
120
|
+
const placed: Placed[] = useMemo(() => {
|
|
121
|
+
const items = canvas.nodes
|
|
122
|
+
.filter((n) => n.type !== "group")
|
|
123
|
+
.map((n) => {
|
|
124
|
+
const tile = pixelToTile({ x: n.x, y: n.y }, cell)
|
|
125
|
+
return { node: n, griot: n.griot as GriotNodeMeta | undefined, tile, box: isoBox(tile, boxHeight) }
|
|
126
|
+
})
|
|
127
|
+
// back paints first or a neighbour's extrusion draws over it
|
|
128
|
+
return depthSort(items)
|
|
129
|
+
}, [canvas.nodes, cell, boxHeight])
|
|
130
|
+
|
|
131
|
+
const byId = useMemo(() => new Map(placed.map((p) => [p.node.id, p])), [placed])
|
|
132
|
+
|
|
133
|
+
const bounds = useMemo(() => {
|
|
134
|
+
if (!placed.length) return { minX: -400, minY: -300, w: 800, h: 600 }
|
|
135
|
+
const xs = placed.flatMap((p) => [p.box.center.x - PROJECTED_TILE_SIZE.width, p.box.center.x + PROJECTED_TILE_SIZE.width])
|
|
136
|
+
const ys = placed.flatMap((p) => [p.box.center.y - PROJECTED_TILE_SIZE.height, p.box.center.y + PROJECTED_TILE_SIZE.height + boxHeight])
|
|
137
|
+
const minX = Math.min(...xs) - 60
|
|
138
|
+
const minY = Math.min(...ys) - 60
|
|
139
|
+
return { minX, minY, w: Math.max(...xs) - minX + 60, h: Math.max(...ys) - minY + 60 }
|
|
140
|
+
}, [placed, boxHeight])
|
|
141
|
+
|
|
142
|
+
// Lanshu's sequential activation, under diagram-design's one-at-a-time rule.
|
|
143
|
+
const lit = motionMode === "loop" ? activeIndex(elapsed, placed.length, 620) : -1
|
|
144
|
+
const pulse = motionMode === "loop" ? pulseIntensity(elapsed) : 0
|
|
145
|
+
|
|
146
|
+
const onDown = useCallback((e: React.PointerEvent) => {
|
|
147
|
+
if ((e.target as HTMLElement).closest("[data-iso-node]")) return
|
|
148
|
+
pan.current = { x: e.clientX, y: e.clientY, cx: cam.x, cy: cam.y }
|
|
149
|
+
;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
|
|
150
|
+
onSelect?.(null)
|
|
151
|
+
}, [cam.x, cam.y, onSelect])
|
|
152
|
+
|
|
153
|
+
const onMove = useCallback((e: React.PointerEvent) => {
|
|
154
|
+
if (!pan.current) return
|
|
155
|
+
setCam((c) => ({ ...c, x: pan.current!.cx + (e.clientX - pan.current!.x), y: pan.current!.cy + (e.clientY - pan.current!.y) }))
|
|
156
|
+
}, [])
|
|
157
|
+
|
|
158
|
+
const onUp = useCallback(() => { pan.current = null }, [])
|
|
159
|
+
|
|
160
|
+
const onWheel = useCallback((e: React.WheelEvent) => {
|
|
161
|
+
e.preventDefault()
|
|
162
|
+
setCam((c) => ({ ...c, zoom: Math.max(0.2, Math.min(2.4, c.zoom * (e.deltaY < 0 ? 1.12 : 1 / 1.12))) }))
|
|
163
|
+
}, [])
|
|
164
|
+
|
|
165
|
+
return (
|
|
166
|
+
<div className="vz-iso" ref={wrap} onPointerDown={onDown} onPointerMove={onMove} onPointerUp={onUp} onWheel={onWheel}>
|
|
167
|
+
<svg
|
|
168
|
+
className="vz-iso-svg"
|
|
169
|
+
viewBox={`${bounds.minX} ${bounds.minY} ${bounds.w} ${bounds.h}`}
|
|
170
|
+
style={{
|
|
171
|
+
transform: `translate(${cam.x}px, ${cam.y}px) scale(${cam.zoom})`,
|
|
172
|
+
transition: `transform var(--motion-camera, ${MOTION.camera}ms) var(--motion-ease)`,
|
|
173
|
+
}}
|
|
174
|
+
>
|
|
175
|
+
<defs>
|
|
176
|
+
<filter id="iso-glow" x="-60%" y="-60%" width="220%" height="220%">
|
|
177
|
+
<feGaussianBlur stdDeviation="4" result="b" />
|
|
178
|
+
<feMerge>
|
|
179
|
+
<feMergeNode in="b" />
|
|
180
|
+
<feMergeNode in="SourceGraphic" />
|
|
181
|
+
</feMerge>
|
|
182
|
+
</filter>
|
|
183
|
+
</defs>
|
|
184
|
+
|
|
185
|
+
{/* edges first — they run under the boxes, like cabling under a floor */}
|
|
186
|
+
<g className="vz-iso-edges">
|
|
187
|
+
{canvas.edges.map((e) => {
|
|
188
|
+
const a = byId.get(e.fromNode)
|
|
189
|
+
const b = byId.get(e.toNode)
|
|
190
|
+
if (!a || !b) return null
|
|
191
|
+
const p1 = a.box.anchors[(e.fromSide ?? "bottom") as keyof typeof a.box.anchors]
|
|
192
|
+
const p2 = b.box.anchors[(e.toSide ?? "top") as keyof typeof b.box.anchors]
|
|
193
|
+
const mx = (p1.x + p2.x) / 2
|
|
194
|
+
const d = `M${p1.x},${p1.y} Q${mx},${p1.y} ${mx},${(p1.y + p2.y) / 2} T${p2.x},${p2.y}`
|
|
195
|
+
// THE HALO — grafted from FossFLOW Connector.tsx:107-125. Two stacked
|
|
196
|
+
// strokes: a ground-coloured halo at 1.4x width / 0.7 opacity beneath the
|
|
197
|
+
// line itself. Its A* grid is walls-free, so nothing avoids anything; the
|
|
198
|
+
// halo is what keeps a connector readable where it crosses a dense scene.
|
|
199
|
+
// Legibility solved at DRAW time, not at route time.
|
|
200
|
+
return (
|
|
201
|
+
<g key={e.id}>
|
|
202
|
+
<path className="vz-iso-edge-halo" d={d} />
|
|
203
|
+
<path className="vz-iso-edge" d={d} />
|
|
204
|
+
</g>
|
|
205
|
+
)
|
|
206
|
+
})}
|
|
207
|
+
</g>
|
|
208
|
+
|
|
209
|
+
{/* boxes, painted back to front */}
|
|
210
|
+
<g className="vz-iso-nodes">
|
|
211
|
+
{placed.map((p, i) => {
|
|
212
|
+
const ember = tintFor(p.griot, p.node)
|
|
213
|
+
const isSel = selectedId === p.node.id
|
|
214
|
+
const isLit = i === lit
|
|
215
|
+
const label = (p.node as any).label ?? p.node.id
|
|
216
|
+
const ax = (p.node as any).archify ?? {}
|
|
217
|
+
const match = resolveIcon(icons, { label: String(label), type: ax.type, brand: ax.brand })
|
|
218
|
+
const art = match ? placeIcon(match.icon, p.box.center, boxHeight) : null
|
|
219
|
+
return (
|
|
220
|
+
<g
|
|
221
|
+
key={p.node.id}
|
|
222
|
+
data-iso-node={p.node.id}
|
|
223
|
+
className={`vz-iso-node${isSel ? " sel" : ""}`}
|
|
224
|
+
onClick={(ev) => {
|
|
225
|
+
ev.stopPropagation()
|
|
226
|
+
onSelect?.(p.node.id)
|
|
227
|
+
}}
|
|
228
|
+
onDoubleClick={() => {
|
|
229
|
+
const o = p.griot?.ui?.origin
|
|
230
|
+
if (o && onReveal) void onReveal(o)
|
|
231
|
+
}}
|
|
232
|
+
>
|
|
233
|
+
{/* Lanshu's pulse, ruled decorative: aria-hidden, loop-only, never semantic */}
|
|
234
|
+
{isLit && (
|
|
235
|
+
<polygon
|
|
236
|
+
{...decorativeAttrs()}
|
|
237
|
+
points={p.box.top}
|
|
238
|
+
fill={ember}
|
|
239
|
+
opacity={0.25 + pulse * 0.45}
|
|
240
|
+
filter="url(#iso-glow)"
|
|
241
|
+
/>
|
|
242
|
+
)}
|
|
243
|
+
<polygon className="vz-iso-face left" points={p.box.left} fill={ember} />
|
|
244
|
+
<polygon className="vz-iso-face right" points={p.box.right} fill={ember} />
|
|
245
|
+
<polygon className="vz-iso-face top" points={p.box.top} fill={ember} />
|
|
246
|
+
{art && (
|
|
247
|
+
/* Two paths, never averaged: an isometric icon is ANCHORED and never
|
|
248
|
+
transformed; a flat vendor mark is PROJECTED into the plane as a
|
|
249
|
+
decal. Transform the first and it shears; leave the second flat and
|
|
250
|
+
it floats above the scene like a sticker. */
|
|
251
|
+
<image
|
|
252
|
+
className="vz-iso-icon"
|
|
253
|
+
href={art.href}
|
|
254
|
+
width={art.width}
|
|
255
|
+
x={art.x}
|
|
256
|
+
y={art.y}
|
|
257
|
+
transform={art.transform}
|
|
258
|
+
preserveAspectRatio="xMidYMax meet"
|
|
259
|
+
aria-hidden="true"
|
|
260
|
+
/>
|
|
261
|
+
)}
|
|
262
|
+
<text
|
|
263
|
+
className="vz-iso-label"
|
|
264
|
+
x={p.box.labelAt.x}
|
|
265
|
+
y={art ? p.box.labelAt.y + boxHeight + 16 : p.box.labelAt.y}
|
|
266
|
+
textAnchor="middle"
|
|
267
|
+
>
|
|
268
|
+
{wrapLabel(String(label)).map((line, li, all) => (
|
|
269
|
+
<tspan key={li} x={p.box.labelAt.x} dy={li === 0 ? -((all.length - 1) * 6) : 12}>
|
|
270
|
+
{line}
|
|
271
|
+
</tspan>
|
|
272
|
+
))}
|
|
273
|
+
</text>
|
|
274
|
+
</g>
|
|
275
|
+
)
|
|
276
|
+
})}
|
|
277
|
+
</g>
|
|
278
|
+
</svg>
|
|
279
|
+
|
|
280
|
+
{!placed.length && (
|
|
281
|
+
<p className="vz-empty vz-iso-empty">
|
|
282
|
+
Nothing to project. The isometric view renders the same JSON Canvas as the graph view —
|
|
283
|
+
load a source first.
|
|
284
|
+
</p>
|
|
285
|
+
)}
|
|
286
|
+
</div>
|
|
287
|
+
)
|
|
288
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The isometric icon system — TWO paths, because the packs are two kinds of thing.
|
|
3
|
+
*
|
|
4
|
+
* `@isoflow/isopacks` was a devDependency of FossFLOW and the first harvest wrote it off
|
|
5
|
+
* as "used only by examples." That was the wrong read: a cloud/server isometric without
|
|
6
|
+
* icons is coloured boxes with captions. Counted after installing:
|
|
7
|
+
*
|
|
8
|
+
* isoflow 37 icons ALL isometric — drawn to sit ON a tile
|
|
9
|
+
* aws 320 icons 0 isometric — flat vendor marks
|
|
10
|
+
* gcp 217 icons 0 isometric
|
|
11
|
+
* azure 448 icons 0 isometric
|
|
12
|
+
* kubernetes 40 icons 0 isometric
|
|
13
|
+
*
|
|
14
|
+
* So 37 of 1062 are genuinely isometric, and FossFLOW handles the split with two
|
|
15
|
+
* components rather than one. Both are reproduced here from source:
|
|
16
|
+
*
|
|
17
|
+
* ISOMETRIC (IconTypes/IsometricIcon.tsx) — never transformed, only ANCHORED.
|
|
18
|
+
* width = PROJECTED_TILE_SIZE.width * 0.8, top = -height, left = -width/2.
|
|
19
|
+
* Bottom-centre, so the art sits on the tile it belongs to.
|
|
20
|
+
*
|
|
21
|
+
* FLAT (IconTypes/NonIsometricIcon.tsx) — projected INTO the plane as a decal.
|
|
22
|
+
* matrix(0.707, -0.409, 0.707, 0.409, 0, -0.816) (renderer.ts:204)
|
|
23
|
+
* transformOrigin top-left, offset by -W/2/-H/2, image at 0.7 tile width.
|
|
24
|
+
*
|
|
25
|
+
* Transforming an already-isometric icon double-projects it and it reads as sheared;
|
|
26
|
+
* leaving a flat one unprojected makes it float above the scene like a sticker. The
|
|
27
|
+
* split is the whole trick, which is why it is preserved rather than averaged.
|
|
28
|
+
*
|
|
29
|
+
* Every icon is a self-contained `data:image/svg+xml;base64` URI — no network, so this
|
|
30
|
+
* works under the artifact CSP and offline.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import { PROJECTED_TILE_SIZE } from "./isometric"
|
|
34
|
+
|
|
35
|
+
export interface IsoIcon {
|
|
36
|
+
id: string
|
|
37
|
+
name: string
|
|
38
|
+
url: string
|
|
39
|
+
isIsometric?: boolean
|
|
40
|
+
pack: string
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** renderer.ts:204 — the decal projection, verbatim. */
|
|
44
|
+
export const ISO_MATRIX = [0.707, -0.409, 0.707, 0.409, 0, -0.816] as const
|
|
45
|
+
export const ISO_MATRIX_CSS = `matrix(${ISO_MATRIX.join(", ")})`
|
|
46
|
+
|
|
47
|
+
/** IsometricIcon.tsx — anchored art sits at 0.8 tile width. */
|
|
48
|
+
export const ANCHORED_SCALE = 0.8
|
|
49
|
+
/** NonIsometricIcon.tsx — a decal is smaller so the projection does not overrun the face. */
|
|
50
|
+
export const DECAL_SCALE = 0.7
|
|
51
|
+
|
|
52
|
+
// ── the catalogue, loaded lazily ──────────────────────────────────────────────
|
|
53
|
+
let CATALOGUE: IsoIcon[] | null = null
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Packs are dynamically imported so 1062 base64 icons never enter the initial bundle.
|
|
57
|
+
* A canvas with no icons pays nothing.
|
|
58
|
+
*/
|
|
59
|
+
/**
|
|
60
|
+
* Literal specifiers, not a template. A bundler cannot statically analyse
|
|
61
|
+
* `import(`...${p}.js`)` on a bare package name, so the first version resolved nothing
|
|
62
|
+
* and silently drew no icons — the failure mode a try/catch hides. Each entry is its own
|
|
63
|
+
* analysable dynamic import, so the packs still code-split.
|
|
64
|
+
*/
|
|
65
|
+
const PACK_LOADERS: Record<string, () => Promise<any>> = {
|
|
66
|
+
isoflow: () => import("@isoflow/isopacks/dist/isoflow.js"),
|
|
67
|
+
aws: () => import("@isoflow/isopacks/dist/aws.js"),
|
|
68
|
+
gcp: () => import("@isoflow/isopacks/dist/gcp.js"),
|
|
69
|
+
azure: () => import("@isoflow/isopacks/dist/azure.js"),
|
|
70
|
+
kubernetes: () => import("@isoflow/isopacks/dist/kubernetes.js"),
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function loadIcons(
|
|
74
|
+
packs: string[] = ["isoflow", "aws", "gcp", "azure", "kubernetes"]
|
|
75
|
+
): Promise<IsoIcon[]> {
|
|
76
|
+
if (CATALOGUE) return CATALOGUE
|
|
77
|
+
const out: IsoIcon[] = []
|
|
78
|
+
for (const p of packs) {
|
|
79
|
+
const load = PACK_LOADERS[p]
|
|
80
|
+
if (!load) continue
|
|
81
|
+
try {
|
|
82
|
+
const mod: any = await load()
|
|
83
|
+
const pack = mod.default ?? mod
|
|
84
|
+
for (const i of pack.icons ?? []) out.push({ ...i, pack: p })
|
|
85
|
+
} catch (e) {
|
|
86
|
+
// Say it. A silent catch here is what made the first version draw nothing while
|
|
87
|
+
// reporting success.
|
|
88
|
+
console.warn(`[prism-viz-engine] icon pack "${p}" failed to load`, e)
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
CATALOGUE = out
|
|
92
|
+
return out
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ── resolution ────────────────────────────────────────────────────────────────
|
|
96
|
+
/**
|
|
97
|
+
* Match a node to an icon by what it IS, not by a lookup table we maintain.
|
|
98
|
+
*
|
|
99
|
+
* Order matters: an explicit `brand` wins, then a vendor-prefixed name match, then the
|
|
100
|
+
* generic isoflow primitive for the component type. Falling through to no icon is a
|
|
101
|
+
* legitimate result — a wrong icon is worse than none, because it asserts a technology
|
|
102
|
+
* the diagram never claimed.
|
|
103
|
+
*/
|
|
104
|
+
const TYPE_TO_PRIMITIVE: Record<string, string> = {
|
|
105
|
+
database: "storage",
|
|
106
|
+
cloud: "cloud",
|
|
107
|
+
security: "firewall",
|
|
108
|
+
messagebus: "block",
|
|
109
|
+
frontend: "desktop",
|
|
110
|
+
backend: "server",
|
|
111
|
+
external: "user",
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const HINTS: Array<[RegExp, string]> = [
|
|
115
|
+
[/\bpostgres|mysql|sql|rds\b/i, "storage"],
|
|
116
|
+
[/\bredis|cache|memcach/i, "cache"],
|
|
117
|
+
[/\bdns|route ?53|cloudflare\b/i, "dns"],
|
|
118
|
+
[/\bfirewall|waf|security ?group|trust\b/i, "firewall"],
|
|
119
|
+
[/\bcdn|edge|worker\b/i, "cloud"],
|
|
120
|
+
[/\bqueue|bus|kafka|sqs|pubsub/i, "block"],
|
|
121
|
+
[/\blambda|function|serverless/i, "function-module"],
|
|
122
|
+
[/\bcron|schedul/i, "cronjob"],
|
|
123
|
+
[/\bbucket|s3|storage|volume|r2\b/i, "storage"],
|
|
124
|
+
[/\bdesktop|client|browser|ui\b/i, "desktop"],
|
|
125
|
+
[/\blaptop|dev\b/i, "laptop"],
|
|
126
|
+
[/\bdocument|doc|file\b/i, "document"],
|
|
127
|
+
]
|
|
128
|
+
|
|
129
|
+
export interface IconMatch {
|
|
130
|
+
icon: IsoIcon
|
|
131
|
+
/** why it matched — surfaced in the inspector so a wrong icon is traceable */
|
|
132
|
+
because: string
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function resolveIcon(
|
|
136
|
+
catalogue: IsoIcon[],
|
|
137
|
+
opts: { label?: string; type?: string; brand?: string }
|
|
138
|
+
): IconMatch | null {
|
|
139
|
+
if (!catalogue.length) return null
|
|
140
|
+
const norm = (s: string) => s.toLowerCase().replace(/[^a-z0-9]+/g, "-")
|
|
141
|
+
|
|
142
|
+
// 1. an explicit brand is an author's statement — honour it exactly
|
|
143
|
+
if (opts.brand) {
|
|
144
|
+
const b = norm(opts.brand)
|
|
145
|
+
const hit = catalogue.find((i) => norm(i.name) === b) ?? catalogue.find((i) => norm(i.name).endsWith("-" + b))
|
|
146
|
+
if (hit) return { icon: hit, because: `brand "${opts.brand}"` }
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// 2. the label naming a vendor service — "API Gateway" -> aws-api-gateway
|
|
150
|
+
if (opts.label) {
|
|
151
|
+
const l = norm(opts.label)
|
|
152
|
+
if (l.length > 3) {
|
|
153
|
+
const hit = catalogue.find((i) => i.pack !== "isoflow" && norm(i.name).endsWith("-" + l))
|
|
154
|
+
if (hit) return { icon: hit, because: `label matches ${hit.pack} "${hit.name}"` }
|
|
155
|
+
}
|
|
156
|
+
for (const [re, prim] of HINTS) {
|
|
157
|
+
if (re.test(opts.label)) {
|
|
158
|
+
const hit = catalogue.find((i) => i.pack === "isoflow" && norm(i.name) === prim)
|
|
159
|
+
if (hit) return { icon: hit, because: `label reads as ${prim}` }
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// 3. the component type -> a generic isometric primitive
|
|
165
|
+
const prim = opts.type ? TYPE_TO_PRIMITIVE[opts.type] : undefined
|
|
166
|
+
if (prim) {
|
|
167
|
+
const hit = catalogue.find((i) => i.pack === "isoflow" && norm(i.name) === prim)
|
|
168
|
+
if (hit) return { icon: hit, because: `type "${opts.type}"` }
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// 4. nothing honest to draw
|
|
172
|
+
return null
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Placement for an SVG <image>, per the icon's own kind. Returns the attributes the
|
|
177
|
+
* renderer needs rather than a component, so the same maths serves SVG and DOM.
|
|
178
|
+
*/
|
|
179
|
+
export function placeIcon(icon: IsoIcon, centre: { x: number; y: number }, boxHeight: number) {
|
|
180
|
+
const w = PROJECTED_TILE_SIZE.width * (icon.isIsometric ? ANCHORED_SCALE : DECAL_SCALE)
|
|
181
|
+
if (icon.isIsometric) {
|
|
182
|
+
// Anchored: bottom-centre on the tile's top face. Never transformed.
|
|
183
|
+
return {
|
|
184
|
+
href: icon.url,
|
|
185
|
+
width: w,
|
|
186
|
+
x: centre.x - w / 2,
|
|
187
|
+
y: centre.y - w * 0.86,
|
|
188
|
+
transform: undefined as string | undefined,
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
// Decal: projected into the plane, sitting on the lid.
|
|
192
|
+
return {
|
|
193
|
+
href: icon.url,
|
|
194
|
+
width: w,
|
|
195
|
+
x: 0,
|
|
196
|
+
y: 0,
|
|
197
|
+
transform: `translate(${centre.x - w / 2}, ${centre.y - boxHeight * 0.5 - w * 0.34}) ${ISO_MATRIX_CSS}`,
|
|
198
|
+
}
|
|
199
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Layer 02 — the isometric projection. GRAFTED from FossFLOW, reimplemented over our model.
|
|
3
|
+
*
|
|
4
|
+
* Isometric cloud/server views are part of this engine, not a separate tool. What is NOT
|
|
5
|
+
* lifted is the FossFLOW package: its scene model puts position on the VIEW as integer
|
|
6
|
+
* tile indices (`views.ts:7-11`) with no width/height on a node at all
|
|
7
|
+
* (`modelItems.ts:4-9`), so it cannot hold a JSON Canvas document — and adopting it drags
|
|
8
|
+
* MUI 5 + Emotion + GSAP + Paper + Quill + react-router-dom behind it.
|
|
9
|
+
*
|
|
10
|
+
* The maths is the valuable part and it is 20 lines. Read at source 2026-09-11 from
|
|
11
|
+
* `GriotSandbox/viz-generate/FossFLOW/packages/fossflow-lib/src/`:
|
|
12
|
+
*
|
|
13
|
+
* utils/renderer.ts:89-96 getTilePosition — the forward projection
|
|
14
|
+
* x = halfW * tile.x - halfW * tile.y
|
|
15
|
+
* y = -(halfH * tile.x + halfH * tile.y)
|
|
16
|
+
*
|
|
17
|
+
* utils/renderer.ts:51-78 screenToIso — the inverse
|
|
18
|
+
* config.ts:17-24 UNPROJECTED_TILE_SIZE = 100
|
|
19
|
+
* TILE_PROJECTION_MULTIPLIERS = { w: 1.415, h: 0.819 }
|
|
20
|
+
*
|
|
21
|
+
* Those multipliers are not arbitrary: 1.415 ~ sqrt(2) and 0.819 ~ sqrt(2)/sqrt(3), which
|
|
22
|
+
* is true 30-degree isometric. Zero CSS 3D and zero WebGL — it is a 2x2 matrix and an SVG
|
|
23
|
+
* polygon, which is exactly why it is worth having rather than depending on.
|
|
24
|
+
*
|
|
25
|
+
* THE ONE HONEST LOSS. Our nodes are pixel-positioned and sized; a tile lattice is
|
|
26
|
+
* integer-indexed and uniform. Converting quantises position and discards per-node size.
|
|
27
|
+
* That is a real lossy step, so it happens HERE, visibly, on the way into the view — the
|
|
28
|
+
* canvas document is never mutated, and switching back to the graph view restores exact
|
|
29
|
+
* positions because they were never overwritten.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
export const UNPROJECTED_TILE_SIZE = 100
|
|
33
|
+
export const TILE_PROJECTION_MULTIPLIERS = { width: 1.415, height: 0.819 } as const
|
|
34
|
+
export const PROJECTED_TILE_SIZE = {
|
|
35
|
+
width: UNPROJECTED_TILE_SIZE * TILE_PROJECTION_MULTIPLIERS.width,
|
|
36
|
+
height: UNPROJECTED_TILE_SIZE * TILE_PROJECTION_MULTIPLIERS.height,
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface Tile {
|
|
40
|
+
x: number
|
|
41
|
+
y: number
|
|
42
|
+
}
|
|
43
|
+
export interface Point {
|
|
44
|
+
x: number
|
|
45
|
+
y: number
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export type TileOrigin = "CENTER" | "TOP" | "BOTTOM" | "LEFT" | "RIGHT"
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Forward projection — tile lattice to screen. Grafted from `getTilePosition`
|
|
52
|
+
* (renderer.ts:89-96), including the five origin anchors, which is how a node's top face,
|
|
53
|
+
* base and side midpoints are addressed without a second set of maths.
|
|
54
|
+
*/
|
|
55
|
+
export function tileToScreen(
|
|
56
|
+
tile: Tile,
|
|
57
|
+
origin: TileOrigin = "CENTER",
|
|
58
|
+
size = PROJECTED_TILE_SIZE
|
|
59
|
+
): Point {
|
|
60
|
+
const halfW = size.width / 2
|
|
61
|
+
const halfH = size.height / 2
|
|
62
|
+
const p: Point = {
|
|
63
|
+
x: halfW * tile.x - halfW * tile.y,
|
|
64
|
+
y: -(halfH * tile.x + halfH * tile.y),
|
|
65
|
+
}
|
|
66
|
+
switch (origin) {
|
|
67
|
+
case "TOP":
|
|
68
|
+
return { x: p.x, y: p.y - halfH }
|
|
69
|
+
case "BOTTOM":
|
|
70
|
+
return { x: p.x, y: p.y + halfH }
|
|
71
|
+
case "LEFT":
|
|
72
|
+
return { x: p.x - halfW, y: p.y }
|
|
73
|
+
case "RIGHT":
|
|
74
|
+
return { x: p.x + halfW, y: p.y }
|
|
75
|
+
default:
|
|
76
|
+
return p
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Inverse — screen back to tile. Grafted from `screenToIso` (renderer.ts:51-78) with
|
|
82
|
+
* their scroll/rendererSize framing dropped, because our stage owns pan and zoom; this
|
|
83
|
+
* takes an already stage-local point.
|
|
84
|
+
*/
|
|
85
|
+
export function screenToTile(p: Point, size = PROJECTED_TILE_SIZE): Tile {
|
|
86
|
+
const halfW = size.width / 2
|
|
87
|
+
const halfH = size.height / 2
|
|
88
|
+
return {
|
|
89
|
+
x: Math.floor((p.x + halfW) / size.width - p.y / size.height),
|
|
90
|
+
y: -Math.floor((p.y + halfH) / size.height + p.x / size.width),
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The lossy step, made explicit. Pixel-space node position to a tile index, by dividing
|
|
96
|
+
* through a cell. `cell` is the pixel footprint one tile stands for — larger spreads the
|
|
97
|
+
* lattice out, smaller packs it. Callers pick it; we do not guess a "correct" value.
|
|
98
|
+
*/
|
|
99
|
+
export function pixelToTile(p: Point, cell = 220): Tile {
|
|
100
|
+
return { x: Math.round(p.x / cell), y: Math.round(p.y / cell) }
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* A node's isometric box, as three SVG polygons: the top face (a diamond) plus the two
|
|
105
|
+
* visible side faces extruded by `height`. This is the cloud/server-rack read — a flat
|
|
106
|
+
* diamond alone looks like a floor tile, the extrusion is what makes it a machine.
|
|
107
|
+
*/
|
|
108
|
+
export function isoBox(tile: Tile, height = 34, size = PROJECTED_TILE_SIZE) {
|
|
109
|
+
const c = tileToScreen(tile, "CENTER", size)
|
|
110
|
+
const halfW = size.width / 2
|
|
111
|
+
const halfH = size.height / 2
|
|
112
|
+
|
|
113
|
+
// top face, clockwise from the north vertex
|
|
114
|
+
const n = { x: c.x, y: c.y - halfH }
|
|
115
|
+
const e = { x: c.x + halfW, y: c.y }
|
|
116
|
+
const s = { x: c.x, y: c.y + halfH }
|
|
117
|
+
const w = { x: c.x - halfW, y: c.y }
|
|
118
|
+
|
|
119
|
+
const pts = (ps: Point[]) => ps.map((p) => `${p.x.toFixed(1)},${p.y.toFixed(1)}`).join(" ")
|
|
120
|
+
const down = (p: Point) => ({ x: p.x, y: p.y + height })
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
center: c,
|
|
124
|
+
/** the lid — this is what carries the node's colour */
|
|
125
|
+
top: pts([n, e, s, w]),
|
|
126
|
+
/** south-east wall */
|
|
127
|
+
right: pts([e, s, down(s), down(e)]),
|
|
128
|
+
/** south-west wall */
|
|
129
|
+
left: pts([w, s, down(s), down(w)]),
|
|
130
|
+
/** where a label sits so it reads flat, not skewed */
|
|
131
|
+
labelAt: { x: c.x, y: c.y + 4 },
|
|
132
|
+
/** edge anchor points, reusing the origin anchors rather than a second maths path */
|
|
133
|
+
anchors: {
|
|
134
|
+
top: tileToScreen(tile, "TOP", size),
|
|
135
|
+
right: tileToScreen(tile, "RIGHT", size),
|
|
136
|
+
bottom: { x: s.x, y: s.y + height },
|
|
137
|
+
left: tileToScreen(tile, "LEFT", size),
|
|
138
|
+
},
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Depth order. In an isometric scene a tile further "back" must paint first or it will
|
|
144
|
+
* draw over its neighbour's extrusion. Back is smaller (x+y). FossFLOW gets this from
|
|
145
|
+
* array order in its view; we compute it, since our document has no implicit z.
|
|
146
|
+
*/
|
|
147
|
+
export function depthSort<T extends { tile: Tile }>(items: T[]): T[] {
|
|
148
|
+
return [...items].sort((a, b) => a.tile.x + a.tile.y - (b.tile.x + b.tile.y))
|
|
149
|
+
}
|