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,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
+ }