@visualli/react 0.1.1

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.
Files changed (38) hide show
  1. package/README.md +335 -0
  2. package/dist/index.cjs +3262 -0
  3. package/dist/index.cjs.map +1 -0
  4. package/dist/index.d.cts +375 -0
  5. package/dist/index.d.ts +375 -0
  6. package/dist/index.js +3289 -0
  7. package/dist/index.js.map +1 -0
  8. package/package.json +48 -0
  9. package/src/VisualliCanvas.tsx +1087 -0
  10. package/src/VisualliRenderer.tsx +427 -0
  11. package/src/animations/index.ts +2 -0
  12. package/src/animations/konvaLayerTransition.ts +223 -0
  13. package/src/components/KonvaContainer.tsx +255 -0
  14. package/src/components/KonvaContainerLayer.tsx +63 -0
  15. package/src/components/KonvaEdge.tsx +254 -0
  16. package/src/components/KonvaEdgeLayer.tsx +72 -0
  17. package/src/components/KonvaNode.tsx +177 -0
  18. package/src/components/KonvaNodeLayer.tsx +61 -0
  19. package/src/components/KonvaStage.tsx +80 -0
  20. package/src/components/NavigationStack.tsx +184 -0
  21. package/src/components/SketchyBoxKonva.tsx +121 -0
  22. package/src/components/ZoomControls.tsx +110 -0
  23. package/src/config/blobShapes.ts +204 -0
  24. package/src/config/index.ts +21 -0
  25. package/src/config/textScaling.ts +69 -0
  26. package/src/context/VisualliContext.tsx +50 -0
  27. package/src/hooks/index.ts +7 -0
  28. package/src/hooks/useKonvaLayerTransition.ts +221 -0
  29. package/src/hooks/useKonvaRenderer.ts +185 -0
  30. package/src/hooks/useViewportNodes.ts +59 -0
  31. package/src/index.ts +81 -0
  32. package/src/konvaCompat.ts +29 -0
  33. package/src/stores/index.ts +3 -0
  34. package/src/stores/stores.ts +17 -0
  35. package/src/stores/useNodeStore.ts +101 -0
  36. package/src/stores/useViewportStore.ts +95 -0
  37. package/src/utils/index.ts +1 -0
  38. package/src/utils/layerNavigation.ts +15 -0
package/dist/index.js ADDED
@@ -0,0 +1,3289 @@
1
+ 'use client';
2
+
3
+ // src/VisualliRenderer.tsx
4
+ import { useEffect as useEffect7, useRef as useRef10, useState as useState4, useMemo as useMemo8 } from "react";
5
+ import { parseVisualliFile as parseVisualliFile2 } from "@visualli/core";
6
+
7
+ // src/VisualliCanvas.tsx
8
+ import { useRef as useRef9, useState as useState3, useCallback as useCallback4, useEffect as useEffect6, useMemo as useMemo7 } from "react";
9
+ import {
10
+ parseVisualliFile,
11
+ getNodesForLayer,
12
+ getThemeBackground,
13
+ darkenHexColor as darkenHexColor2,
14
+ RBushSpatialIndex as RBushSpatialIndex2,
15
+ NODE_HEIGHT as NODE_HEIGHT5,
16
+ TEXT_LABEL_HIDE_BELOW_ZOOM as TEXT_LABEL_HIDE_BELOW_ZOOM3,
17
+ ZOOM_NAV_IN_THRESHOLD,
18
+ ZOOM_NAV_OUT_THRESHOLD,
19
+ ZOOM_MIN as ZOOM_MIN2
20
+ } from "@visualli/core";
21
+
22
+ // src/stores/useNodeStore.ts
23
+ import { create } from "zustand";
24
+ function buildChildrenIndex(nodes) {
25
+ const index = /* @__PURE__ */ new Map();
26
+ for (const node of nodes.values()) {
27
+ const pid = node.parentId ?? null;
28
+ if (!index.has(pid)) index.set(pid, /* @__PURE__ */ new Set());
29
+ index.get(pid).add(node.id);
30
+ }
31
+ return index;
32
+ }
33
+ var useNodeStore = create((set, get) => ({
34
+ nodes: /* @__PURE__ */ new Map(),
35
+ childrenIndex: /* @__PURE__ */ new Map(),
36
+ setNodes: (nodes) => set({ nodes, childrenIndex: buildChildrenIndex(nodes) }),
37
+ updateNode: (nodeId, updates) => set((s) => {
38
+ const node = s.nodes.get(nodeId);
39
+ if (!node) return s;
40
+ const next = new Map(s.nodes);
41
+ next.set(nodeId, { ...node, ...updates, updatedAt: /* @__PURE__ */ new Date() });
42
+ return { nodes: next };
43
+ }),
44
+ updateNodePosition: (nodeId, x, y) => set((s) => {
45
+ const node = s.nodes.get(nodeId);
46
+ if (!node || node.x === x && node.y === y) return s;
47
+ const next = new Map(s.nodes);
48
+ next.set(nodeId, { ...node, x, y });
49
+ return { nodes: next };
50
+ }),
51
+ addNode: (node) => set((s) => {
52
+ const next = new Map(s.nodes);
53
+ next.set(node.id, node);
54
+ const idx = new Map(s.childrenIndex);
55
+ const pid = node.parentId ?? null;
56
+ if (!idx.has(pid)) idx.set(pid, /* @__PURE__ */ new Set());
57
+ idx.get(pid).add(node.id);
58
+ return { nodes: next, childrenIndex: idx };
59
+ }),
60
+ removeNode: (nodeId) => set((s) => {
61
+ const node = s.nodes.get(nodeId);
62
+ if (!node) return s;
63
+ const next = new Map(s.nodes);
64
+ next.delete(nodeId);
65
+ const idx = new Map(s.childrenIndex);
66
+ const pid = node.parentId ?? null;
67
+ if (idx.has(pid)) {
68
+ const set2 = new Set(idx.get(pid));
69
+ set2.delete(nodeId);
70
+ if (set2.size === 0) idx.delete(pid);
71
+ else idx.set(pid, set2);
72
+ }
73
+ return { nodes: next, childrenIndex: idx };
74
+ }),
75
+ setNodeExpanded: (nodeId, expanded) => set((s) => {
76
+ const node = s.nodes.get(nodeId);
77
+ if (!node) return s;
78
+ const next = new Map(s.nodes);
79
+ next.set(nodeId, { ...node, isExpanded: expanded, updatedAt: /* @__PURE__ */ new Date() });
80
+ return { nodes: next };
81
+ }),
82
+ getNode: (nodeId) => get().nodes.get(nodeId),
83
+ getChildren: (pid) => {
84
+ const { nodes, childrenIndex } = get();
85
+ return Array.from(childrenIndex.get(pid) ?? []).map((id) => nodes.get(id)).filter(Boolean);
86
+ },
87
+ getChildrenIds: (pid) => get().childrenIndex.get(pid) ?? /* @__PURE__ */ new Set(),
88
+ getAllNodes: () => Array.from(get().nodes.values())
89
+ }));
90
+
91
+ // src/stores/useViewportStore.ts
92
+ import { create as create2 } from "zustand";
93
+ import {
94
+ panViewport,
95
+ zoomViewport,
96
+ setViewportCenter,
97
+ recalculateViewportBounds,
98
+ clampZoom,
99
+ DEFAULT_CANVAS_WIDTH,
100
+ DEFAULT_CANVAS_HEIGHT,
101
+ worldToScreen,
102
+ screenToWorld,
103
+ ZOOM_DEFAULT
104
+ } from "@visualli/core";
105
+ function makeInitial(w, h) {
106
+ const vp = {
107
+ centerX: 0,
108
+ centerY: 0,
109
+ zoomLevel: ZOOM_DEFAULT,
110
+ rotation: 0,
111
+ visibleBounds: { minX: 0, minY: 0, maxX: 0, maxY: 0 }
112
+ };
113
+ return recalculateViewportBounds(vp, w, h);
114
+ }
115
+ var getVP = (s) => ({
116
+ centerX: s.centerX,
117
+ centerY: s.centerY,
118
+ zoomLevel: s.zoomLevel,
119
+ rotation: s.rotation,
120
+ visibleBounds: s.visibleBounds
121
+ });
122
+ var useViewportStore = create2((set, get) => {
123
+ const init = makeInitial(DEFAULT_CANVAS_WIDTH, DEFAULT_CANVAS_HEIGHT);
124
+ return {
125
+ ...init,
126
+ canvasWidth: DEFAULT_CANVAS_WIDTH,
127
+ canvasHeight: DEFAULT_CANVAS_HEIGHT,
128
+ pan: (dx, dy) => set((s) => {
129
+ const updated = recalculateViewportBounds(panViewport(dx, dy, getVP(s)), s.canvasWidth, s.canvasHeight);
130
+ return { centerX: updated.centerX, centerY: updated.centerY, visibleBounds: updated.visibleBounds };
131
+ }),
132
+ zoom: (delta, pivotX, pivotY) => set((s) => {
133
+ const updated = recalculateViewportBounds(
134
+ zoomViewport(delta, getVP(s), pivotX, pivotY, s.canvasWidth, s.canvasHeight),
135
+ s.canvasWidth,
136
+ s.canvasHeight
137
+ );
138
+ return { centerX: updated.centerX, centerY: updated.centerY, zoomLevel: updated.zoomLevel, visibleBounds: updated.visibleBounds };
139
+ }),
140
+ setCenter: (x, y) => set((s) => {
141
+ const updated = recalculateViewportBounds(setViewportCenter(x, y, getVP(s)), s.canvasWidth, s.canvasHeight);
142
+ return { centerX: updated.centerX, centerY: updated.centerY, visibleBounds: updated.visibleBounds };
143
+ }),
144
+ setZoom: (level) => set((s) => {
145
+ const clamped = clampZoom(level);
146
+ const updated = recalculateViewportBounds({ ...getVP(s), zoomLevel: clamped }, s.canvasWidth, s.canvasHeight);
147
+ return { zoomLevel: updated.zoomLevel, visibleBounds: updated.visibleBounds };
148
+ }),
149
+ updateCanvasSize: (w, h) => set((s) => {
150
+ const updated = recalculateViewportBounds(getVP(s), w, h);
151
+ return { canvasWidth: w, canvasHeight: h, visibleBounds: updated.visibleBounds };
152
+ }),
153
+ getVisibleBounds: () => get().visibleBounds,
154
+ worldToScreen: (x, y) => {
155
+ const s = get();
156
+ return worldToScreen(x, y, getVP(s), s.canvasWidth, s.canvasHeight);
157
+ },
158
+ screenToWorld: (x, y) => {
159
+ const s = get();
160
+ return screenToWorld(x, y, getVP(s), s.canvasWidth, s.canvasHeight);
161
+ }
162
+ };
163
+ });
164
+
165
+ // src/stores/stores.ts
166
+ import { create as create3 } from "zustand";
167
+ import { selectionStore, renderConfigStore } from "@visualli/core";
168
+ var useSelectionStore = create3((set, get, store) => {
169
+ selectionStore.subscribe((state) => set(state));
170
+ return selectionStore.getState();
171
+ });
172
+ var useRenderConfigStore = create3((set, get, store) => {
173
+ renderConfigStore.subscribe((state) => set(state));
174
+ return renderConfigStore.getState();
175
+ });
176
+
177
+ // src/hooks/useKonvaRenderer.ts
178
+ import { useEffect, useCallback, useRef, useState } from "react";
179
+ function useKonvaRenderer({
180
+ containerRef,
181
+ stageRef,
182
+ enabled = true
183
+ }) {
184
+ const pan = useViewportStore((s) => s.pan);
185
+ const zoom = useViewportStore((s) => s.zoom);
186
+ const setZoom = useViewportStore((s) => s.setZoom);
187
+ const setCenter = useViewportStore((s) => s.setCenter);
188
+ const updateCanvasSize = useViewportStore((s) => s.updateCanvasSize);
189
+ const autoAdjust = useRenderConfigStore((s) => s.autoAdjustQuality);
190
+ const [canvasWidth, setCanvasWidth] = useState(0);
191
+ const [canvasHeight, setCanvasHeight] = useState(0);
192
+ const cachedSizeRef = useRef({ width: 0, height: 0 });
193
+ const isPanningRef = useRef(false);
194
+ const lastPanPointRef = useRef({ x: 0, y: 0 });
195
+ const panAccumRef = useRef({ x: 0, y: 0 });
196
+ const fpsRef = useRef({ frames: 0, last: performance.now() });
197
+ const fpsRafId = useRef(null);
198
+ useEffect(() => {
199
+ if (!enabled) return;
200
+ const tick = (now) => {
201
+ fpsRef.current.frames++;
202
+ if (now - fpsRef.current.last >= 2e3) {
203
+ const fps = fpsRef.current.frames / ((now - fpsRef.current.last) / 1e3) | 0;
204
+ autoAdjust(fps);
205
+ fpsRef.current = { frames: 0, last: now };
206
+ }
207
+ fpsRafId.current = requestAnimationFrame(tick);
208
+ };
209
+ fpsRafId.current = requestAnimationFrame(tick);
210
+ return () => {
211
+ if (fpsRafId.current !== null) cancelAnimationFrame(fpsRafId.current);
212
+ };
213
+ }, [enabled, autoAdjust]);
214
+ useEffect(() => {
215
+ if (!enabled) return;
216
+ let rafId = null;
217
+ const measure = () => {
218
+ const el = containerRef.current;
219
+ const w = el ? Math.round(el.getBoundingClientRect().width) : 800;
220
+ const h = el ? Math.round(el.getBoundingClientRect().height) : 600;
221
+ return { w, h };
222
+ };
223
+ const updateSize = () => {
224
+ if (rafId) cancelAnimationFrame(rafId);
225
+ rafId = requestAnimationFrame(() => {
226
+ rafId = null;
227
+ const { w, h } = measure();
228
+ if (w > 0 && h > 0 && (cachedSizeRef.current.width !== w || cachedSizeRef.current.height !== h)) {
229
+ cachedSizeRef.current = { width: w, height: h };
230
+ setCanvasWidth(w);
231
+ setCanvasHeight(h);
232
+ updateCanvasSize(w, h);
233
+ }
234
+ });
235
+ };
236
+ updateSize();
237
+ let ro = null;
238
+ if (typeof ResizeObserver !== "undefined" && containerRef.current) {
239
+ ro = new ResizeObserver(updateSize);
240
+ ro.observe(containerRef.current);
241
+ } else {
242
+ window.addEventListener("resize", updateSize);
243
+ }
244
+ return () => {
245
+ ro ? ro.disconnect() : window.removeEventListener("resize", updateSize);
246
+ if (rafId) cancelAnimationFrame(rafId);
247
+ };
248
+ }, [enabled, containerRef, updateCanvasSize]);
249
+ const handleWheel = useCallback((e) => {
250
+ e.evt.preventDefault();
251
+ const stage = e.target.getStage();
252
+ if (!stage) return;
253
+ const pointer = stage.getPointerPosition();
254
+ if (!pointer) return;
255
+ const delta = -e.evt.deltaY / 1e3;
256
+ zoom(delta, pointer.x, pointer.y);
257
+ }, [zoom]);
258
+ const handleMouseDown = useCallback((e) => {
259
+ const clickedOnEmpty = e.target === e.target.getStage();
260
+ if (clickedOnEmpty && e.evt.button === 0) {
261
+ e.evt.preventDefault();
262
+ isPanningRef.current = true;
263
+ lastPanPointRef.current = { x: e.evt.clientX, y: e.evt.clientY };
264
+ panAccumRef.current = { x: 0, y: 0 };
265
+ const stage = e.target.getStage();
266
+ if (stage) stage.container().style.cursor = "grabbing";
267
+ }
268
+ }, []);
269
+ const handleMouseMove = useCallback((e) => {
270
+ if (!isPanningRef.current) {
271
+ const isOverEmpty = e.target === e.target.getStage();
272
+ const stage2 = e.target.getStage();
273
+ if (stage2) stage2.container().style.cursor = isOverEmpty ? "grab" : "default";
274
+ return;
275
+ }
276
+ e.evt.preventDefault();
277
+ const deltaX = e.evt.clientX - lastPanPointRef.current.x;
278
+ const deltaY = e.evt.clientY - lastPanPointRef.current.y;
279
+ const stage = e.target.getStage();
280
+ if (stage) {
281
+ stage.x(stage.x() + deltaX);
282
+ stage.y(stage.y() + deltaY);
283
+ stage.batchDraw();
284
+ }
285
+ panAccumRef.current.x += deltaX;
286
+ panAccumRef.current.y += deltaY;
287
+ lastPanPointRef.current = { x: e.evt.clientX, y: e.evt.clientY };
288
+ }, []);
289
+ const handleMouseUp = useCallback((e) => {
290
+ if (isPanningRef.current) {
291
+ isPanningRef.current = false;
292
+ const { x, y } = panAccumRef.current;
293
+ if (x !== 0 || y !== 0) {
294
+ pan(x, y);
295
+ panAccumRef.current = { x: 0, y: 0 };
296
+ }
297
+ const stage = e.target.getStage();
298
+ if (stage) stage.container().style.cursor = "grab";
299
+ }
300
+ }, [pan]);
301
+ return { handleWheel, handleMouseDown, handleMouseMove, handleMouseUp, canvasWidth, canvasHeight };
302
+ }
303
+
304
+ // src/hooks/useKonvaLayerTransition.ts
305
+ import { useCallback as useCallback2, useRef as useRef2, useEffect as useEffect2 } from "react";
306
+
307
+ // src/animations/konvaLayerTransition.ts
308
+ import { easeInOutSine, easeOutCubic, easeZoomTransition } from "@visualli/core";
309
+ import { ANIMATION_PHASES } from "@visualli/core";
310
+ function parseColor(color) {
311
+ if (color.startsWith("#")) {
312
+ const h = color.slice(1);
313
+ return { r: parseInt(h.slice(0, 2), 16), g: parseInt(h.slice(2, 4), 16), b: parseInt(h.slice(4, 6), 16) };
314
+ }
315
+ const m = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
316
+ return m ? { r: +m[1], g: +m[2], b: +m[3] } : { r: 255, g: 255, b: 255 };
317
+ }
318
+ function interpolateRgb(a, b, t) {
319
+ const e = easeInOutSine(t);
320
+ return `rgb(${a.r + (b.r - a.r) * e + 0.5 | 0},${a.g + (b.g - a.g) * e + 0.5 | 0},${a.b + (b.b - a.b) * e + 0.5 | 0})`;
321
+ }
322
+ function lerp(a, b, t) {
323
+ return a + (b - a) * t;
324
+ }
325
+ var KonvaLayerTransitionAnimator = class {
326
+ constructor() {
327
+ this.rafId = null;
328
+ this.timers = [];
329
+ this._active = false;
330
+ }
331
+ get active() {
332
+ return this._active;
333
+ }
334
+ cancel() {
335
+ if (this.rafId !== null) {
336
+ cancelAnimationFrame(this.rafId);
337
+ this.rafId = null;
338
+ }
339
+ this.timers.forEach(clearTimeout);
340
+ this.timers = [];
341
+ this._active = false;
342
+ }
343
+ // ── Zoom into child layer ──────────────────────────────────────────────────
344
+ animateZoomIntoLayer(current, targetNode, childCenter, fromColor, toColor, cb, targetZoom = 1, canvasWidth = 800, canvasHeight = 600) {
345
+ if (this._active) return;
346
+ this._active = true;
347
+ const ZOOM_DURATION = ANIMATION_PHASES.ZOOM_IN.ZOOM_TO_TARGET;
348
+ const FADE_OUT_DELAY = ANIMATION_PHASES.ZOOM_IN.FADE_OUT_START;
349
+ const FADE_OUT_DURATION = ANIMATION_PHASES.ZOOM_IN.FADE_OUT;
350
+ const SWAP_AT_MS = ANIMATION_PHASES.ZOOM_IN.CONTENT_SWAP;
351
+ const FADE_IN_DURATION = ANIMATION_PHASES.ZOOM_IN.FADE_IN;
352
+ const SETTLE_FACTOR = 0.94;
353
+ const z0 = current.zoomLevel;
354
+ const dx = targetNode.x - current.centerX;
355
+ const dy = targetNode.y - current.centerY;
356
+ const vmin = Math.min(canvasWidth, canvasHeight);
357
+ const fillZoom = Math.max(4, vmin * 1.5 / Math.max(targetNode.width, 100));
358
+ const fromRgb = parseColor(fromColor);
359
+ const toRgb = parseColor(toColor);
360
+ const state = { viewport: { centerX: 0, centerY: 0, zoomLevel: 0 }, opacity: 1, backgroundColor: fromColor };
361
+ const start = performance.now();
362
+ let swapped = false;
363
+ let fadeInStart = 0;
364
+ cb.onPhaseChange?.("zoom");
365
+ const tick = (now) => {
366
+ if (!this._active) return;
367
+ const elapsed = now - start;
368
+ if (!swapped) {
369
+ if (elapsed >= SWAP_AT_MS) {
370
+ swapped = true;
371
+ fadeInStart = now;
372
+ state.viewport = { centerX: childCenter.x, centerY: childCenter.y, zoomLevel: targetZoom * SETTLE_FACTOR };
373
+ state.opacity = 0;
374
+ state.backgroundColor = toColor;
375
+ cb.onUpdate(state);
376
+ cb.onPhaseChange?.("swap");
377
+ this.rafId = requestAnimationFrame(tick);
378
+ return;
379
+ }
380
+ const rawT = Math.min(elapsed / ZOOM_DURATION, 1);
381
+ const zoomT = easeZoomTransition(rawT);
382
+ const zl = lerp(z0, fillZoom, zoomT);
383
+ state.viewport = {
384
+ centerX: targetNode.x - dx * z0 * (1 - zoomT) / zl,
385
+ centerY: targetNode.y - dy * z0 * (1 - zoomT) / zl,
386
+ zoomLevel: zl
387
+ };
388
+ state.opacity = elapsed > FADE_OUT_DELAY ? Math.max(0, 1 - easeOutCubic(Math.min((elapsed - FADE_OUT_DELAY) / FADE_OUT_DURATION, 1))) : 1;
389
+ state.backgroundColor = interpolateRgb(fromRgb, toRgb, rawT);
390
+ cb.onUpdate(state);
391
+ } else {
392
+ const fi = now - fadeInStart;
393
+ const ft = Math.min(fi / FADE_IN_DURATION, 1);
394
+ const eased = easeOutCubic(ft);
395
+ state.viewport = { centerX: childCenter.x, centerY: childCenter.y, zoomLevel: lerp(targetZoom * SETTLE_FACTOR, targetZoom, eased) };
396
+ state.opacity = eased;
397
+ state.backgroundColor = toColor;
398
+ cb.onUpdate(state);
399
+ if (ft >= 1) {
400
+ cb.onPhaseChange?.("fade-in");
401
+ this.rafId = null;
402
+ this._complete(cb.onComplete);
403
+ return;
404
+ }
405
+ }
406
+ this.rafId = requestAnimationFrame(tick);
407
+ };
408
+ this.rafId = requestAnimationFrame(tick);
409
+ }
410
+ // ── Zoom out to parent layer ───────────────────────────────────────────────
411
+ animateZoomOutToParent(current, parent, fromColor, toColor, cb) {
412
+ if (this._active) return;
413
+ this._active = true;
414
+ const FADE_OUT = ANIMATION_PHASES.ZOOM_OUT.FADE_OUT;
415
+ const FADE_IN = ANIMATION_PHASES.ZOOM_OUT.FADE_IN;
416
+ const OPACITY_SETTLE = 66;
417
+ const fromRgb = parseColor(fromColor);
418
+ const toRgb = parseColor(toColor);
419
+ const state = { viewport: { centerX: 0, centerY: 0, zoomLevel: 0 }, opacity: 1, backgroundColor: fromColor };
420
+ const start = performance.now();
421
+ let swapped = false;
422
+ let fadeInStart = 0;
423
+ cb.onPhaseChange?.("fade-out");
424
+ const tick = (now) => {
425
+ if (!this._active) return;
426
+ const elapsed = now - start;
427
+ if (!swapped) {
428
+ if (elapsed >= FADE_OUT) {
429
+ swapped = true;
430
+ fadeInStart = now;
431
+ state.viewport = { centerX: parent.centerX, centerY: parent.centerY, zoomLevel: parent.zoomLevel * 5 };
432
+ state.opacity = 0;
433
+ state.backgroundColor = toColor;
434
+ cb.onUpdate(state);
435
+ cb.onPhaseChange?.("zoom-back");
436
+ this.rafId = requestAnimationFrame(tick);
437
+ return;
438
+ }
439
+ const rawT = Math.min(elapsed / FADE_OUT, 1);
440
+ const t = easeOutCubic(rawT);
441
+ state.viewport = { centerX: current.centerX, centerY: current.centerY, zoomLevel: lerp(current.zoomLevel, current.zoomLevel * 0.6, t) };
442
+ state.opacity = 1 - t;
443
+ state.backgroundColor = interpolateRgb(fromRgb, toRgb, rawT);
444
+ cb.onUpdate(state);
445
+ } else {
446
+ const fi = now - fadeInStart;
447
+ const rawT = Math.min(fi / FADE_IN, 1);
448
+ const t = easeZoomTransition(rawT);
449
+ state.viewport = { centerX: parent.centerX, centerY: parent.centerY, zoomLevel: lerp(parent.zoomLevel * 5, parent.zoomLevel, t) };
450
+ state.opacity = fi < OPACITY_SETTLE ? 0 : easeZoomTransition(Math.min((fi - OPACITY_SETTLE) / (FADE_IN - OPACITY_SETTLE), 1));
451
+ state.backgroundColor = toColor;
452
+ cb.onUpdate(state);
453
+ if (rawT >= 1) {
454
+ cb.onPhaseChange?.("fade-in");
455
+ this.rafId = null;
456
+ this._complete(cb.onComplete);
457
+ return;
458
+ }
459
+ }
460
+ this.rafId = requestAnimationFrame(tick);
461
+ };
462
+ this.rafId = requestAnimationFrame(tick);
463
+ }
464
+ _complete(onComplete) {
465
+ this.timers.forEach(clearTimeout);
466
+ this.timers = [];
467
+ this._active = false;
468
+ onComplete();
469
+ }
470
+ };
471
+ var konvaLayerTransitionAnimator = new KonvaLayerTransitionAnimator();
472
+
473
+ // src/utils/layerNavigation.ts
474
+ import {
475
+ getConnectionsForLayer,
476
+ getChildLayerForNode,
477
+ getLayerForNavigation,
478
+ calculateFitZoom,
479
+ calculateFitCenter,
480
+ getContainersForLayer,
481
+ calculateFitView
482
+ } from "@visualli/core";
483
+
484
+ // src/hooks/useKonvaLayerTransition.ts
485
+ function useKonvaLayerTransition(opts) {
486
+ const {
487
+ stageRef,
488
+ canvasWrapperRef,
489
+ bgRef,
490
+ onSwapLayer,
491
+ onSwapBack,
492
+ onComplete,
493
+ onViewportFrame,
494
+ onViewportFinal,
495
+ onPhaseChange: onPhaseChangeExt,
496
+ onTransitionLifecycle
497
+ } = opts;
498
+ const setZoom = useViewportStore((s) => s.setZoom);
499
+ const setCenter = useViewportStore((s) => s.setCenter);
500
+ const getState = useViewportStore.getState;
501
+ const swappedRef = useRef2(false);
502
+ const canvasSizeRef = useRef2({ width: 800, height: 600 });
503
+ useEffect2(() => {
504
+ const update = () => {
505
+ const vp = useViewportStore.getState();
506
+ canvasSizeRef.current = { width: vp.canvasWidth || 800, height: vp.canvasHeight || 600 };
507
+ };
508
+ update();
509
+ const unsubscribe = useViewportStore.subscribe(update);
510
+ return unsubscribe;
511
+ }, []);
512
+ const applyViewport = useCallback2((vp) => {
513
+ const stage = stageRef.current;
514
+ if (!stage) return;
515
+ const { width, height } = canvasSizeRef.current;
516
+ stage.x(width / 2 - vp.centerX * vp.zoomLevel);
517
+ stage.y(height / 2 - vp.centerY * vp.zoomLevel);
518
+ stage.scaleX(vp.zoomLevel);
519
+ stage.scaleY(vp.zoomLevel);
520
+ stage.getLayers().forEach((l) => l.drawScene());
521
+ setCenter(vp.centerX, vp.centerY);
522
+ setZoom(vp.zoomLevel);
523
+ }, [stageRef, setCenter, setZoom]);
524
+ const applyOpacity = useCallback2((opacity) => {
525
+ if (canvasWrapperRef.current) {
526
+ canvasWrapperRef.current.style.opacity = String(opacity);
527
+ }
528
+ }, [canvasWrapperRef]);
529
+ const applyBgColor = useCallback2((color) => {
530
+ if (bgRef?.current) {
531
+ bgRef.current.style.backgroundColor = color;
532
+ }
533
+ }, [bgRef]);
534
+ const zoomIntoLayer = useCallback2((_doc, targetNode, childLayerId, childNodes, fromColor, toColor) => {
535
+ if (konvaLayerTransitionAnimator.active) return;
536
+ const vp = getState();
537
+ const stage = stageRef.current;
538
+ const w = stage?.width() ?? 800;
539
+ const h = stage?.height() ?? 600;
540
+ const fit = calculateFitView(childNodes, w, h);
541
+ swappedRef.current = false;
542
+ onTransitionLifecycle?.("start");
543
+ konvaLayerTransitionAnimator.animateZoomIntoLayer(
544
+ { centerX: vp.centerX, centerY: vp.centerY, zoomLevel: vp.zoomLevel },
545
+ { x: targetNode.x, y: targetNode.y, width: targetNode.width, height: targetNode.height },
546
+ { x: fit.centerX, y: fit.centerY },
547
+ fromColor,
548
+ toColor,
549
+ {
550
+ onUpdate: ({ viewport, opacity, backgroundColor }) => {
551
+ if (onViewportFrame) {
552
+ onViewportFrame(viewport);
553
+ } else {
554
+ applyViewport(viewport);
555
+ }
556
+ applyOpacity(opacity);
557
+ applyBgColor(backgroundColor);
558
+ },
559
+ onComplete: () => {
560
+ const finalVp = { centerX: fit.centerX, centerY: fit.centerY, zoomLevel: fit.zoomLevel };
561
+ if (onViewportFinal) {
562
+ onViewportFinal(finalVp);
563
+ } else {
564
+ applyViewport(finalVp);
565
+ }
566
+ applyOpacity(1);
567
+ onComplete();
568
+ onTransitionLifecycle?.("end");
569
+ },
570
+ onPhaseChange: (phase) => {
571
+ onPhaseChangeExt?.(phase);
572
+ if (phase === "swap" && !swappedRef.current) {
573
+ swappedRef.current = true;
574
+ onSwapLayer(childLayerId);
575
+ }
576
+ }
577
+ },
578
+ fit.zoomLevel,
579
+ w,
580
+ h
581
+ );
582
+ }, [stageRef, getState, applyViewport, applyOpacity, applyBgColor, onSwapLayer, onComplete, onViewportFrame, onViewportFinal, onPhaseChangeExt, onTransitionLifecycle]);
583
+ const zoomOutToParent = useCallback2((parentViewport, fromColor, toColor) => {
584
+ if (konvaLayerTransitionAnimator.active) return;
585
+ const vp = getState();
586
+ swappedRef.current = false;
587
+ onTransitionLifecycle?.("start");
588
+ konvaLayerTransitionAnimator.animateZoomOutToParent(
589
+ { centerX: vp.centerX, centerY: vp.centerY, zoomLevel: vp.zoomLevel },
590
+ parentViewport,
591
+ fromColor,
592
+ toColor,
593
+ {
594
+ onUpdate: ({ viewport, opacity, backgroundColor }) => {
595
+ if (onViewportFrame) {
596
+ onViewportFrame(viewport);
597
+ } else {
598
+ applyViewport(viewport);
599
+ }
600
+ applyOpacity(opacity);
601
+ applyBgColor(backgroundColor);
602
+ },
603
+ onComplete: () => {
604
+ if (onViewportFinal) {
605
+ onViewportFinal(parentViewport);
606
+ } else {
607
+ applyViewport(parentViewport);
608
+ }
609
+ applyOpacity(1);
610
+ onComplete();
611
+ onTransitionLifecycle?.("end");
612
+ },
613
+ onPhaseChange: (phase) => {
614
+ onPhaseChangeExt?.(phase);
615
+ if (phase === "zoom-back" && !swappedRef.current) {
616
+ swappedRef.current = true;
617
+ onSwapBack();
618
+ }
619
+ }
620
+ }
621
+ );
622
+ }, [getState, applyViewport, applyOpacity, applyBgColor, onSwapBack, onComplete, onViewportFrame, onViewportFinal, onPhaseChangeExt, onTransitionLifecycle]);
623
+ const isTransitioning = useCallback2(() => konvaLayerTransitionAnimator.active, []);
624
+ useEffect2(() => () => {
625
+ konvaLayerTransitionAnimator.cancel();
626
+ }, []);
627
+ return { zoomIntoLayer, zoomOutToParent, isTransitioning };
628
+ }
629
+
630
+ // src/components/KonvaStage.tsx
631
+ import { useRef as useRef3, useEffect as useEffect3, forwardRef } from "react";
632
+
633
+ // src/konvaCompat.ts
634
+ import {
635
+ Stage,
636
+ Layer,
637
+ Group,
638
+ Shape,
639
+ Path,
640
+ Rect,
641
+ Text,
642
+ Arrow,
643
+ Line,
644
+ Circle,
645
+ TextPath
646
+ } from "react-konva";
647
+ var KStage = Stage;
648
+ var KLayer = Layer;
649
+ var KGroup = Group;
650
+ var KShape = Shape;
651
+ var KRect = Rect;
652
+ var KText = Text;
653
+ var KLine = Line;
654
+ var KTextPath = TextPath;
655
+
656
+ // src/components/KonvaStage.tsx
657
+ import { jsx } from "react/jsx-runtime";
658
+ var KonvaStage = forwardRef(function KonvaStage2({
659
+ children,
660
+ onWheel,
661
+ onMouseDown,
662
+ onMouseMove,
663
+ onMouseUp,
664
+ onContextMenu,
665
+ onTouchStart,
666
+ onTouchMove,
667
+ onTouchEnd,
668
+ onStageReady
669
+ }, ref) {
670
+ const internalRef = useRef3(null);
671
+ const { centerX, centerY, zoomLevel, canvasWidth, canvasHeight } = useViewportStore();
672
+ useEffect3(() => {
673
+ if (internalRef.current) {
674
+ if (typeof ref === "function") ref(internalRef.current);
675
+ else if (ref) ref.current = internalRef.current;
676
+ onStageReady?.(internalRef.current);
677
+ }
678
+ }, []);
679
+ useEffect3(() => {
680
+ return () => {
681
+ internalRef.current?.destroy();
682
+ };
683
+ }, []);
684
+ const w = canvasWidth || 800;
685
+ const h = canvasHeight || 600;
686
+ const stageX = w / 2 - centerX * zoomLevel;
687
+ const stageY = h / 2 - centerY * zoomLevel;
688
+ return /* @__PURE__ */ jsx(
689
+ KStage,
690
+ {
691
+ ref: internalRef,
692
+ width: w,
693
+ height: h,
694
+ x: stageX,
695
+ y: stageY,
696
+ scaleX: zoomLevel,
697
+ scaleY: zoomLevel,
698
+ onWheel,
699
+ onMouseDown,
700
+ onMouseMove,
701
+ onMouseUp,
702
+ onContextMenu,
703
+ onTouchStart,
704
+ onTouchMove,
705
+ onTouchEnd,
706
+ draggable: false,
707
+ pixelRatio: 1,
708
+ children
709
+ }
710
+ );
711
+ });
712
+ var KonvaStage_default = KonvaStage;
713
+
714
+ // src/components/KonvaNodeLayer.tsx
715
+ import { useRef as useRef5, useLayoutEffect, useEffect as useEffect4, useMemo as useMemo3 } from "react";
716
+
717
+ // src/hooks/useViewportNodes.ts
718
+ import { useMemo, useRef as useRef4 } from "react";
719
+ import { RBushSpatialIndex, getViewportVisibleNodes } from "@visualli/core";
720
+ var SPATIAL_INDEX_THRESHOLD = 200;
721
+ function useViewportNodes(nodes, levelFilter, skipCulling = false, isDragging = false) {
722
+ const { centerX, centerY, zoomLevel, canvasWidth, canvasHeight } = useViewportStore();
723
+ const indexRef = useRef4(null);
724
+ const nodesHashRef = useRef4(0);
725
+ const nodesHash = nodes.length;
726
+ if (nodesHash !== nodesHashRef.current) {
727
+ nodesHashRef.current = nodesHash;
728
+ indexRef.current = null;
729
+ }
730
+ return useMemo(() => {
731
+ if (nodes.length === 0) return [];
732
+ if (!indexRef.current && nodes.length >= SPATIAL_INDEX_THRESHOLD && !skipCulling && !isDragging) {
733
+ indexRef.current = new RBushSpatialIndex();
734
+ indexRef.current.bulkLoad(nodes.map((n) => ({
735
+ nodeId: n.id,
736
+ bounds: { minX: n.x, minY: n.y, maxX: n.x + n.width, maxY: n.y + n.height }
737
+ })));
738
+ }
739
+ return getViewportVisibleNodes({
740
+ nodes,
741
+ viewport: { centerX, centerY, zoomLevel, rotation: 0, visibleBounds: { minX: 0, minY: 0, maxX: 0, maxY: 0 } },
742
+ canvasWidth,
743
+ canvasHeight,
744
+ levelFilter,
745
+ skipCulling,
746
+ isDragging,
747
+ spatialIndex: indexRef.current,
748
+ spatialIndexThreshold: SPATIAL_INDEX_THRESHOLD
749
+ });
750
+ }, [nodes, levelFilter, skipCulling, isDragging, centerX, centerY, zoomLevel, canvasWidth, canvasHeight]);
751
+ }
752
+
753
+ // src/components/KonvaNode.tsx
754
+ import React2, { useMemo as useMemo2 } from "react";
755
+ import { CANVAS_COLORS, NODE_HEIGHT, TEXT_LABEL_HIDE_BELOW_ZOOM, darkenHexColor } from "@visualli/core";
756
+
757
+ // src/config/index.ts
758
+ import {
759
+ ALL_BLOB_SHAPES as ALL_BLOB_SHAPES2,
760
+ ACTIVE_BLOB_TYPES,
761
+ BLOB_LAYER_CONFIG,
762
+ NODE_LAYER_CONFIG,
763
+ getBlobTypeForLayer,
764
+ BLOB_TEXT_OFFSETS,
765
+ drawBlobPath,
766
+ NODE_TEXT_BASE_FONT_PX,
767
+ DESCRIPTION_TEXT_BASE_FONT_PX,
768
+ CONTAINER_LABEL_BASE_FONT_PX,
769
+ EDGE_LABEL_BASE_FONT_PX,
770
+ OVERLAY_FONT_UPPER_BOOST_PX,
771
+ computeNodeTextWorldScale,
772
+ computeNodeTextScreenScale,
773
+ computeOverlayScale,
774
+ computeEdgeLabelScale
775
+ } from "@visualli/core";
776
+
777
+ // src/config/blobShapes.ts
778
+ var ALL_BLOB_SHAPES = [
779
+ // Shape 0 · Blob 1 (root / L0)
780
+ [
781
+ { x: 1.12, y: 0 },
782
+ { x: 0.86, y: 0.56 },
783
+ { x: 0.46, y: 0.94 },
784
+ { x: -0.08, y: 1.06 },
785
+ { x: -0.68, y: 0.82 },
786
+ { x: -0.96, y: 0.08 },
787
+ { x: -0.55, y: -0.82 },
788
+ { x: 0.04, y: -1.02 },
789
+ { x: 0.72, y: -0.8 },
790
+ { x: 1.08, y: -0.38 }
791
+ ],
792
+ // Shape 1 · Blob 2
793
+ [
794
+ { x: -0.489, y: -0.625 },
795
+ { x: -0.384, y: -0.716 },
796
+ { x: -0.285, y: -0.792 },
797
+ { x: -0.189, y: -0.855 },
798
+ { x: -0.09, y: -0.903 },
799
+ { x: 0.014, y: -0.932 },
800
+ { x: 0.129, y: -0.94 },
801
+ { x: 0.255, y: -0.924 },
802
+ { x: 0.412, y: -0.876 },
803
+ { x: 0.516, y: -0.832 },
804
+ { x: 0.612, y: -0.78 },
805
+ { x: 0.695, y: -0.725 },
806
+ { x: 0.767, y: -0.666 },
807
+ { x: 0.83, y: -0.606 },
808
+ { x: 0.885, y: -0.546 },
809
+ { x: 0.933, y: -0.486 },
810
+ { x: 0.979, y: -0.425 },
811
+ { x: 1.006, y: -0.385 },
812
+ { x: 1.034, y: -0.334 },
813
+ { x: 1.058, y: -0.276 },
814
+ { x: 1.078, y: -0.209 },
815
+ { x: 1.092, y: -0.133 },
816
+ { x: 1.101, y: -0.046 },
817
+ { x: 1.102, y: 0.052 },
818
+ { x: 1.094, y: 0.173 },
819
+ { x: 1.083, y: 0.244 },
820
+ { x: 1.063, y: 0.324 },
821
+ { x: 1.034, y: 0.401 },
822
+ { x: 0.996, y: 0.477 },
823
+ { x: 0.948, y: 0.549 },
824
+ { x: 0.889, y: 0.617 },
825
+ { x: 0.82, y: 0.681 },
826
+ { x: 0.735, y: 0.741 },
827
+ { x: 0.658, y: 0.783 },
828
+ { x: 0.569, y: 0.822 },
829
+ { x: 0.474, y: 0.855 },
830
+ { x: 0.372, y: 0.882 },
831
+ { x: 0.265, y: 0.904 },
832
+ { x: 0.156, y: 0.92 },
833
+ { x: 0.046, y: 0.931 },
834
+ { x: -0.069, y: 0.938 },
835
+ { x: -0.155, y: 0.935 },
836
+ { x: -0.25, y: 0.919 },
837
+ { x: -0.346, y: 0.89 },
838
+ { x: -0.443, y: 0.851 },
839
+ { x: -0.539, y: 0.8 },
840
+ { x: -0.633, y: 0.738 },
841
+ { x: -0.723, y: 0.667 },
842
+ { x: -0.808, y: 0.585 },
843
+ { x: -0.882, y: 0.501 },
844
+ { x: -0.944, y: 0.421 },
845
+ { x: -0.994, y: 0.343 },
846
+ { x: -1.034, y: 0.267 },
847
+ { x: -1.064, y: 0.192 },
848
+ { x: -1.085, y: 0.12 },
849
+ { x: -1.097, y: 0.042 },
850
+ { x: -1.102, y: 1e-3 },
851
+ { x: -1.09, y: -0.243 },
852
+ { x: -1.05, y: -0.365 },
853
+ { x: -0.99, y: -0.441 },
854
+ { x: -0.911, y: -0.479 },
855
+ { x: -0.819, y: -0.499 },
856
+ { x: -0.715, y: -0.517 },
857
+ { x: -0.605, y: -0.551 }
858
+ ],
859
+ // Shape 2 · Blob 3
860
+ [
861
+ { x: 3e-3, y: -0.97 },
862
+ { x: 0.163, y: -0.984 },
863
+ { x: 0.286, y: -1.028 },
864
+ { x: 0.389, y: -1.096 },
865
+ { x: 0.488, y: -1.174 },
866
+ { x: 0.597, y: -1.21 },
867
+ { x: 0.728, y: -1.165 },
868
+ { x: 0.867, y: -1.036 },
869
+ { x: 0.996, y: -0.839 },
870
+ { x: 1.098, y: -0.59 },
871
+ { x: 1.158, y: -0.304 },
872
+ { x: 1.153, y: 0.012 },
873
+ { x: 1.08, y: 0.336 },
874
+ { x: 0.944, y: 0.639 },
875
+ { x: 0.756, y: 0.889 },
876
+ { x: 0.484, y: 1.072 },
877
+ { x: 0.339, y: 1.079 },
878
+ { x: 0.219, y: 1.051 },
879
+ { x: 0.131, y: 0.994 },
880
+ { x: 0.059, y: 0.939 },
881
+ { x: 8e-3, y: 0.915 },
882
+ { x: -0.105, y: 0.931 },
883
+ { x: -0.176, y: 0.965 },
884
+ { x: -0.249, y: 1.005 },
885
+ { x: -0.345, y: 1.039 },
886
+ { x: -0.497, y: 1.055 },
887
+ { x: -0.672, y: 1.032 },
888
+ { x: -0.817, y: 0.952 },
889
+ { x: -0.945, y: 0.81 },
890
+ { x: -1.054, y: 0.6 },
891
+ { x: -1.16, y: 0.302 },
892
+ { x: -1.187, y: -0.011 },
893
+ { x: -1.165, y: -0.326 },
894
+ { x: -1.097, y: -0.599 },
895
+ { x: -0.991, y: -0.838 },
896
+ { x: -0.852, y: -0.912 },
897
+ { x: -0.695, y: -0.958 },
898
+ { x: -0.53, y: -0.988 },
899
+ { x: -0.357, y: -0.992 },
900
+ { x: -0.18, y: -0.983 }
901
+ ],
902
+ // Shape 3 · Blob 4
903
+ [
904
+ { x: -0.734, y: -1.127 },
905
+ { x: -0.656, y: -1.146 },
906
+ { x: -0.567, y: -1.142 },
907
+ { x: -0.468, y: -1.115 },
908
+ { x: -0.358, y: -1.079 },
909
+ { x: -0.238, y: -1.034 },
910
+ { x: -0.107, y: -0.989 },
911
+ { x: 0.035, y: -0.953 },
912
+ { x: 0.188, y: -0.929 },
913
+ { x: 0.325, y: -0.916 },
914
+ { x: 0.484, y: -0.896 },
915
+ { x: 0.65, y: -0.854 },
916
+ { x: 0.811, y: -0.77 },
917
+ { x: 0.955, y: -0.631 },
918
+ { x: 1.068, y: -0.422 },
919
+ { x: 1.138, y: -0.126 },
920
+ { x: 1.152, y: 0.272 },
921
+ { x: 1.121, y: 0.451 },
922
+ { x: 1.046, y: 0.602 },
923
+ { x: 0.928, y: 0.727 },
924
+ { x: 0.767, y: 0.833 },
925
+ { x: 0.565, y: 0.925 },
926
+ { x: 0.323, y: 1.001 },
927
+ { x: 0.042, y: 1.075 },
928
+ { x: -0.277, y: 1.142 },
929
+ { x: -0.368, y: 1.147 },
930
+ { x: -0.475, y: 1.136 },
931
+ { x: -0.589, y: 1.104 },
932
+ { x: -0.704, y: 1.05 },
933
+ { x: -0.815, y: 0.973 },
934
+ { x: -0.913, y: 0.871 },
935
+ { x: -0.993, y: 0.746 },
936
+ { x: -1.048, y: 0.593 },
937
+ { x: -1.109, y: 0.298 },
938
+ { x: -1.145, y: 0.018 },
939
+ { x: -1.154, y: -0.243 },
940
+ { x: -1.135, y: -0.482 },
941
+ { x: -1.085, y: -0.695 },
942
+ { x: -1.003, y: -0.874 },
943
+ { x: -0.886, y: -1.02 }
944
+ ],
945
+ // Shape 4 · Blob 5 (inverted Blob 4)
946
+ [
947
+ { x: -0.734, y: 1.127 },
948
+ { x: -0.656, y: 1.146 },
949
+ { x: -0.567, y: 1.142 },
950
+ { x: -0.468, y: 1.115 },
951
+ { x: -0.358, y: 1.079 },
952
+ { x: -0.238, y: 1.034 },
953
+ { x: -0.107, y: 0.989 },
954
+ { x: 0.035, y: 0.953 },
955
+ { x: 0.188, y: 0.929 },
956
+ { x: 0.325, y: 0.916 },
957
+ { x: 0.484, y: 0.896 },
958
+ { x: 0.65, y: 0.854 },
959
+ { x: 0.811, y: 0.77 },
960
+ { x: 0.955, y: 0.631 },
961
+ { x: 1.068, y: 0.422 },
962
+ { x: 1.138, y: 0.126 },
963
+ { x: 1.152, y: -0.272 },
964
+ { x: 1.121, y: -0.451 },
965
+ { x: 1.046, y: -0.602 },
966
+ { x: 0.928, y: -0.727 },
967
+ { x: 0.767, y: -0.833 },
968
+ { x: 0.565, y: -0.925 },
969
+ { x: 0.323, y: -1.001 },
970
+ { x: 0.042, y: -1.075 },
971
+ { x: -0.277, y: -1.142 },
972
+ { x: -0.368, y: -1.147 },
973
+ { x: -0.475, y: -1.136 },
974
+ { x: -0.589, y: -1.104 },
975
+ { x: -0.704, y: -1.05 },
976
+ { x: -0.815, y: -0.973 },
977
+ { x: -0.913, y: -0.871 },
978
+ { x: -0.993, y: -0.746 },
979
+ { x: -1.048, y: -0.593 },
980
+ { x: -1.109, y: -0.298 },
981
+ { x: -1.145, y: -0.018 },
982
+ { x: -1.154, y: 0.243 },
983
+ { x: -1.135, y: 0.482 },
984
+ { x: -1.085, y: 0.695 },
985
+ { x: -1.003, y: 0.874 },
986
+ { x: -0.886, y: 1.02 }
987
+ ],
988
+ // Shape 5 · Blob 6
989
+ [
990
+ { x: 1e-3, y: -1.006 },
991
+ { x: 0.134, y: -1.001 },
992
+ { x: 0.262, y: -1.024 },
993
+ { x: 0.382, y: -1.073 },
994
+ { x: 0.498, y: -1.107 },
995
+ { x: 0.614, y: -1.105 },
996
+ { x: 0.733, y: -1.049 },
997
+ { x: 0.85, y: -0.932 },
998
+ { x: 0.953, y: -0.755 },
999
+ { x: 1.03, y: -0.529 },
1000
+ { x: 1.067, y: -0.265 },
1001
+ { x: 1.054, y: 0.018 },
1002
+ { x: 0.986, y: 0.3 },
1003
+ { x: 0.868, y: 0.557 },
1004
+ { x: 0.715, y: 0.766 },
1005
+ { x: 0.55, y: 0.909 },
1006
+ { x: 0.396, y: 0.984 },
1007
+ { x: 0.262, y: 1.005 },
1008
+ { x: 0.148, y: 1.003 },
1009
+ { x: 0.037, y: 1.008 },
1010
+ { x: -0.083, y: 1.04 },
1011
+ { x: -0.22, y: 1.118 },
1012
+ { x: -0.36, y: 1.192 },
1013
+ { x: -0.5, y: 1.234 },
1014
+ { x: -0.65, y: 1.175 },
1015
+ { x: -0.81, y: 1.045 },
1016
+ { x: -0.99, y: 0.855 },
1017
+ { x: -1.14, y: 0.61 },
1018
+ { x: -1.24, y: 0.33 },
1019
+ { x: -1.27, y: 0.04 },
1020
+ { x: -1.275, y: -0.25 },
1021
+ { x: -1.225, y: -0.5 },
1022
+ { x: -1.08, y: -0.72 },
1023
+ { x: -0.89, y: -0.92 },
1024
+ { x: -0.734, y: -1.053 },
1025
+ { x: -0.614, y: -1.112 },
1026
+ { x: -0.498, y: -1.127 },
1027
+ { x: -0.381, y: -1.11 },
1028
+ { x: -0.261, y: -1.095 },
1029
+ { x: -0.133, y: -1.058 },
1030
+ { x: 1e-3, y: -1.01 },
1031
+ { x: 0.134, y: -1.001 }
1032
+ ]
1033
+ ];
1034
+ function buildBlobPathData(_shape, cx, cy, width, height) {
1035
+ const rx = width / 2;
1036
+ const ry = height / 2;
1037
+ const blobType = 0;
1038
+ const srcPts = ALL_BLOB_SHAPES[blobType];
1039
+ const pts = srcPts.map((p) => ({ x: cx + p.x * rx, y: cy + p.y * ry }));
1040
+ const mid = (a, b) => ({ x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 });
1041
+ const last = pts[pts.length - 1];
1042
+ const first = pts[0];
1043
+ const s = mid(last, first);
1044
+ let d = `M ${s.x} ${s.y}`;
1045
+ for (let i = 0; i < pts.length; i++) {
1046
+ const curr = pts[i];
1047
+ const next = pts[(i + 1) % pts.length];
1048
+ const m = mid(curr, next);
1049
+ d += ` Q ${curr.x} ${curr.y} ${m.x} ${m.y}`;
1050
+ }
1051
+ d += " Z";
1052
+ return d;
1053
+ }
1054
+
1055
+ // src/components/KonvaNode.tsx
1056
+ import { jsx as jsx2, jsxs } from "react/jsx-runtime";
1057
+ var KonvaNode = React2.memo(function KonvaNode2({
1058
+ node,
1059
+ zoomLevel,
1060
+ isExternallyHovered = false,
1061
+ isExternallyPressed = false,
1062
+ isDragging = false
1063
+ }) {
1064
+ const borderColor = useMemo2(() => darkenHexColor(node.color, 0.13), [node.color]);
1065
+ const layerCount = useMemo2(() => {
1066
+ const count = node.branchCount ?? 0;
1067
+ return Math.min(count, 3);
1068
+ }, [node.branchCount]);
1069
+ const baseRadiusX = node.width / 2;
1070
+ const baseRadiusY = Math.max(NODE_HEIGHT * 1.6 / 2, baseRadiusX * 0.74);
1071
+ const calculateFontSize = useMemo2(() => {
1072
+ return 30;
1073
+ }, [node.title]);
1074
+ const textNaturalWidth = node.width - 40;
1075
+ const textNaturalHeight = NODE_HEIGHT * 1.2;
1076
+ const maxScale = computeNodeTextWorldScale(node.width, zoomLevel);
1077
+ const blobType = getBlobTypeForLayer(node.level);
1078
+ const textOffsetX = (BLOB_TEXT_OFFSETS[blobType] ?? 0) * baseRadiusX;
1079
+ const effectiveHover = isExternallyHovered;
1080
+ const isPressed = isExternallyPressed;
1081
+ const topFill = effectiveHover || isPressed ? borderColor : node.color;
1082
+ const topStroke = isPressed ? node.color : borderColor;
1083
+ const backgroundLayers = useMemo2(() => {
1084
+ if (layerCount === 0) return [];
1085
+ const startIdx = BLOB_LAYER_CONFIG.length - layerCount;
1086
+ const layers = [];
1087
+ for (let i = startIdx; i < BLOB_LAYER_CONFIG.length; i++) {
1088
+ const config = BLOB_LAYER_CONFIG[i];
1089
+ layers.push({
1090
+ ...config,
1091
+ radiusX: baseRadiusX * config.scaleUp,
1092
+ radiusY: baseRadiusY * config.scaleUp
1093
+ });
1094
+ }
1095
+ return layers;
1096
+ }, [layerCount, baseRadiusX, baseRadiusY]);
1097
+ return /* @__PURE__ */ jsxs(KGroup, { x: node.x, y: node.y, listening: false, children: [
1098
+ backgroundLayers.map((layer, idx) => /* @__PURE__ */ jsx2(
1099
+ KShape,
1100
+ {
1101
+ rotation: layer.rotation,
1102
+ opacity: layer.opacity,
1103
+ sceneFunc: (ctx, shape) => {
1104
+ drawBlobPath(ctx, layer.radiusX, layer.radiusY, blobType);
1105
+ ctx.fillStrokeShape(shape);
1106
+ },
1107
+ fill: node.color,
1108
+ stroke: borderColor,
1109
+ strokeWidth: layer.strokeWidth,
1110
+ dash: layer.dash ? [...layer.dash] : void 0,
1111
+ listening: false,
1112
+ perfectDrawEnabled: false
1113
+ },
1114
+ `blob-layer-${idx}`
1115
+ )),
1116
+ /* @__PURE__ */ jsx2(
1117
+ KShape,
1118
+ {
1119
+ rotation: NODE_LAYER_CONFIG.rotation,
1120
+ opacity: NODE_LAYER_CONFIG.opacity,
1121
+ sceneFunc: (ctx, shape) => {
1122
+ drawBlobPath(ctx, baseRadiusX, baseRadiusY, blobType);
1123
+ ctx.fillStrokeShape(shape);
1124
+ },
1125
+ hitFunc: (ctx, shape) => {
1126
+ drawBlobPath(ctx, baseRadiusX, baseRadiusY, blobType);
1127
+ ctx.fillStrokeShape(shape);
1128
+ },
1129
+ fill: topFill,
1130
+ stroke: topStroke,
1131
+ strokeWidth: NODE_LAYER_CONFIG.strokeWidth,
1132
+ shadowEnabled: !isDragging,
1133
+ shadowColor: "rgba(255,255,255,1)",
1134
+ shadowBlur: effectiveHover || isPressed ? 40 : 15,
1135
+ shadowOpacity: effectiveHover || isPressed ? 0.4 : 0.15,
1136
+ shadowOffsetX: 0,
1137
+ shadowOffsetY: 0,
1138
+ perfectDrawEnabled: false
1139
+ }
1140
+ ),
1141
+ /* @__PURE__ */ jsx2(
1142
+ KGroup,
1143
+ {
1144
+ clipFunc: (ctx) => {
1145
+ drawBlobPath(ctx, baseRadiusX, baseRadiusY, blobType);
1146
+ },
1147
+ visible: zoomLevel >= TEXT_LABEL_HIDE_BELOW_ZOOM,
1148
+ listening: false,
1149
+ children: /* @__PURE__ */ jsx2(
1150
+ KText,
1151
+ {
1152
+ x: textOffsetX,
1153
+ y: 0,
1154
+ offsetX: textNaturalWidth / 2,
1155
+ offsetY: textNaturalHeight / 2,
1156
+ width: textNaturalWidth,
1157
+ height: textNaturalHeight,
1158
+ scaleX: maxScale,
1159
+ scaleY: maxScale,
1160
+ text: node.title,
1161
+ fontSize: calculateFontSize,
1162
+ fontFamily: "'Story Script', cursive",
1163
+ fontStyle: "normal",
1164
+ fill: CANVAS_COLORS.node.titleColor,
1165
+ align: "center",
1166
+ verticalAlign: "middle",
1167
+ wrap: "word",
1168
+ ellipsis: false,
1169
+ listening: false,
1170
+ perfectDrawEnabled: false
1171
+ }
1172
+ )
1173
+ }
1174
+ )
1175
+ ] });
1176
+ });
1177
+ var KonvaNode_default = KonvaNode;
1178
+
1179
+ // src/components/KonvaNodeLayer.tsx
1180
+ import { jsx as jsx3 } from "react/jsx-runtime";
1181
+ function KonvaNodeLayer({
1182
+ nodes: propNodes,
1183
+ isTransitioning = false,
1184
+ isDragging = false,
1185
+ hoveredNodeId = null,
1186
+ pressedNodeId = null
1187
+ }) {
1188
+ const layerRef = useRef5(null);
1189
+ const zoomLevel = useViewportStore((s) => s.zoomLevel);
1190
+ const storeNodes = useNodeStore((s) => s.nodes);
1191
+ const allNodes = useMemo3(
1192
+ () => propNodes ?? Array.from(storeNodes.values()),
1193
+ [propNodes, storeNodes]
1194
+ );
1195
+ const visibleNodes = useViewportNodes(allNodes, void 0, isTransitioning, isDragging);
1196
+ useLayoutEffect(() => {
1197
+ layerRef.current?.batchDraw();
1198
+ }, [visibleNodes, hoveredNodeId, pressedNodeId, isDragging, zoomLevel]);
1199
+ useEffect4(() => {
1200
+ layerRef.current?.batchDraw();
1201
+ }, [isTransitioning]);
1202
+ return /* @__PURE__ */ jsx3(KLayer, { ref: layerRef, name: "nodes", children: visibleNodes.map((n) => /* @__PURE__ */ jsx3(
1203
+ KonvaNode_default,
1204
+ {
1205
+ node: n,
1206
+ zoomLevel,
1207
+ isExternallyHovered: hoveredNodeId === n.id,
1208
+ isExternallyPressed: pressedNodeId === n.id,
1209
+ isDragging
1210
+ },
1211
+ n.id
1212
+ )) });
1213
+ }
1214
+
1215
+ // src/components/KonvaEdgeLayer.tsx
1216
+ import { useRef as useRef6, useLayoutEffect as useLayoutEffect2, useMemo as useMemo5 } from "react";
1217
+
1218
+ // src/components/KonvaEdge.tsx
1219
+ import { useMemo as useMemo4, memo } from "react";
1220
+ import { NODE_HEIGHT as NODE_HEIGHT2 } from "@visualli/core";
1221
+ import { Fragment, jsx as jsx4, jsxs as jsxs2 } from "react/jsx-runtime";
1222
+ var getOutermostScaleUp = (branchCount) => {
1223
+ const layerCount = Math.min(branchCount ?? 0, 3);
1224
+ if (layerCount === 0) return 1;
1225
+ const scaleUps = [1.3, 1.2, 1.1];
1226
+ return scaleUps[scaleUps.length - layerCount];
1227
+ };
1228
+ function getEllipseIntersection(cx, cy, rx, ry, angle) {
1229
+ return {
1230
+ x: cx + rx * Math.cos(angle),
1231
+ y: cy + ry * Math.sin(angle)
1232
+ };
1233
+ }
1234
+ var EDGE_SPACING = 25;
1235
+ var STROKE_COLOR = "#000000";
1236
+ var STROKE_WIDTH = 2.5;
1237
+ var KonvaEdge = memo(function KonvaEdge2({
1238
+ sourceNode: from,
1239
+ targetNode: to,
1240
+ connection,
1241
+ zoomLevel = 1
1242
+ }) {
1243
+ const label = connection?.label;
1244
+ const points = useMemo4(() => {
1245
+ const fromBaseX = from.width / 2;
1246
+ const fromBaseY = Math.max(NODE_HEIGHT2 * 1.6 / 2, fromBaseX * 0.74);
1247
+ const fromScale = getOutermostScaleUp(from.branchCount);
1248
+ const fromRadiusX = fromBaseX * fromScale;
1249
+ const fromRadiusY = fromBaseY * fromScale;
1250
+ const toBaseX = to.width / 2;
1251
+ const toBaseY = Math.max(NODE_HEIGHT2 * 1.6 / 2, toBaseX * 0.74);
1252
+ const toScale = getOutermostScaleUp(to.branchCount);
1253
+ const toRadiusX = toBaseX * toScale;
1254
+ const toRadiusY = toBaseY * toScale;
1255
+ const angle = Math.atan2(to.y - from.y, to.x - from.x);
1256
+ const fromEdge = getEllipseIntersection(from.x, from.y, fromRadiusX, fromRadiusY, angle);
1257
+ const toEdge = getEllipseIntersection(to.x, to.y, toRadiusX, toRadiusY, angle + Math.PI);
1258
+ const fromX = fromEdge.x + EDGE_SPACING * Math.cos(angle);
1259
+ const fromY = fromEdge.y + EDGE_SPACING * Math.sin(angle);
1260
+ const toX = toEdge.x - EDGE_SPACING * Math.cos(angle);
1261
+ const toY = toEdge.y - EDGE_SPACING * Math.sin(angle);
1262
+ const midX = (fromX + toX) / 2;
1263
+ const midY = (fromY + toY) / 2;
1264
+ const distance = Math.sqrt((toX - fromX) ** 2 + (toY - fromY) ** 2);
1265
+ const arcHeight = Math.min(45, 45 / (1 + Math.max(0, (distance - 200) / 400)));
1266
+ const perpX = -(toY - fromY) / distance;
1267
+ const perpY = (toX - fromX) / distance;
1268
+ const offsetMidX = midX + perpX * arcHeight;
1269
+ const offsetMidY = midY + perpY * arcHeight;
1270
+ const ctrl1X = fromX + (offsetMidX - fromX) * 0.66;
1271
+ const ctrl1Y = fromY + (offsetMidY - fromY) * 0.66;
1272
+ const ctrl2X = toX + (offsetMidX - toX) * 0.66;
1273
+ const ctrl2Y = toY + (offsetMidY - toY) * 0.66;
1274
+ const NUM_SEGMENTS = 20;
1275
+ const pts = [];
1276
+ for (let i = 0; i <= NUM_SEGMENTS; i++) {
1277
+ const t = i / NUM_SEGMENTS;
1278
+ const mt = 1 - t;
1279
+ const mt2 = mt * mt;
1280
+ const mt3 = mt2 * mt;
1281
+ const t2 = t * t;
1282
+ const t3 = t2 * t;
1283
+ pts.push(
1284
+ mt3 * fromX + 3 * mt2 * t * ctrl1X + 3 * mt * t2 * ctrl2X + t3 * toX,
1285
+ mt3 * fromY + 3 * mt2 * t * ctrl1Y + 3 * mt * t2 * ctrl2Y + t3 * toY
1286
+ );
1287
+ }
1288
+ return pts;
1289
+ }, [from.x, from.y, from.width, from.branchCount, to.x, to.y, to.width, to.branchCount]);
1290
+ const arrowHeadPoints = useMemo4(() => {
1291
+ const len = points.length;
1292
+ const tipX = points[len - 2];
1293
+ const tipY = points[len - 1];
1294
+ const prevX = points[len - 4];
1295
+ const prevY = points[len - 3];
1296
+ const angle = Math.atan2(tipY - prevY, tipX - prevX);
1297
+ const armLength = 11;
1298
+ const armAngle = Math.PI / 5.5;
1299
+ return [
1300
+ tipX - armLength * Math.cos(angle - armAngle),
1301
+ tipY - armLength * Math.sin(angle - armAngle),
1302
+ tipX,
1303
+ tipY,
1304
+ tipX - armLength * Math.cos(angle + armAngle),
1305
+ tipY - armLength * Math.sin(angle + armAngle)
1306
+ ];
1307
+ }, [points]);
1308
+ const { pathData, fontSize } = useMemo4(() => {
1309
+ if (!label) return { pathData: null, fontSize: 12 };
1310
+ const fromBaseX = from.width / 2;
1311
+ const fromBaseY = Math.max(NODE_HEIGHT2 * 1.6 / 2, fromBaseX * 0.74);
1312
+ const fromScale = getOutermostScaleUp(from.branchCount);
1313
+ const fromRadiusX = fromBaseX * fromScale;
1314
+ const fromRadiusY = fromBaseY * fromScale;
1315
+ const toBaseX = to.width / 2;
1316
+ const toBaseY = Math.max(NODE_HEIGHT2 * 1.6 / 2, toBaseX * 0.74);
1317
+ const toScale = getOutermostScaleUp(to.branchCount);
1318
+ const toRadiusX = toBaseX * toScale;
1319
+ const toRadiusY = toBaseY * toScale;
1320
+ const angle = Math.atan2(to.y - from.y, to.x - from.x);
1321
+ const fromEdge = getEllipseIntersection(from.x, from.y, fromRadiusX, fromRadiusY, angle);
1322
+ const toEdge = getEllipseIntersection(to.x, to.y, toRadiusX, toRadiusY, angle + Math.PI);
1323
+ const fromX = fromEdge.x + EDGE_SPACING * Math.cos(angle);
1324
+ const fromY = fromEdge.y + EDGE_SPACING * Math.sin(angle);
1325
+ const toX = toEdge.x - EDGE_SPACING * Math.cos(angle);
1326
+ const toY = toEdge.y - EDGE_SPACING * Math.sin(angle);
1327
+ const midX = (fromX + toX) / 2;
1328
+ const midY = (fromY + toY) / 2;
1329
+ const distance = Math.sqrt((toX - fromX) ** 2 + (toY - fromY) ** 2);
1330
+ const arcHeight = Math.min(45, 45 / (1 + Math.max(0, (distance - 200) / 400)));
1331
+ const perpX = -(toY - fromY) / distance;
1332
+ const perpY = (toX - fromX) / distance;
1333
+ const offsetMidX = midX + perpX * arcHeight;
1334
+ const offsetMidY = midY + perpY * arcHeight;
1335
+ const ctrl1X = fromX + (offsetMidX - fromX) * 0.66;
1336
+ const ctrl1Y = fromY + (offsetMidY - fromY) * 0.66;
1337
+ const ctrl2X = toX + (offsetMidX - toX) * 0.66;
1338
+ const ctrl2Y = toY + (offsetMidY - toY) * 0.66;
1339
+ const TEXT_OFFSET = 15;
1340
+ const fxo = fromX + perpX * TEXT_OFFSET;
1341
+ const fyo = fromY + perpY * TEXT_OFFSET;
1342
+ const txo = toX + perpX * TEXT_OFFSET;
1343
+ const tyo = toY + perpY * TEXT_OFFSET;
1344
+ const c1xo = ctrl1X + perpX * TEXT_OFFSET;
1345
+ const c1yo = ctrl1Y + perpY * TEXT_OFFSET;
1346
+ const c2xo = ctrl2X + perpX * TEXT_OFFSET;
1347
+ const c2yo = ctrl2Y + perpY * TEXT_OFFSET;
1348
+ const baseFontSize = 14;
1349
+ const zoomFactor = Math.max(1, 1 / zoomLevel);
1350
+ const scaledFontSize = Math.max(12, Math.min(24, baseFontSize * (from.width / 180) * zoomFactor));
1351
+ let d = "";
1352
+ if (fromX > toX) {
1353
+ d = `M ${txo},${tyo} C ${c2xo},${c2yo} ${c1xo},${c1yo} ${fxo},${fyo}`;
1354
+ } else {
1355
+ d = `M ${fxo},${fyo} C ${c1xo},${c1yo} ${c2xo},${c2yo} ${txo},${tyo}`;
1356
+ }
1357
+ return { pathData: d, fontSize: scaledFontSize };
1358
+ }, [from.x, from.y, from.width, from.branchCount, to.x, to.y, to.width, to.branchCount, label, zoomLevel]);
1359
+ return /* @__PURE__ */ jsxs2(KGroup, { children: [
1360
+ /* @__PURE__ */ jsx4(
1361
+ KLine,
1362
+ {
1363
+ points,
1364
+ stroke: STROKE_COLOR,
1365
+ strokeWidth: STROKE_WIDTH,
1366
+ tension: 0,
1367
+ opacity: 0.9,
1368
+ lineCap: "round",
1369
+ lineJoin: "round",
1370
+ listening: false,
1371
+ perfectDrawEnabled: false
1372
+ }
1373
+ ),
1374
+ /* @__PURE__ */ jsx4(
1375
+ KLine,
1376
+ {
1377
+ points: arrowHeadPoints,
1378
+ stroke: STROKE_COLOR,
1379
+ strokeWidth: STROKE_WIDTH,
1380
+ lineCap: "round",
1381
+ lineJoin: "round",
1382
+ opacity: 0.9,
1383
+ listening: false,
1384
+ perfectDrawEnabled: false
1385
+ }
1386
+ ),
1387
+ label && pathData && /* @__PURE__ */ jsxs2(Fragment, { children: [
1388
+ /* @__PURE__ */ jsx4(
1389
+ KTextPath,
1390
+ {
1391
+ data: pathData,
1392
+ text: label,
1393
+ fontSize,
1394
+ fontFamily: "'Playpen Sans', cursive",
1395
+ fontStyle: "normal",
1396
+ fill: "#ffffff",
1397
+ align: "center",
1398
+ letterSpacing: 0.5,
1399
+ listening: false,
1400
+ opacity: 0.9
1401
+ }
1402
+ ),
1403
+ /* @__PURE__ */ jsx4(
1404
+ KTextPath,
1405
+ {
1406
+ data: pathData,
1407
+ text: label,
1408
+ fontSize,
1409
+ fontFamily: "'Playpen Sans', cursive",
1410
+ fontStyle: "normal",
1411
+ fill: "#6b6860",
1412
+ align: "center",
1413
+ letterSpacing: 0.5,
1414
+ listening: false,
1415
+ opacity: 0.9
1416
+ }
1417
+ )
1418
+ ] })
1419
+ ] });
1420
+ });
1421
+ var KonvaEdge_default = KonvaEdge;
1422
+
1423
+ // src/components/KonvaEdgeLayer.tsx
1424
+ import { jsx as jsx5 } from "react/jsx-runtime";
1425
+ function KonvaEdgeLayer({
1426
+ nodes,
1427
+ connections,
1428
+ currentLevel,
1429
+ isDark = false,
1430
+ isDragging = false
1431
+ }) {
1432
+ const layerRef = useRef6(null);
1433
+ const zoomLevel = useViewportStore((s) => s.zoomLevel);
1434
+ const storeNodes = useNodeStore((s) => s.nodes);
1435
+ const visibleNodes = useViewportNodes(nodes, currentLevel, false, isDragging);
1436
+ const visibleSet = useMemo5(() => new Set(visibleNodes.map((n) => n.id)), [visibleNodes]);
1437
+ useLayoutEffect2(() => {
1438
+ layerRef.current?.batchDraw();
1439
+ }, [connections, isDark, isDragging, visibleSet, storeNodes]);
1440
+ return /* @__PURE__ */ jsx5(KLayer, { ref: layerRef, listening: false, children: connections.map((conn, idx) => {
1441
+ const src = storeNodes.get(conn.from);
1442
+ const tgt = storeNodes.get(conn.to);
1443
+ if (!src || !tgt) return null;
1444
+ if (!isDragging && !visibleSet.has(src.id) && !visibleSet.has(tgt.id)) return null;
1445
+ return /* @__PURE__ */ jsx5(
1446
+ KonvaEdge_default,
1447
+ {
1448
+ sourceNode: src,
1449
+ targetNode: tgt,
1450
+ connection: conn,
1451
+ isDark,
1452
+ zoomLevel
1453
+ },
1454
+ `${conn.from}-${conn.to}-${idx}`
1455
+ );
1456
+ }) });
1457
+ }
1458
+
1459
+ // src/components/KonvaContainerLayer.tsx
1460
+ import { useRef as useRef7, useLayoutEffect as useLayoutEffect3 } from "react";
1461
+
1462
+ // src/components/KonvaContainer.tsx
1463
+ import { memo as memo2, useMemo as useMemo6 } from "react";
1464
+ import { NODE_HEIGHT as NODE_HEIGHT4, TEXT_LABEL_HIDE_BELOW_ZOOM as TEXT_LABEL_HIDE_BELOW_ZOOM2 } from "@visualli/core";
1465
+
1466
+ // src/config/textScaling.ts
1467
+ import { NODE_HEIGHT as NODE_HEIGHT3 } from "@visualli/core";
1468
+ var DESCRIPTION_TEXT_BASE_FONT_PX2 = 12;
1469
+ var CONTAINER_LABEL_BASE_FONT_PX2 = 24;
1470
+ var NODE_TEXT_SAFE_BLOB_FILL_RATIO = 0.88;
1471
+ function computeNodeTextWorldScale2(nodeWidth, zoomLevel) {
1472
+ const safeZoom = Math.max(zoomLevel, 1e-4);
1473
+ const baseWidth = 300;
1474
+ const baseRadiusX = baseWidth / 2;
1475
+ const baseRadiusY = Math.max(NODE_HEIGHT3 * 1.6 / 2, baseRadiusX * 0.74);
1476
+ const textNaturalWidth = baseWidth - 40;
1477
+ const textNaturalHeight = NODE_HEIGHT3 * 1.2;
1478
+ const maxScaleX = baseRadiusX * 2 * NODE_TEXT_SAFE_BLOB_FILL_RATIO / Math.max(textNaturalWidth, 1);
1479
+ const maxScaleY = baseRadiusY * 2 * NODE_TEXT_SAFE_BLOB_FILL_RATIO / Math.max(textNaturalHeight, 1);
1480
+ const fitScale = Math.max(0.01, Math.min(maxScaleX, maxScaleY));
1481
+ return Math.min(1 / safeZoom, fitScale);
1482
+ }
1483
+ function computeNodeTextScreenScale2(nodeWidth, zoomLevel) {
1484
+ return computeNodeTextWorldScale2(nodeWidth, zoomLevel) * zoomLevel;
1485
+ }
1486
+
1487
+ // src/components/KonvaContainer.tsx
1488
+ import { jsx as jsx6, jsxs as jsxs3 } from "react/jsx-runtime";
1489
+ var CONTAINER_PADDING = 50;
1490
+ var CONTAINER_DASH = [10, 5];
1491
+ var LABEL_BG = "#ddd9d0";
1492
+ var LABEL_BORDER_RADIUS = 20;
1493
+ var LABEL_FONT_SIZE = CONTAINER_LABEL_BASE_FONT_PX2;
1494
+ var LABEL_FONT_WEIGHT = "800";
1495
+ var LABEL_FONT_FAMILY = "'Playpen Sans', Quicksand, sans-serif";
1496
+ var LABEL_PAD_H = 20;
1497
+ var LABEL_PAD_V = 10;
1498
+ var LABEL_LINE_HEIGHT = 1.2;
1499
+ var MAX_BLOB_EXTENT_X = 1.275;
1500
+ var MAX_BLOB_EXTENT_Y = 1.234;
1501
+ var _labelCtx = null;
1502
+ function measureLabelBoxWidth(text, fontSize) {
1503
+ if (!_labelCtx) {
1504
+ const c = document.createElement("canvas");
1505
+ _labelCtx = c.getContext("2d");
1506
+ }
1507
+ if (!_labelCtx) return Math.ceil(text.length * fontSize * 0.65 + 2 * LABEL_PAD_H);
1508
+ _labelCtx.font = `${LABEL_FONT_WEIGHT} ${fontSize}px ${LABEL_FONT_FAMILY}`;
1509
+ return Math.ceil(_labelCtx.measureText(text).width + 2 * LABEL_PAD_H);
1510
+ }
1511
+ function isLeftTurn(p1, p2, p3) {
1512
+ return (p2.x - p1.x) * (p3.y - p1.y) - (p2.y - p1.y) * (p3.x - p1.x) > 0;
1513
+ }
1514
+ function convexHull(points) {
1515
+ if (points.length < 3) return points;
1516
+ let start = 0;
1517
+ for (let i = 1; i < points.length; i++) {
1518
+ if (points[i].y < points[start].y || points[i].y === points[start].y && points[i].x < points[start].x) start = i;
1519
+ }
1520
+ const sorted = [...points];
1521
+ const sp = sorted.splice(start, 1)[0];
1522
+ sorted.sort((a, b) => {
1523
+ const da = Math.atan2(a.y - sp.y, a.x - sp.x);
1524
+ const db = Math.atan2(b.y - sp.y, b.x - sp.x);
1525
+ if (da !== db) return da - db;
1526
+ return Math.hypot(a.x - sp.x, a.y - sp.y) - Math.hypot(b.x - sp.x, b.y - sp.y);
1527
+ });
1528
+ const hull = [sp, sorted[0]];
1529
+ for (let i = 1; i < sorted.length; i++) {
1530
+ while (hull.length > 1 && !isLeftTurn(hull[hull.length - 2], hull[hull.length - 1], sorted[i])) hull.pop();
1531
+ hull.push(sorted[i]);
1532
+ }
1533
+ return hull;
1534
+ }
1535
+ function chaikinSmooth(pts, passes = 6) {
1536
+ let cur = pts;
1537
+ for (let iter = 0; iter < passes; iter++) {
1538
+ const next = [];
1539
+ const n = cur.length;
1540
+ for (let i = 0; i < n; i++) {
1541
+ const a = cur[i], b = cur[(i + 1) % n];
1542
+ next.push({ x: 0.75 * a.x + 0.25 * b.x, y: 0.75 * a.y + 0.25 * b.y });
1543
+ next.push({ x: 0.25 * a.x + 0.75 * b.x, y: 0.25 * a.y + 0.75 * b.y });
1544
+ }
1545
+ cur = next;
1546
+ }
1547
+ return cur.flatMap((p) => [p.x, p.y]);
1548
+ }
1549
+ function hullEdgeYAtX(points, targetX, isBottom) {
1550
+ const n = points.length;
1551
+ let result = isBottom ? -Infinity : Infinity;
1552
+ for (let i = 0; i < n; i += 2) {
1553
+ const x1 = points[i], y1 = points[i + 1];
1554
+ const x2 = points[(i + 2) % n], y2 = points[(i + 3) % n];
1555
+ if (x1 < targetX && x2 >= targetX || x2 < targetX && x1 >= targetX) {
1556
+ const t = (targetX - x1) / (x2 - x1);
1557
+ const y = y1 + t * (y2 - y1);
1558
+ if (isBottom) result = Math.max(result, y);
1559
+ else result = Math.min(result, y);
1560
+ }
1561
+ }
1562
+ if (!isFinite(result)) {
1563
+ for (let i = 1; i < n; i += 2) {
1564
+ if (isBottom) result = Math.max(result, points[i]);
1565
+ else result = Math.min(result, points[i]);
1566
+ }
1567
+ }
1568
+ return result;
1569
+ }
1570
+ function calculateHull(containerNodes) {
1571
+ if (containerNodes.length === 0) return null;
1572
+ const NUM_SAMPLES = 48;
1573
+ const samples = [];
1574
+ for (const node of containerNodes) {
1575
+ const baseRX = node.width / 2;
1576
+ const baseRY = Math.max(NODE_HEIGHT4 * 1.6 / 2, baseRX * 0.74);
1577
+ const halfW = baseRX * MAX_BLOB_EXTENT_X + CONTAINER_PADDING;
1578
+ const halfH = baseRY * MAX_BLOB_EXTENT_Y + CONTAINER_PADDING;
1579
+ for (let j = 0; j < NUM_SAMPLES; j++) {
1580
+ const angle = j / NUM_SAMPLES * Math.PI * 2;
1581
+ samples.push({ x: node.x + halfW * Math.cos(angle), y: node.y + halfH * Math.sin(angle) });
1582
+ }
1583
+ }
1584
+ const hull = convexHull(samples);
1585
+ const points = chaikinSmooth(hull, 6);
1586
+ const xs = containerNodes.map((n) => n.x);
1587
+ const ys = containerNodes.map((n) => n.y);
1588
+ const spanX = Math.max(...xs) - Math.min(...xs);
1589
+ const spanY = Math.max(...ys) - Math.min(...ys);
1590
+ const useTopEdge = spanY > spanX * 2;
1591
+ let hullMinX = Infinity, hullMaxX = -Infinity;
1592
+ for (let i = 0; i < points.length; i += 2) {
1593
+ if (points[i] < hullMinX) hullMinX = points[i];
1594
+ if (points[i] > hullMaxX) hullMaxX = points[i];
1595
+ }
1596
+ const anchorX = (hullMinX + hullMaxX) / 2;
1597
+ const anchorEdgeY = hullEdgeYAtX(points, anchorX, !useTopEdge);
1598
+ return { points, anchorX, anchorEdgeY };
1599
+ }
1600
+ var KonvaContainer = memo2(function KonvaContainer2({
1601
+ nodes,
1602
+ label = ""
1603
+ }) {
1604
+ const zoomLevel = useViewportStore((s) => s.zoomLevel);
1605
+ const hullData = useMemo6(() => calculateHull(nodes), [nodes]);
1606
+ if (!hullData) return null;
1607
+ const refNode = nodes[0];
1608
+ const screenScale = refNode ? computeNodeTextScreenScale2(refNode.width, zoomLevel) : 1;
1609
+ const labelFontSize = LABEL_FONT_SIZE * screenScale;
1610
+ const labelBoxHeight = Math.ceil(labelFontSize * LABEL_LINE_HEIGHT + 2 * LABEL_PAD_V);
1611
+ const boxWidth = label ? measureLabelBoxWidth(label, labelFontSize) : 0;
1612
+ const boxX = hullData.anchorX - boxWidth / 2;
1613
+ const boxY = hullData.anchorEdgeY - labelBoxHeight / 2;
1614
+ return /* @__PURE__ */ jsxs3(KGroup, { listening: false, children: [
1615
+ /* @__PURE__ */ jsx6(
1616
+ KLine,
1617
+ {
1618
+ points: hullData.points,
1619
+ stroke: "#747270",
1620
+ strokeWidth: 1,
1621
+ dash: CONTAINER_DASH,
1622
+ closed: true,
1623
+ tension: 0,
1624
+ listening: false,
1625
+ perfectDrawEnabled: false
1626
+ }
1627
+ ),
1628
+ label.length > 0 && /* @__PURE__ */ jsxs3(
1629
+ KGroup,
1630
+ {
1631
+ x: boxX + boxWidth / 2,
1632
+ y: boxY + labelBoxHeight / 2,
1633
+ offsetX: boxWidth / 2,
1634
+ offsetY: labelBoxHeight / 2,
1635
+ scaleX: 1 / zoomLevel,
1636
+ scaleY: 1 / zoomLevel,
1637
+ visible: zoomLevel >= TEXT_LABEL_HIDE_BELOW_ZOOM2,
1638
+ listening: false,
1639
+ children: [
1640
+ /* @__PURE__ */ jsx6(
1641
+ KRect,
1642
+ {
1643
+ width: boxWidth,
1644
+ height: labelBoxHeight,
1645
+ fill: LABEL_BG,
1646
+ cornerRadius: LABEL_BORDER_RADIUS,
1647
+ shadowEnabled: false,
1648
+ perfectDrawEnabled: false
1649
+ }
1650
+ ),
1651
+ /* @__PURE__ */ jsx6(
1652
+ KText,
1653
+ {
1654
+ x: LABEL_PAD_H,
1655
+ y: LABEL_PAD_V,
1656
+ width: boxWidth - 2 * LABEL_PAD_H,
1657
+ height: labelBoxHeight - 2 * LABEL_PAD_V,
1658
+ text: label,
1659
+ fontSize: labelFontSize,
1660
+ fontFamily: LABEL_FONT_FAMILY,
1661
+ fontStyle: LABEL_FONT_WEIGHT,
1662
+ fill: "rgba(0,0,0,1)",
1663
+ align: "center",
1664
+ verticalAlign: "middle",
1665
+ lineHeight: LABEL_LINE_HEIGHT,
1666
+ perfectDrawEnabled: false,
1667
+ listening: false
1668
+ }
1669
+ )
1670
+ ]
1671
+ }
1672
+ )
1673
+ ] });
1674
+ });
1675
+ var KonvaContainer_default = KonvaContainer;
1676
+
1677
+ // src/components/KonvaContainerLayer.tsx
1678
+ import { jsx as jsx7 } from "react/jsx-runtime";
1679
+ function KonvaContainerLayer({ nodes, containers, isDark = false }) {
1680
+ const layerRef = useRef7(null);
1681
+ const storeNodes = useNodeStore((s) => s.nodes);
1682
+ useLayoutEffect3(() => {
1683
+ layerRef.current?.batchDraw();
1684
+ }, [containers, isDark, storeNodes]);
1685
+ if (containers.length === 0) return null;
1686
+ return /* @__PURE__ */ jsx7(KLayer, { ref: layerRef, listening: false, children: containers.map((c) => {
1687
+ const groupNodes = c.nodeIds.map((id) => storeNodes.get(id)).filter((n) => !!n);
1688
+ if (groupNodes.length === 0) return null;
1689
+ return /* @__PURE__ */ jsx7(
1690
+ KonvaContainer_default,
1691
+ {
1692
+ nodes: groupNodes,
1693
+ label: c.label,
1694
+ isDark
1695
+ },
1696
+ c.id
1697
+ );
1698
+ }) });
1699
+ }
1700
+
1701
+ // src/components/NavigationStack.tsx
1702
+ import { memo as memo3, useState as useState2 } from "react";
1703
+ import { jsx as jsx8, jsxs as jsxs4 } from "react/jsx-runtime";
1704
+ var PASTEL_FILLS = [
1705
+ "#b6eea7",
1706
+ "#d5bcfe",
1707
+ "#a1c4fc",
1708
+ "#a6f5d8",
1709
+ "#ffc9de",
1710
+ "#c4e8ff",
1711
+ "#f0c4ff",
1712
+ "#ffe0a3"
1713
+ ];
1714
+ var ORGANIC_PATHS_SMALL = [
1715
+ "M13.72,6.31 C15.12,6.29 17.96,8.57 17.99,10.60 C18.07,14.62 16.72,17.90 11.12,18.00 C7.26,18.07 4.07,14.86 4.00,10.85 C3.93,6.83 9.44,2.02 13.69,4.85",
1716
+ "M13.88,6.41 C15.28,6.43 18.04,8.82 18.00,10.85 C17.93,14.86 16.48,18.10 10.88,18.00 C7.02,17.93 3.94,14.62 4.01,10.60 C4.08,6.59 9.76,1.97 13.91,4.95"
1717
+ ];
1718
+ var ORGANIC_PATH_ACTIVE = "M16.55,9.22 C17.95,9.15 20.87,11.33 20.97,13.36 C21.19,17.37 19.96,20.70 14.37,20.99 C10.51,21.19 7.20,18.10 7.00,14.09 C6.78,10.08 12.13,5.08 16.48,7.77";
1719
+ var CIRCLE_SIZE = 14;
1720
+ function hexToRgba(hex, opacity) {
1721
+ const h = hex.replace("#", "");
1722
+ const r = parseInt(h.substring(0, 2), 16);
1723
+ const g = parseInt(h.substring(2, 4), 16);
1724
+ const b = parseInt(h.substring(4, 6), 16);
1725
+ return `rgba(${r},${g},${b},${opacity})`;
1726
+ }
1727
+ var OrganicCircle = memo3(function OrganicCircle2({
1728
+ size,
1729
+ fill,
1730
+ stroke,
1731
+ strokeWidth,
1732
+ variant,
1733
+ isActive
1734
+ }) {
1735
+ const path = isActive ? ORGANIC_PATH_ACTIVE : ORGANIC_PATHS_SMALL[variant % ORGANIC_PATHS_SMALL.length];
1736
+ const vb = isActive ? "4 3 20 20" : "2 1 18 18";
1737
+ const vbSz = isActive ? 20 : 18;
1738
+ const sw = strokeWidth * vbSz / size;
1739
+ return /* @__PURE__ */ jsxs4("svg", { width: size, height: size, viewBox: vb, fill: "none", style: { display: "block" }, children: [
1740
+ /* @__PURE__ */ jsx8("path", { d: path, fill, fillOpacity: 1 }),
1741
+ /* @__PURE__ */ jsx8("path", { d: path, fill: "none", stroke, strokeWidth: sw, strokeLinecap: "round" })
1742
+ ] });
1743
+ });
1744
+ var NavigationStack = memo3(function NavigationStack2({
1745
+ stack,
1746
+ onNavigateBack,
1747
+ isDark = false,
1748
+ top = "16px",
1749
+ left = "16px"
1750
+ }) {
1751
+ const [hoveredIndex, setHoveredIndex] = useState2(null);
1752
+ if (stack.length === 0) return null;
1753
+ const visitedStroke = isDark ? "#908e8c" : "#747270";
1754
+ const visitedText = isDark ? "#908e8c" : "#747270";
1755
+ const activeStroke = isDark ? "#e8e6e4" : "#1a1a18";
1756
+ const activeText = isDark ? "#e8e6e4" : "#1a1a18";
1757
+ const connectorColor = isDark ? "#908e8c" : "#747270";
1758
+ const connectorMarginLeft = 10.5;
1759
+ const currentIndex = stack.length - 1;
1760
+ return /* @__PURE__ */ jsx8(
1761
+ "div",
1762
+ {
1763
+ "data-help": "navigation-stack",
1764
+ style: {
1765
+ position: "absolute",
1766
+ top,
1767
+ left,
1768
+ zIndex: 30,
1769
+ pointerEvents: "auto"
1770
+ },
1771
+ children: /* @__PURE__ */ jsx8("div", { style: { display: "flex", flexDirection: "column", padding: "8px" }, children: stack.flatMap((entry, index) => {
1772
+ const isCurrent = index === currentIndex;
1773
+ const isClickable = index < currentIndex;
1774
+ const isHovered = hoveredIndex === index;
1775
+ const pastelFill = PASTEL_FILLS[index % PASTEL_FILLS.length];
1776
+ const rowBg = isCurrent ? hexToRgba(pastelFill, 0.6) : isHovered && isClickable ? hexToRgba(pastelFill, 0.3) : "transparent";
1777
+ const items = [];
1778
+ if (index > 0) {
1779
+ items.push(
1780
+ /* @__PURE__ */ jsx8(
1781
+ "div",
1782
+ {
1783
+ style: {
1784
+ width: "1px",
1785
+ height: "20px",
1786
+ marginLeft: `${connectorMarginLeft}px`,
1787
+ marginTop: "-6px",
1788
+ marginBottom: "-6px",
1789
+ background: connectorColor,
1790
+ alignSelf: "flex-start",
1791
+ flexShrink: 0,
1792
+ position: "relative",
1793
+ zIndex: 1
1794
+ }
1795
+ },
1796
+ `conn-${index}`
1797
+ )
1798
+ );
1799
+ }
1800
+ items.push(
1801
+ /* @__PURE__ */ jsxs4(
1802
+ "div",
1803
+ {
1804
+ style: {
1805
+ position: "relative",
1806
+ zIndex: 2,
1807
+ alignSelf: "flex-start",
1808
+ display: "flex",
1809
+ alignItems: "center",
1810
+ gap: "4px",
1811
+ height: "22px",
1812
+ padding: "4px",
1813
+ borderRadius: "8px",
1814
+ cursor: isClickable ? "pointer" : "default",
1815
+ background: rowBg,
1816
+ transition: "background 0.15s"
1817
+ },
1818
+ onMouseEnter: () => setHoveredIndex(index),
1819
+ onMouseLeave: () => setHoveredIndex(null),
1820
+ onClick: (e) => {
1821
+ e.stopPropagation();
1822
+ if (isClickable) onNavigateBack(index);
1823
+ },
1824
+ children: [
1825
+ /* @__PURE__ */ jsx8("div", { style: { width: CIRCLE_SIZE, height: CIRCLE_SIZE, display: "flex", alignItems: "center", justifyContent: "center", flexShrink: 0, zIndex: 4 }, children: isCurrent ? /* @__PURE__ */ jsx8(OrganicCircle, { size: CIRCLE_SIZE, fill: pastelFill, stroke: activeStroke, strokeWidth: 1.5, variant: index, isActive: true }) : /* @__PURE__ */ jsx8(OrganicCircle, { size: CIRCLE_SIZE, fill: pastelFill, stroke: visitedStroke, strokeWidth: 1, variant: index }) }),
1826
+ /* @__PURE__ */ jsx8(
1827
+ "span",
1828
+ {
1829
+ style: {
1830
+ fontFamily: "'Nunito', sans-serif",
1831
+ fontSize: isCurrent || isHovered && isClickable ? "11px" : "9px",
1832
+ fontWeight: isCurrent ? 600 : 400,
1833
+ color: isCurrent ? activeText : visitedText,
1834
+ lineHeight: 1,
1835
+ whiteSpace: "nowrap",
1836
+ transition: "color 0.15s, font-size 0.15s"
1837
+ },
1838
+ children: entry.label
1839
+ }
1840
+ )
1841
+ ]
1842
+ },
1843
+ `${entry.layerId}-${index}`
1844
+ )
1845
+ );
1846
+ return items;
1847
+ }) })
1848
+ }
1849
+ );
1850
+ });
1851
+ var NavigationStack_default = NavigationStack;
1852
+
1853
+ // src/components/ZoomControls.tsx
1854
+ import { useCallback as useCallback3, memo as memo4 } from "react";
1855
+ import { ZOOM_MIN, ZOOM_MAX } from "@visualli/core";
1856
+ import { jsx as jsx9, jsxs as jsxs5 } from "react/jsx-runtime";
1857
+ var ZoomInIcon = () => /* @__PURE__ */ jsxs5("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [
1858
+ /* @__PURE__ */ jsx9("circle", { cx: "11", cy: "11", r: "8" }),
1859
+ /* @__PURE__ */ jsx9("line", { x1: "21", y1: "21", x2: "16.65", y2: "16.65" }),
1860
+ /* @__PURE__ */ jsx9("line", { x1: "11", y1: "8", x2: "11", y2: "14" }),
1861
+ /* @__PURE__ */ jsx9("line", { x1: "8", y1: "11", x2: "14", y2: "11" })
1862
+ ] });
1863
+ var ZoomOutIcon = () => /* @__PURE__ */ jsxs5("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [
1864
+ /* @__PURE__ */ jsx9("circle", { cx: "11", cy: "11", r: "8" }),
1865
+ /* @__PURE__ */ jsx9("line", { x1: "21", y1: "21", x2: "16.65", y2: "16.65" }),
1866
+ /* @__PURE__ */ jsx9("line", { x1: "8", y1: "11", x2: "14", y2: "11" })
1867
+ ] });
1868
+ var ZoomControls = memo4(function ZoomControls2({ isDark = false }) {
1869
+ const zoomLevel = useViewportStore((s) => s.zoomLevel);
1870
+ const setZoom = useViewportStore((s) => s.setZoom);
1871
+ const handleZoomIn = useCallback3(() => setZoom(Math.min(ZOOM_MAX, zoomLevel * 1.2)), [zoomLevel, setZoom]);
1872
+ const handleZoomOut = useCallback3(() => setZoom(Math.max(ZOOM_MIN, zoomLevel / 1.2)), [zoomLevel, setZoom]);
1873
+ const surfaceBg = isDark ? "var(--bg-surface, #1C1C1A)" : "var(--bg-surface, #FAF8F4)";
1874
+ const border = isDark ? "var(--border-color, #333330)" : "var(--border-color, #DDD9D0)";
1875
+ const primary = isDark ? "var(--text-primary, #F0EDE6)" : "var(--text-primary, #1A1A18)";
1876
+ const muted = isDark ? "var(--text-muted, #8E8C88)" : "var(--text-muted, #747270)";
1877
+ return /* @__PURE__ */ jsxs5(
1878
+ "div",
1879
+ {
1880
+ style: {
1881
+ position: "relative",
1882
+ background: surfaceBg,
1883
+ border: `0.5px solid ${border}`,
1884
+ borderRadius: "8px",
1885
+ overflow: "hidden",
1886
+ width: "65px",
1887
+ height: "32px",
1888
+ flexShrink: 0
1889
+ },
1890
+ role: "group",
1891
+ "aria-label": "Zoom controls",
1892
+ children: [
1893
+ /* @__PURE__ */ jsx9(
1894
+ "button",
1895
+ {
1896
+ onClick: handleZoomIn,
1897
+ disabled: zoomLevel >= ZOOM_MAX,
1898
+ style: {
1899
+ position: "absolute",
1900
+ top: 0,
1901
+ left: 0,
1902
+ width: "32px",
1903
+ height: "100%",
1904
+ display: "flex",
1905
+ alignItems: "center",
1906
+ justifyContent: "center",
1907
+ border: "none",
1908
+ background: "transparent",
1909
+ cursor: zoomLevel >= ZOOM_MAX ? "not-allowed" : "pointer",
1910
+ color: zoomLevel >= ZOOM_MAX ? muted : primary,
1911
+ transition: "background 0.15s"
1912
+ },
1913
+ onMouseEnter: (e) => {
1914
+ if (zoomLevel < ZOOM_MAX) e.currentTarget.style.background = isDark ? "rgba(255,255,255,0.06)" : "rgba(0,0,0,0.05)";
1915
+ },
1916
+ onMouseLeave: (e) => {
1917
+ e.currentTarget.style.background = "transparent";
1918
+ },
1919
+ "aria-label": "Zoom in",
1920
+ children: /* @__PURE__ */ jsx9(ZoomInIcon, {})
1921
+ }
1922
+ ),
1923
+ /* @__PURE__ */ jsx9("div", { style: { position: "absolute", top: 0, bottom: 0, left: "32px", width: "0.5px", background: border } }),
1924
+ /* @__PURE__ */ jsx9(
1925
+ "button",
1926
+ {
1927
+ onClick: handleZoomOut,
1928
+ disabled: zoomLevel <= ZOOM_MIN,
1929
+ style: {
1930
+ position: "absolute",
1931
+ top: 0,
1932
+ right: 0,
1933
+ width: "33px",
1934
+ height: "100%",
1935
+ display: "flex",
1936
+ alignItems: "center",
1937
+ justifyContent: "center",
1938
+ border: "none",
1939
+ background: "transparent",
1940
+ cursor: zoomLevel <= ZOOM_MIN ? "not-allowed" : "pointer",
1941
+ color: zoomLevel <= ZOOM_MIN ? muted : primary,
1942
+ transition: "background 0.15s"
1943
+ },
1944
+ onMouseEnter: (e) => {
1945
+ if (zoomLevel > ZOOM_MIN) e.currentTarget.style.background = isDark ? "rgba(255,255,255,0.06)" : "rgba(0,0,0,0.05)";
1946
+ },
1947
+ onMouseLeave: (e) => {
1948
+ e.currentTarget.style.background = "transparent";
1949
+ },
1950
+ "aria-label": "Zoom out",
1951
+ children: /* @__PURE__ */ jsx9(ZoomOutIcon, {})
1952
+ }
1953
+ )
1954
+ ]
1955
+ }
1956
+ );
1957
+ });
1958
+ var ZoomControls_default = ZoomControls;
1959
+
1960
+ // src/components/SketchyBoxKonva.tsx
1961
+ import { useEffect as useEffect5, useRef as useRef8 } from "react";
1962
+ import Konva from "konva";
1963
+ import { jsx as jsx10, jsxs as jsxs6 } from "react/jsx-runtime";
1964
+ var BACK_PATH = "M0.653,0 L485.653,1 C485.653,1,484.782,152.773,486.653,165 C413.653,171,14.653,178,5.653,168C2.653,158,0.653,0,0.653,0";
1965
+ var FRONT_PATH = "M485,167.524 L0,166.524C0,166.524,1.870,8.751,0,0C73,0,479,0,487,8C490,18,485,167.524,485,167.524";
1966
+ var BACK_W = 486;
1967
+ var BACK_H = 178;
1968
+ var FRONT_W = 490;
1969
+ var FRONT_H = 168;
1970
+ var BACK_OFFSET_Y = 8;
1971
+ var FRONT_OFFSET_X = 5.65;
1972
+ var SHADOW_PAD = 12;
1973
+ var SketchyBoxKonva = ({
1974
+ children,
1975
+ fill,
1976
+ stroke,
1977
+ backStroke,
1978
+ padding = "1rem 1.25rem"
1979
+ }) => {
1980
+ const wrapperRef = useRef8(null);
1981
+ const konvaContainerRef = useRef8(null);
1982
+ const stageRef = useRef8(null);
1983
+ useEffect5(() => {
1984
+ const wrapper = wrapperRef.current;
1985
+ const konvaEl = konvaContainerRef.current;
1986
+ if (!wrapper || !konvaEl) return;
1987
+ function draw(W, H) {
1988
+ if (!konvaEl || W <= 0 || H <= 0) return;
1989
+ if (stageRef.current) {
1990
+ stageRef.current.destroy();
1991
+ stageRef.current = null;
1992
+ }
1993
+ const stage = new Konva.Stage({ container: konvaEl, width: W, height: H });
1994
+ const layer = new Konva.Layer({ listening: false });
1995
+ const contentH = H - SHADOW_PAD;
1996
+ layer.add(new Konva.Path({
1997
+ x: 0,
1998
+ y: BACK_OFFSET_Y,
1999
+ data: BACK_PATH,
2000
+ scaleX: W / BACK_W,
2001
+ scaleY: contentH / BACK_H,
2002
+ fill: "#ffffff",
2003
+ stroke: backStroke,
2004
+ strokeWidth: 2,
2005
+ strokeScaleEnabled: false,
2006
+ listening: false
2007
+ }));
2008
+ layer.add(new Konva.Path({
2009
+ x: FRONT_OFFSET_X,
2010
+ y: 0,
2011
+ data: FRONT_PATH,
2012
+ scaleX: (W - FRONT_OFFSET_X) / FRONT_W,
2013
+ scaleY: contentH / FRONT_H,
2014
+ fill,
2015
+ stroke,
2016
+ strokeWidth: 3,
2017
+ strokeScaleEnabled: false,
2018
+ listening: false
2019
+ }));
2020
+ stage.add(layer);
2021
+ stageRef.current = stage;
2022
+ }
2023
+ draw(wrapper.offsetWidth, wrapper.offsetHeight);
2024
+ const ro = new ResizeObserver((entries) => {
2025
+ const el = entries[0].target;
2026
+ draw(el.offsetWidth, el.offsetHeight);
2027
+ });
2028
+ ro.observe(wrapper);
2029
+ return () => {
2030
+ ro.disconnect();
2031
+ stageRef.current?.destroy();
2032
+ stageRef.current = null;
2033
+ };
2034
+ }, [fill, stroke, backStroke]);
2035
+ return /* @__PURE__ */ jsxs6("div", { ref: wrapperRef, style: { position: "relative", paddingBottom: `${SHADOW_PAD}px` }, children: [
2036
+ /* @__PURE__ */ jsx10(
2037
+ "div",
2038
+ {
2039
+ ref: konvaContainerRef,
2040
+ style: { position: "absolute", inset: 0, pointerEvents: "none", zIndex: 0, overflow: "hidden" }
2041
+ }
2042
+ ),
2043
+ /* @__PURE__ */ jsx10("div", { style: { position: "relative", padding, zIndex: 1 }, children })
2044
+ ] });
2045
+ };
2046
+ var SketchyBoxKonva_default = SketchyBoxKonva;
2047
+
2048
+ // src/VisualliCanvas.tsx
2049
+ import { jsx as jsx11, jsxs as jsxs7 } from "react/jsx-runtime";
2050
+ function hexToRgba2(hex, alpha) {
2051
+ hex = hex.replace(/^#/, "");
2052
+ let r, g, b;
2053
+ if (hex.length === 3) {
2054
+ r = parseInt(hex[0] + hex[0], 16);
2055
+ g = parseInt(hex[1] + hex[1], 16);
2056
+ b = parseInt(hex[2] + hex[2], 16);
2057
+ } else if (hex.length === 6) {
2058
+ r = parseInt(hex.substring(0, 2), 16);
2059
+ g = parseInt(hex.substring(2, 4), 16);
2060
+ b = parseInt(hex.substring(4, 6), 16);
2061
+ } else {
2062
+ return `rgba(240, 237, 230, ${alpha})`;
2063
+ }
2064
+ return `rgba(${r}, ${g}, ${b}, ${alpha})`;
2065
+ }
2066
+ function resolveDocument(props, fileText) {
2067
+ if (props.preParsedVisualli) return props.preParsedVisualli;
2068
+ const rawContent = fileText ?? props.visualliString;
2069
+ if (rawContent) {
2070
+ try {
2071
+ return parseVisualliFile(rawContent);
2072
+ } catch {
2073
+ return null;
2074
+ }
2075
+ }
2076
+ return null;
2077
+ }
2078
+ function layerLabel(doc, layer, layerId) {
2079
+ if (layer.parentNodeId) {
2080
+ const parentLayer = layer.parentLayerId ? doc.layers.get(layer.parentLayerId) : null;
2081
+ if (parentLayer) {
2082
+ const parentNode = parentLayer.nodes.find((n) => n.id === layer.parentNodeId);
2083
+ if (parentNode) {
2084
+ return Array.isArray(parentNode.data.label) ? parentNode.data.label.join(" ") : parentNode.data.label || "Untitled";
2085
+ }
2086
+ }
2087
+ }
2088
+ return layer.description ?? layerId;
2089
+ }
2090
+ function getRootLayerId(doc) {
2091
+ for (const [id, layer] of doc.layers) {
2092
+ if (!layer.parentLayerId) return id;
2093
+ }
2094
+ return doc.layers.keys().next().value ?? null;
2095
+ }
2096
+ function VisualliCanvas(props) {
2097
+ const { isDark = false, chromaticImmersion = false, onNodeClick, onLayerChange, renderOverlay, renderNodeContent, navigationStackTop = "16px", navigationStackLeft = "16px", className = "", style } = props;
2098
+ const [fileText, setFileText] = useState3(void 0);
2099
+ useEffect6(() => {
2100
+ if (!props.visualliFile) {
2101
+ setFileText(void 0);
2102
+ return;
2103
+ }
2104
+ let cancelled = false;
2105
+ if (typeof props.visualliFile === "string") {
2106
+ fetch(props.visualliFile).then((response) => {
2107
+ if (!response.ok) throw new Error(`Failed to fetch: ${response.statusText}`);
2108
+ return response.text();
2109
+ }).then((t) => {
2110
+ if (!cancelled) setFileText(t);
2111
+ }).catch(() => {
2112
+ if (!cancelled) setFileText(void 0);
2113
+ });
2114
+ } else {
2115
+ props.visualliFile.text().then((t) => {
2116
+ if (!cancelled) setFileText(t);
2117
+ }).catch(() => {
2118
+ if (!cancelled) setFileText(void 0);
2119
+ });
2120
+ }
2121
+ return () => {
2122
+ cancelled = true;
2123
+ };
2124
+ }, [props.visualliFile]);
2125
+ const doc = useMemo7(() => resolveDocument(props, fileText), [props.preParsedVisualli, props.visualliString, props.visualliFile, fileText]);
2126
+ const [navStack, setNavStack] = useState3([]);
2127
+ const [currentLayerId, setCurrentLayerId] = useState3(null);
2128
+ const parentViewports = useRef9([]);
2129
+ const [isTransitioning, setIsTransitioning] = useState3(false);
2130
+ const isTransitioningRef = useRef9(false);
2131
+ useEffect6(() => {
2132
+ if (!doc) return;
2133
+ const rootId = getRootLayerId(doc);
2134
+ if (!rootId) return;
2135
+ const rootLayer = doc.layers.get(rootId);
2136
+ setCurrentLayerId(rootId);
2137
+ setNavStack([{ layerId: rootId, layer: rootLayer, label: "Home" }]);
2138
+ parentViewports.current = [];
2139
+ }, [doc]);
2140
+ const { flatNodes, connections, containers } = useMemo7(() => {
2141
+ if (!doc || !currentLayerId) return { flatNodes: [], connections: [], containers: [] };
2142
+ try {
2143
+ return {
2144
+ flatNodes: getNodesForLayer(doc, currentLayerId),
2145
+ connections: getConnectionsForLayer(doc, currentLayerId),
2146
+ containers: getContainersForLayer(doc, currentLayerId)
2147
+ };
2148
+ } catch {
2149
+ return { flatNodes: [], connections: [], containers: [] };
2150
+ }
2151
+ }, [doc, currentLayerId]);
2152
+ const setNodes = useNodeStore((s) => s.setNodes);
2153
+ const nodes = useNodeStore((s) => s.nodes);
2154
+ const updateNodePosition = useNodeStore((s) => s.updateNodePosition);
2155
+ const setCenter = useViewportStore((s) => s.setCenter);
2156
+ const setZoom = useViewportStore((s) => s.setZoom);
2157
+ const viewport = useViewportStore((s) => ({
2158
+ centerX: s.centerX,
2159
+ centerY: s.centerY,
2160
+ zoomLevel: s.zoomLevel,
2161
+ canvasWidth: s.canvasWidth,
2162
+ canvasHeight: s.canvasHeight
2163
+ }));
2164
+ const clearSel = useSelectionStore((s) => s.clear);
2165
+ const select = useSelectionStore((s) => s.select);
2166
+ useEffect6(() => {
2167
+ const map = new Map(flatNodes.map((n) => [n.id, n]));
2168
+ setNodes(map);
2169
+ clearSel();
2170
+ }, [flatNodes, setNodes, clearSel]);
2171
+ const containerRef = useRef9(null);
2172
+ const stageRef = useRef9(null);
2173
+ const canvasWrapperRef = useRef9(null);
2174
+ const baselineTransformRef = useRef9(null);
2175
+ const konvaContentDivRef = useRef9(null);
2176
+ const lastAnimViewportRef = useRef9(null);
2177
+ const zoomOutTargetRef = useRef9(null);
2178
+ const canvasSizeRef = useRef9({ width: 0, height: 0 });
2179
+ const dragMoveRafRef = useRef9(null);
2180
+ const dragMoveLatestRef = useRef9(null);
2181
+ const dragStartPosRef = useRef9(/* @__PURE__ */ new Map());
2182
+ const baseBgColor = isDark ? "#141412" : "#F0EDE6";
2183
+ const chromaticBgColor = useMemo7(() => {
2184
+ if (!chromaticImmersion || !doc || !currentLayerId) return baseBgColor;
2185
+ const currentLayer = doc.layers.get(currentLayerId);
2186
+ if (!currentLayer) return baseBgColor;
2187
+ if (currentLayer.level === 0 || !currentLayer.parentNodeId) return baseBgColor;
2188
+ const parentLayer = currentLayer.parentLayerId ? doc.layers.get(currentLayer.parentLayerId) : null;
2189
+ if (parentLayer) {
2190
+ const parentNode = parentLayer.nodes.find((n) => n.id === currentLayer.parentNodeId);
2191
+ if (parentNode) {
2192
+ const nodeColor = parentNode.data.color || baseBgColor;
2193
+ return hexToRgba2(nodeColor, 0.15);
2194
+ }
2195
+ }
2196
+ return baseBgColor;
2197
+ }, [chromaticImmersion, doc, currentLayerId, baseBgColor]);
2198
+ const bgColor = chromaticImmersion ? chromaticBgColor : baseBgColor;
2199
+ const spatialIndexRef = useRef9(null);
2200
+ useEffect6(() => {
2201
+ if (flatNodes.length === 0) {
2202
+ spatialIndexRef.current?.clear();
2203
+ return;
2204
+ }
2205
+ const idx = new RBushSpatialIndex2();
2206
+ idx.bulkLoad(flatNodes.map((n) => {
2207
+ const blobHalfH = Math.max(NODE_HEIGHT5 * 1.6 / 2, n.width / 2 * 0.74);
2208
+ return {
2209
+ nodeId: n.id,
2210
+ bounds: {
2211
+ minX: n.x - n.width / 2,
2212
+ minY: n.y - blobHalfH,
2213
+ maxX: n.x + n.width / 2,
2214
+ maxY: n.y + blobHalfH
2215
+ }
2216
+ };
2217
+ }));
2218
+ spatialIndexRef.current = idx;
2219
+ return () => {
2220
+ spatialIndexRef.current?.clear();
2221
+ };
2222
+ }, [flatNodes]);
2223
+ const {
2224
+ handleWheel: rendererHandleWheel,
2225
+ handleMouseDown: rendererHandleMouseDown,
2226
+ handleMouseMove: rendererHandleMouseMove,
2227
+ handleMouseUp: rendererHandleMouseUp,
2228
+ canvasWidth,
2229
+ canvasHeight
2230
+ } = useKonvaRenderer({ containerRef, stageRef });
2231
+ const _cw = canvasWidth || containerRef.current?.getBoundingClientRect().width || 800;
2232
+ const _ch = canvasHeight || containerRef.current?.getBoundingClientRect().height || 600;
2233
+ canvasSizeRef.current = { width: _cw, height: _ch };
2234
+ const handleDragStart = useCallback4((nodeId) => {
2235
+ isDraggingRef.current = true;
2236
+ setIsDraggingState(true);
2237
+ setHoveredNode(null);
2238
+ const node = nodes.get(nodeId);
2239
+ if (node) dragStartPosRef.current.set(nodeId, { x: node.x, y: node.y });
2240
+ }, [nodes]);
2241
+ const handleDragMove = useCallback4((nodeId, x, y) => {
2242
+ dragMoveLatestRef.current = { nodeId, x, y };
2243
+ if (dragMoveRafRef.current !== null) return;
2244
+ dragMoveRafRef.current = requestAnimationFrame(() => {
2245
+ dragMoveRafRef.current = null;
2246
+ const latest = dragMoveLatestRef.current;
2247
+ if (latest) {
2248
+ dragMoveLatestRef.current = null;
2249
+ updateNodePosition(latest.nodeId, latest.x, latest.y);
2250
+ }
2251
+ });
2252
+ }, [updateNodePosition]);
2253
+ const handleDragEnd = useCallback4((nodeId, x, y) => {
2254
+ if (dragMoveRafRef.current !== null) {
2255
+ cancelAnimationFrame(dragMoveRafRef.current);
2256
+ dragMoveRafRef.current = null;
2257
+ }
2258
+ dragMoveLatestRef.current = null;
2259
+ isDraggingRef.current = false;
2260
+ setIsDraggingState(false);
2261
+ updateNodePosition(nodeId, x, y);
2262
+ if (spatialIndexRef.current) {
2263
+ const node = nodes.get(nodeId);
2264
+ if (node) {
2265
+ const oldPos = dragStartPosRef.current.get(nodeId);
2266
+ if (oldPos) {
2267
+ const oldHalfH = Math.max(NODE_HEIGHT5 * 1.6 / 2, node.width / 2 * 0.74);
2268
+ spatialIndexRef.current.remove(nodeId, {
2269
+ minX: oldPos.x - node.width / 2,
2270
+ minY: oldPos.y - oldHalfH,
2271
+ maxX: oldPos.x + node.width / 2,
2272
+ maxY: oldPos.y + oldHalfH
2273
+ });
2274
+ }
2275
+ const newHalfH = Math.max(NODE_HEIGHT5 * 1.6 / 2, node.width / 2 * 0.74);
2276
+ spatialIndexRef.current.insert(nodeId, {
2277
+ minX: x - node.width / 2,
2278
+ minY: y - newHalfH,
2279
+ maxX: x + node.width / 2,
2280
+ maxY: y + newHalfH
2281
+ });
2282
+ }
2283
+ }
2284
+ dragStartPosRef.current.delete(nodeId);
2285
+ }, [updateNodePosition, nodes]);
2286
+ const fitToScreenRef = useRef9(() => {
2287
+ });
2288
+ const fitToScreen = useCallback4(() => {
2289
+ if (flatNodes.length === 0) return;
2290
+ if (isTransitioningRef.current) return;
2291
+ const cw = canvasSizeRef.current.width || containerRef.current?.getBoundingClientRect().width || 800;
2292
+ const ch = canvasSizeRef.current.height || containerRef.current?.getBoundingClientRect().height || 600;
2293
+ const { centerX, centerY } = calculateFitView(flatNodes, cw, ch);
2294
+ const isRootLayer = navStack.length <= 1;
2295
+ const zoomLevel = isRootLayer ? 1 : calculateFitView(flatNodes, cw, ch).zoomLevel;
2296
+ setCenter(centerX, centerY);
2297
+ setZoom(zoomLevel);
2298
+ }, [flatNodes, navStack.length, setCenter, setZoom]);
2299
+ fitToScreenRef.current = fitToScreen;
2300
+ useEffect6(() => {
2301
+ fitToScreen();
2302
+ }, [currentLayerId]);
2303
+ const didFitAfterMeasureRef = useRef9(false);
2304
+ useEffect6(() => {
2305
+ if (!canvasWidth || didFitAfterMeasureRef.current) return;
2306
+ didFitAfterMeasureRef.current = true;
2307
+ fitToScreen();
2308
+ }, [canvasWidth, fitToScreen]);
2309
+ const navBackTargetRef = useRef9(-1);
2310
+ const { zoomIntoLayer, zoomOutToParent, isTransitioning: isAnimating } = useKonvaLayerTransition({
2311
+ stageRef,
2312
+ canvasWrapperRef,
2313
+ onSwapLayer: (childLayerId) => {
2314
+ const childLayer = doc?.layers.get(childLayerId);
2315
+ if (!childLayer || !doc) return;
2316
+ setCurrentLayerId(childLayerId);
2317
+ setNavStack((prev) => [...prev, { layerId: childLayerId, layer: childLayer, label: layerLabel(doc, childLayer, childLayerId) }]);
2318
+ onLayerChange?.(childLayerId, childLayer);
2319
+ },
2320
+ onSwapBack: () => {
2321
+ const targetIndex = navBackTargetRef.current;
2322
+ setNavStack((prev) => {
2323
+ const newStack = prev.slice(0, targetIndex + 1);
2324
+ parentViewports.current = parentViewports.current.slice(0, targetIndex);
2325
+ const newLayerId = newStack[newStack.length - 1].layerId;
2326
+ setCurrentLayerId(newLayerId);
2327
+ onLayerChange?.(newLayerId, newStack[newStack.length - 1].layer);
2328
+ return newStack;
2329
+ });
2330
+ },
2331
+ onComplete: () => {
2332
+ setIsTransitioning(false);
2333
+ isTransitioningRef.current = false;
2334
+ requestAnimationFrame(() => {
2335
+ fitToScreenRef.current();
2336
+ });
2337
+ },
2338
+ // GPU CSS-transform callbacks (matching visualli.ai exactly):
2339
+ onViewportFrame: (vp) => {
2340
+ lastAnimViewportRef.current = vp;
2341
+ const baseline = baselineTransformRef.current;
2342
+ const contentDiv = konvaContentDivRef.current;
2343
+ const { width, height } = canvasSizeRef.current;
2344
+ if (baseline && contentDiv) {
2345
+ const targetX = width / 2 - vp.centerX * vp.zoomLevel;
2346
+ const targetY = height / 2 - vp.centerY * vp.zoomLevel;
2347
+ const ratio = vp.zoomLevel / baseline.scaleX;
2348
+ const tx = targetX - baseline.x * ratio;
2349
+ const ty = targetY - baseline.y * ratio;
2350
+ contentDiv.style.transform = `translate(${tx}px, ${ty}px) scale(${ratio})`;
2351
+ } else {
2352
+ const stage = stageRef.current;
2353
+ if (stage) {
2354
+ stage.x(width / 2 - vp.centerX * vp.zoomLevel);
2355
+ stage.y(height / 2 - vp.centerY * vp.zoomLevel);
2356
+ stage.scaleX(vp.zoomLevel);
2357
+ stage.scaleY(vp.zoomLevel);
2358
+ stage.batchDraw();
2359
+ }
2360
+ }
2361
+ },
2362
+ onViewportFinal: (vp) => {
2363
+ if (konvaContentDivRef.current) konvaContentDivRef.current.style.transform = "";
2364
+ const stage = stageRef.current;
2365
+ if (stage) {
2366
+ const { width, height } = canvasSizeRef.current;
2367
+ stage.x(width / 2 - vp.centerX * vp.zoomLevel);
2368
+ stage.y(height / 2 - vp.centerY * vp.zoomLevel);
2369
+ stage.scaleX(vp.zoomLevel);
2370
+ stage.scaleY(vp.zoomLevel);
2371
+ stage.getLayers().forEach((l) => l.drawScene());
2372
+ }
2373
+ setCenter(vp.centerX, vp.centerY);
2374
+ setZoom(vp.zoomLevel);
2375
+ },
2376
+ onPhaseChange: (phase) => {
2377
+ const stage = stageRef.current;
2378
+ const contentDiv = konvaContentDivRef.current;
2379
+ if (stage && contentDiv && (phase === "swap" || phase === "zoom-back")) {
2380
+ contentDiv.style.transform = "";
2381
+ const vp = phase === "zoom-back" ? zoomOutTargetRef.current : lastAnimViewportRef.current;
2382
+ if (vp) {
2383
+ const { width, height } = canvasSizeRef.current;
2384
+ const nx = width / 2 - vp.centerX * vp.zoomLevel;
2385
+ const ny = height / 2 - vp.centerY * vp.zoomLevel;
2386
+ stage.x(nx);
2387
+ stage.y(ny);
2388
+ stage.scaleX(vp.zoomLevel);
2389
+ stage.scaleY(vp.zoomLevel);
2390
+ stage.getLayers().forEach((l) => l.drawScene());
2391
+ baselineTransformRef.current = { x: nx, y: ny, scaleX: vp.zoomLevel, scaleY: vp.zoomLevel };
2392
+ }
2393
+ }
2394
+ },
2395
+ onTransitionLifecycle: (phase) => {
2396
+ const stage = stageRef.current;
2397
+ if (phase === "start") {
2398
+ if (canvasWrapperRef.current) canvasWrapperRef.current.style.willChange = "opacity, transform";
2399
+ if (stage) {
2400
+ baselineTransformRef.current = { x: stage.x(), y: stage.y(), scaleX: stage.scaleX(), scaleY: stage.scaleY() };
2401
+ const container = stage.container();
2402
+ const cd = container?.querySelector(".konvajs-content");
2403
+ if (cd) {
2404
+ konvaContentDivRef.current = cd;
2405
+ cd.style.transformOrigin = "0 0";
2406
+ cd.style.willChange = "transform";
2407
+ }
2408
+ stage.getLayers().forEach((l) => {
2409
+ l.listening(false);
2410
+ });
2411
+ }
2412
+ } else {
2413
+ if (canvasWrapperRef.current) canvasWrapperRef.current.style.willChange = "auto";
2414
+ if (konvaContentDivRef.current) {
2415
+ konvaContentDivRef.current.style.willChange = "auto";
2416
+ konvaContentDivRef.current = null;
2417
+ }
2418
+ baselineTransformRef.current = null;
2419
+ zoomOutTargetRef.current = null;
2420
+ if (stage) {
2421
+ stage.getLayers().forEach((l) => {
2422
+ l.listening(true);
2423
+ });
2424
+ stage.batchDraw();
2425
+ }
2426
+ }
2427
+ }
2428
+ });
2429
+ const handleNavigate = useCallback4((nodeId) => {
2430
+ if (!doc || !currentLayerId) {
2431
+ return;
2432
+ }
2433
+ if (isTransitioningRef.current || isAnimating()) {
2434
+ return;
2435
+ }
2436
+ const childLayer = getChildLayerForNode(doc, nodeId, currentLayerId);
2437
+ if (!childLayer) {
2438
+ return;
2439
+ }
2440
+ const childLayerId = [...doc.layers.entries()].find(([, l]) => l === childLayer)?.[0];
2441
+ if (!childLayerId) {
2442
+ return;
2443
+ }
2444
+ const childNodes = (() => {
2445
+ try {
2446
+ return getNodesForLayer(doc, childLayerId);
2447
+ } catch {
2448
+ return [];
2449
+ }
2450
+ })();
2451
+ if (childNodes.length === 0) {
2452
+ return;
2453
+ }
2454
+ const node = nodes.get(nodeId);
2455
+ if (!node) return;
2456
+ const cw = canvasSizeRef.current.width || 800;
2457
+ const ch = canvasSizeRef.current.height || 600;
2458
+ const fitVp = calculateFitView(flatNodes, cw, ch);
2459
+ const isRootNow = navStack.length <= 1;
2460
+ parentViewports.current.push({ centerX: fitVp.centerX, centerY: fitVp.centerY, zoomLevel: isRootNow ? 0.85 : fitVp.zoomLevel });
2461
+ setIsTransitioning(true);
2462
+ isTransitioningRef.current = true;
2463
+ zoomIntoLayer(
2464
+ doc,
2465
+ node,
2466
+ childLayerId,
2467
+ childNodes,
2468
+ node.color ?? getThemeBackground(isDark, "secondary"),
2469
+ node.color ?? getThemeBackground(isDark, "primary")
2470
+ );
2471
+ }, [doc, currentLayerId, nodes, isDark, isAnimating, zoomIntoLayer]);
2472
+ const handleNavigateBack = useCallback4((targetIndex) => {
2473
+ if (targetIndex >= navStack.length - 1) return;
2474
+ if (isTransitioningRef.current || isAnimating()) return;
2475
+ const savedVp = parentViewports.current[targetIndex];
2476
+ if (!savedVp) return;
2477
+ navBackTargetRef.current = targetIndex;
2478
+ setIsTransitioning(true);
2479
+ isTransitioningRef.current = true;
2480
+ zoomOutToParent(savedVp, getThemeBackground(isDark, "primary"), getThemeBackground(isDark, "secondary"));
2481
+ }, [navStack.length, isDark, isAnimating, zoomOutToParent]);
2482
+ const baseZoomRef = useRef9(1);
2483
+ const lastZoomTransitionRef = useRef9(0);
2484
+ const ZOOM_COOLDOWN = 1e3;
2485
+ useEffect6(() => {
2486
+ baseZoomRef.current = 1;
2487
+ }, [currentLayerId]);
2488
+ useEffect6(() => {
2489
+ if (isTransitioningRef.current || isAnimating()) return;
2490
+ const now = Date.now();
2491
+ if (now - lastZoomTransitionRef.current < ZOOM_COOLDOWN) return;
2492
+ const relative = viewport.zoomLevel / baseZoomRef.current;
2493
+ if (relative >= ZOOM_NAV_IN_THRESHOLD) {
2494
+ let closest = null;
2495
+ let minDist = Infinity;
2496
+ for (const node of nodes.values()) {
2497
+ const dx = node.x - viewport.centerX;
2498
+ const dy = node.y - viewport.centerY;
2499
+ const d = dx * dx + dy * dy;
2500
+ if (d < minDist) {
2501
+ minDist = d;
2502
+ closest = node;
2503
+ }
2504
+ }
2505
+ if (closest) {
2506
+ const childLayer = doc ? getChildLayerForNode(doc, closest.id, currentLayerId ?? "") : null;
2507
+ if (childLayer) {
2508
+ lastZoomTransitionRef.current = now;
2509
+ handleNavigate(closest.id);
2510
+ baseZoomRef.current = 1;
2511
+ }
2512
+ }
2513
+ } else if (relative < ZOOM_NAV_OUT_THRESHOLD && navStack.length > 1) {
2514
+ lastZoomTransitionRef.current = now;
2515
+ handleNavigateBack(navStack.length - 2);
2516
+ baseZoomRef.current = 1;
2517
+ }
2518
+ }, [viewport.zoomLevel, viewport.centerX, viewport.centerY, nodes, doc, currentLayerId, navStack, isAnimating, handleNavigate, handleNavigateBack]);
2519
+ const toWorldCoords = useCallback4((stage, pointer) => {
2520
+ const sp = stage.position();
2521
+ const sc = stage.scaleX();
2522
+ return { worldX: (pointer.x - sp.x) / sc, worldY: (pointer.y - sp.y) / sc };
2523
+ }, []);
2524
+ const hitTestNode = useCallback4((worldX, worldY) => {
2525
+ if (!spatialIndexRef.current) return null;
2526
+ const hits = spatialIndexRef.current.query({ minX: worldX, minY: worldY, maxX: worldX, maxY: worldY });
2527
+ if (!hits.length) return null;
2528
+ let targetId = null, minDist = Infinity;
2529
+ for (const nodeId of hits) {
2530
+ const node = nodes.get(nodeId);
2531
+ if (!node) continue;
2532
+ const d = (worldX - node.x) ** 2 + (worldY - node.y) ** 2;
2533
+ if (d < minDist) {
2534
+ minDist = d;
2535
+ targetId = nodeId;
2536
+ }
2537
+ }
2538
+ return targetId;
2539
+ }, [nodes]);
2540
+ const stageActiveNodeIdRef = useRef9(null);
2541
+ const stageDragNodeOffsetRef = useRef9({ x: 0, y: 0 });
2542
+ const stageMouseDownPosRef = useRef9(null);
2543
+ const stageDragCommittedRef = useRef9(false);
2544
+ const stageLastPointerCheckRef = useRef9(0);
2545
+ const pointerNodeIdRef = useRef9(null);
2546
+ const isCanvasPanningRef = useRef9(false);
2547
+ const isDraggingRef = useRef9(false);
2548
+ const [pointerNodeId, setPointerNodeId] = useState3(null);
2549
+ const [pressedNodeId, setPressedNodeId] = useState3(null);
2550
+ const [isDraggingState, setIsDraggingState] = useState3(false);
2551
+ const [hoveredNode, setHoveredNode] = useState3(null);
2552
+ const tooltipHoverRef = useRef9(false);
2553
+ const keepTooltipOpenRef = useRef9(false);
2554
+ const clearTooltipTimerRef = useRef9(null);
2555
+ const hoveredNodePosition = useMemo7(() => {
2556
+ if (!hoveredNode) return null;
2557
+ const node = nodes.get(hoveredNode.nodeId);
2558
+ if (!node) return null;
2559
+ const cw = viewport.canvasWidth || canvasSizeRef.current.width;
2560
+ const ch = viewport.canvasHeight || canvasSizeRef.current.height;
2561
+ return {
2562
+ screenX: (node.x - viewport.centerX) * viewport.zoomLevel + cw / 2,
2563
+ screenY: (node.y - viewport.centerY) * viewport.zoomLevel + ch / 2,
2564
+ zoom: viewport.zoomLevel
2565
+ };
2566
+ }, [hoveredNode, nodes, viewport]);
2567
+ const [canvasContextMenu, setCanvasContextMenu] = useState3(null);
2568
+ useEffect6(() => {
2569
+ if (!canvasContextMenu) return;
2570
+ const close = () => setCanvasContextMenu(null);
2571
+ const onKey = (e) => {
2572
+ if (e.key === "Escape") close();
2573
+ };
2574
+ window.addEventListener("pointerdown", close);
2575
+ window.addEventListener("keydown", onKey);
2576
+ return () => {
2577
+ window.removeEventListener("pointerdown", close);
2578
+ window.removeEventListener("keydown", onKey);
2579
+ };
2580
+ }, [canvasContextMenu]);
2581
+ const handleStageMouseDown = useCallback4((e) => {
2582
+ if (e.evt.button !== 0) {
2583
+ rendererHandleMouseDown(e);
2584
+ return;
2585
+ }
2586
+ const stage = e.target.getStage();
2587
+ if (!stage) {
2588
+ rendererHandleMouseDown(e);
2589
+ return;
2590
+ }
2591
+ const pointer = stage.getPointerPosition();
2592
+ if (!pointer) {
2593
+ rendererHandleMouseDown(e);
2594
+ return;
2595
+ }
2596
+ const { worldX, worldY } = toWorldCoords(stage, pointer);
2597
+ const nodeId = hitTestNode(worldX, worldY);
2598
+ if (nodeId) {
2599
+ const node = nodes.get(nodeId);
2600
+ if (node) {
2601
+ select(nodeId);
2602
+ stageActiveNodeIdRef.current = nodeId;
2603
+ stageDragNodeOffsetRef.current = { x: worldX - node.x, y: worldY - node.y };
2604
+ stageMouseDownPosRef.current = { x: e.evt.clientX, y: e.evt.clientY };
2605
+ stageDragCommittedRef.current = false;
2606
+ setPressedNodeId(nodeId);
2607
+ setHoveredNode(null);
2608
+ return;
2609
+ }
2610
+ }
2611
+ setHoveredNode(null);
2612
+ isCanvasPanningRef.current = true;
2613
+ rendererHandleMouseDown(e);
2614
+ }, [hitTestNode, toWorldCoords, nodes, select, rendererHandleMouseDown]);
2615
+ const handleStageMouseMove = useCallback4((e) => {
2616
+ const stage = e.target.getStage();
2617
+ if (!stage) {
2618
+ rendererHandleMouseMove(e);
2619
+ return;
2620
+ }
2621
+ const pointer = stage.getPointerPosition();
2622
+ if (!pointer) {
2623
+ rendererHandleMouseMove(e);
2624
+ return;
2625
+ }
2626
+ const { worldX, worldY } = toWorldCoords(stage, pointer);
2627
+ if (stageActiveNodeIdRef.current && stageMouseDownPosRef.current) {
2628
+ const moved = Math.hypot(e.evt.clientX - stageMouseDownPosRef.current.x, e.evt.clientY - stageMouseDownPosRef.current.y);
2629
+ if (!stageDragCommittedRef.current && moved >= 5) {
2630
+ stageDragCommittedRef.current = true;
2631
+ isDraggingRef.current = true;
2632
+ setIsDraggingState(true);
2633
+ pointerNodeIdRef.current = null;
2634
+ setPointerNodeId(null);
2635
+ handleDragStart(stageActiveNodeIdRef.current);
2636
+ }
2637
+ if (stageDragCommittedRef.current) {
2638
+ const offset = stageDragNodeOffsetRef.current;
2639
+ handleDragMove(stageActiveNodeIdRef.current, worldX - offset.x, worldY - offset.y);
2640
+ stage.container().style.cursor = "grabbing";
2641
+ return;
2642
+ }
2643
+ return;
2644
+ }
2645
+ if (isCanvasPanningRef.current) {
2646
+ rendererHandleMouseMove(e);
2647
+ return;
2648
+ }
2649
+ const now = performance.now();
2650
+ if (now - stageLastPointerCheckRef.current >= 50) {
2651
+ stageLastPointerCheckRef.current = now;
2652
+ const hovered = hitTestNode(worldX, worldY);
2653
+ if (hovered !== pointerNodeIdRef.current) {
2654
+ pointerNodeIdRef.current = hovered;
2655
+ setPointerNodeId(hovered);
2656
+ }
2657
+ if (hovered) {
2658
+ stage.container().style.cursor = "pointer";
2659
+ } else {
2660
+ stage.container().style.cursor = "grab";
2661
+ }
2662
+ }
2663
+ rendererHandleMouseMove(e);
2664
+ }, [toWorldCoords, hitTestNode, handleDragStart, handleDragMove, rendererHandleMouseMove]);
2665
+ const handleStageMouseUp = useCallback4((e) => {
2666
+ setPressedNodeId(null);
2667
+ if (stageActiveNodeIdRef.current) {
2668
+ const nodeId = stageActiveNodeIdRef.current;
2669
+ if (stageDragCommittedRef.current) {
2670
+ const stage = e.target.getStage();
2671
+ if (stage) {
2672
+ const pointer = stage.getPointerPosition();
2673
+ if (pointer) {
2674
+ const { worldX, worldY } = toWorldCoords(stage, pointer);
2675
+ const offset = stageDragNodeOffsetRef.current;
2676
+ handleDragEnd(nodeId, worldX - offset.x, worldY - offset.y);
2677
+ }
2678
+ }
2679
+ } else {
2680
+ const node = nodes.get(nodeId);
2681
+ if (node) {
2682
+ const childLayer = doc ? getChildLayerForNode(doc, nodeId, currentLayerId ?? "") : null;
2683
+ if (childLayer) {
2684
+ handleNavigate(nodeId);
2685
+ }
2686
+ onNodeClick?.(node);
2687
+ }
2688
+ }
2689
+ isDraggingRef.current = false;
2690
+ setIsDraggingState(false);
2691
+ stageActiveNodeIdRef.current = null;
2692
+ stageMouseDownPosRef.current = null;
2693
+ stageDragCommittedRef.current = false;
2694
+ return;
2695
+ }
2696
+ isCanvasPanningRef.current = false;
2697
+ rendererHandleMouseUp(e);
2698
+ }, [toWorldCoords, handleDragEnd, nodes, handleNavigate, onNodeClick, rendererHandleMouseUp]);
2699
+ const handleStageContextMenu = useCallback4((e) => {
2700
+ e.evt.preventDefault();
2701
+ if (navStack.length <= 1) {
2702
+ setCanvasContextMenu(null);
2703
+ return;
2704
+ }
2705
+ setCanvasContextMenu({ x: e.evt.clientX, y: e.evt.clientY });
2706
+ }, [navStack.length]);
2707
+ const handleStageTouchStart = useCallback4((e) => handleStageMouseDown(e), [handleStageMouseDown]);
2708
+ const handleStageTouchMove = useCallback4((e) => handleStageMouseMove(e), [handleStageMouseMove]);
2709
+ const handleStageTouchEnd = useCallback4((e) => handleStageMouseUp(e), [handleStageMouseUp]);
2710
+ const handleWheel = useCallback4((e) => {
2711
+ const isZoomOut = e.evt.deltaY > 0;
2712
+ if (!isZoomOut) {
2713
+ rendererHandleWheel(e);
2714
+ return;
2715
+ }
2716
+ const liveZoom = useViewportStore.getState().zoomLevel;
2717
+ const projected = Math.max(ZOOM_MIN2, liveZoom * (1 - e.evt.deltaY / 1e3));
2718
+ if (navStack.length > 1 && projected <= ZOOM_NAV_OUT_THRESHOLD && !isTransitioningRef.current && !isAnimating()) {
2719
+ e.evt.preventDefault();
2720
+ const now = Date.now();
2721
+ if (now - lastZoomTransitionRef.current >= ZOOM_COOLDOWN) {
2722
+ lastZoomTransitionRef.current = now;
2723
+ handleNavigateBack(navStack.length - 2);
2724
+ baseZoomRef.current = 1;
2725
+ }
2726
+ return;
2727
+ }
2728
+ rendererHandleWheel(e);
2729
+ }, [rendererHandleWheel, navStack, setZoom, isAnimating, handleNavigateBack]);
2730
+ const lastHoverCheckRef = useRef9(0);
2731
+ const handleCanvasMouseMove = useCallback4((e) => {
2732
+ if (!containerRef.current || isDraggingRef.current || isCanvasPanningRef.current) {
2733
+ setHoveredNode(null);
2734
+ return;
2735
+ }
2736
+ const now = Date.now();
2737
+ if (now - lastHoverCheckRef.current < 50) return;
2738
+ lastHoverCheckRef.current = now;
2739
+ const target = e.target;
2740
+ const isOverTooltip = target.closest('[data-node-tooltip="true"]') || target.closest('[data-semantic-tooltip="true"]');
2741
+ if (tooltipHoverRef.current || keepTooltipOpenRef.current || isOverTooltip) {
2742
+ return;
2743
+ }
2744
+ const cw = viewport.canvasWidth || canvasSizeRef.current.width;
2745
+ const ch = viewport.canvasHeight || canvasSizeRef.current.height;
2746
+ const rect = containerRef.current?.getBoundingClientRect();
2747
+ const localX = rect ? e.clientX - rect.left : e.clientX;
2748
+ const localY = rect ? e.clientY - rect.top : e.clientY;
2749
+ const worldX = (localX - cw / 2) / viewport.zoomLevel + viewport.centerX;
2750
+ const worldY = (localY - ch / 2) / viewport.zoomLevel + viewport.centerY;
2751
+ let found = false;
2752
+ if (spatialIndexRef.current) {
2753
+ const hits = spatialIndexRef.current.query({ minX: worldX, minY: worldY, maxX: worldX, maxY: worldY });
2754
+ for (const nodeId of hits) {
2755
+ const node = nodes.get(nodeId);
2756
+ if (node?.description) {
2757
+ setHoveredNode({ nodeId: node.id, summary: node.description, screenX: e.clientX, screenY: e.clientY });
2758
+ found = true;
2759
+ break;
2760
+ }
2761
+ }
2762
+ }
2763
+ if (!found) {
2764
+ if (clearTooltipTimerRef.current) clearTimeout(clearTooltipTimerRef.current);
2765
+ clearTooltipTimerRef.current = setTimeout(() => {
2766
+ if (!tooltipHoverRef.current && !keepTooltipOpenRef.current) setHoveredNode(null);
2767
+ }, 150);
2768
+ } else {
2769
+ if (clearTooltipTimerRef.current) {
2770
+ clearTimeout(clearTooltipTimerRef.current);
2771
+ clearTooltipTimerRef.current = null;
2772
+ }
2773
+ }
2774
+ }, [nodes, viewport, spatialIndexRef]);
2775
+ useEffect6(() => {
2776
+ const handleKeepTooltipOpen = (event) => {
2777
+ keepTooltipOpenRef.current = event.detail.keep;
2778
+ };
2779
+ window.addEventListener("keepNodeTooltipOpen", handleKeepTooltipOpen);
2780
+ return () => {
2781
+ window.removeEventListener("keepNodeTooltipOpen", handleKeepTooltipOpen);
2782
+ };
2783
+ }, []);
2784
+ useEffect6(() => {
2785
+ return () => {
2786
+ if (clearTooltipTimerRef.current) clearTimeout(clearTooltipTimerRef.current);
2787
+ spatialIndexRef.current?.clear();
2788
+ if (dragMoveRafRef.current !== null) cancelAnimationFrame(dragMoveRafRef.current);
2789
+ };
2790
+ }, []);
2791
+ const handleZoomIn = useCallback4(() => setZoom(Math.min(viewport.zoomLevel * 1.2, 5)), [viewport.zoomLevel, setZoom]);
2792
+ const handleZoomOut = useCallback4(() => {
2793
+ setZoom(Math.max(viewport.zoomLevel / 1.2, ZOOM_MIN2));
2794
+ }, [viewport.zoomLevel, setZoom]);
2795
+ if (!doc) {
2796
+ return /* @__PURE__ */ jsx11("div", { className, style: { position: "relative", display: "flex", alignItems: "center", justifyContent: "center", background: bgColor, ...style }, children: /* @__PURE__ */ jsx11("span", { style: { color: isDark ? "rgba(255,255,255,0.4)" : "rgba(0,0,0,0.3)", fontSize: 14 }, children: "No document" }) });
2797
+ }
2798
+ const hasBackOption = navStack.length > 1;
2799
+ return /* @__PURE__ */ jsxs7(
2800
+ "div",
2801
+ {
2802
+ ref: containerRef,
2803
+ className,
2804
+ style: {
2805
+ position: "relative",
2806
+ overflow: "hidden",
2807
+ userSelect: "none",
2808
+ background: bgColor,
2809
+ width: "100%",
2810
+ height: "100%",
2811
+ ...style
2812
+ },
2813
+ onMouseMove: handleCanvasMouseMove,
2814
+ onMouseLeave: () => {
2815
+ if (!tooltipHoverRef.current && !keepTooltipOpenRef.current) {
2816
+ setHoveredNode(null);
2817
+ }
2818
+ },
2819
+ children: [
2820
+ /* @__PURE__ */ jsx11("div", { ref: canvasWrapperRef, style: { position: "relative", zIndex: 1, pointerEvents: "auto" }, children: /* @__PURE__ */ jsxs7(
2821
+ KonvaStage_default,
2822
+ {
2823
+ ref: stageRef,
2824
+ onWheel: handleWheel,
2825
+ onMouseDown: handleStageMouseDown,
2826
+ onMouseMove: handleStageMouseMove,
2827
+ onMouseUp: handleStageMouseUp,
2828
+ onContextMenu: handleStageContextMenu,
2829
+ onTouchStart: handleStageTouchStart,
2830
+ onTouchMove: handleStageTouchMove,
2831
+ onTouchEnd: handleStageTouchEnd,
2832
+ children: [
2833
+ /* @__PURE__ */ jsx11(KonvaContainerLayer, { nodes: flatNodes, containers, isDark }),
2834
+ /* @__PURE__ */ jsx11(
2835
+ KonvaEdgeLayer,
2836
+ {
2837
+ nodes: flatNodes,
2838
+ connections,
2839
+ isDark,
2840
+ isDragging: isDraggingState
2841
+ }
2842
+ ),
2843
+ /* @__PURE__ */ jsx11(
2844
+ KonvaNodeLayer,
2845
+ {
2846
+ isTransitioning,
2847
+ isDragging: isDraggingState,
2848
+ hoveredNodeId: pointerNodeId,
2849
+ pressedNodeId
2850
+ }
2851
+ )
2852
+ ]
2853
+ }
2854
+ ) }),
2855
+ canvasContextMenu && hasBackOption && /* @__PURE__ */ jsx11(
2856
+ "div",
2857
+ {
2858
+ role: "menu",
2859
+ "aria-label": "Canvas options",
2860
+ style: {
2861
+ position: "fixed",
2862
+ zIndex: 60,
2863
+ pointerEvents: "auto",
2864
+ left: `${Math.min(canvasContextMenu.x, Math.max(8, (canvasSizeRef.current.width || 800) - 152))}px`,
2865
+ top: `${Math.min(canvasContextMenu.y, Math.max(8, (canvasSizeRef.current.height || 600) - 72))}px`
2866
+ },
2867
+ onContextMenu: (e) => e.preventDefault(),
2868
+ onPointerDown: (e) => e.stopPropagation(),
2869
+ children: /* @__PURE__ */ jsx11(
2870
+ "button",
2871
+ {
2872
+ type: "button",
2873
+ style: {
2874
+ padding: "6px 16px",
2875
+ borderRadius: "10px",
2876
+ border: `1px solid ${isDark ? "#333330" : "#DDD9D0"}`,
2877
+ background: isDark ? "#1C1C1A" : "#FAF8F4",
2878
+ color: isDark ? "#F0EDE6" : "#1A1A18",
2879
+ cursor: "pointer",
2880
+ fontSize: 13,
2881
+ boxShadow: "0 4px 12px rgba(0,0,0,0.15)"
2882
+ },
2883
+ onClick: () => {
2884
+ setCanvasContextMenu(null);
2885
+ handleNavigateBack(navStack.length - 2);
2886
+ },
2887
+ children: "Back"
2888
+ }
2889
+ )
2890
+ }
2891
+ ),
2892
+ /* @__PURE__ */ jsx11(NavigationStack_default, { stack: navStack, onNavigateBack: handleNavigateBack, isDark, top: navigationStackTop, left: navigationStackLeft }),
2893
+ /* @__PURE__ */ jsx11("div", { "data-help": "zoom-controls", style: { position: "absolute", top: "24px", right: "16px", zIndex: 50, pointerEvents: "auto" }, children: /* @__PURE__ */ jsx11(ZoomControls_default, { isDark }) }),
2894
+ renderOverlay?.({ isDark, containerWidth: canvasSizeRef.current.width || 0, containerHeight: canvasSizeRef.current.height || 0 }),
2895
+ hoveredNode && hoveredNodePosition && viewport.zoomLevel < 3 && viewport.zoomLevel >= TEXT_LABEL_HIDE_BELOW_ZOOM3 && (() => {
2896
+ const nodeData = nodes.get(hoveredNode.nodeId);
2897
+ const borderColor = nodeData?.color ? darkenHexColor2(nodeData.color, 0.13) : "#5BA8D4";
2898
+ const VP_PAD = 8;
2899
+ const HALF_W = 160;
2900
+ const APPROX_H = 90;
2901
+ const containerW = canvasSizeRef.current.width || 800;
2902
+ const safeLeft = Math.max(HALF_W + VP_PAD, Math.min(hoveredNodePosition.screenX, containerW - HALF_W - VP_PAD));
2903
+ const rawTop = hoveredNodePosition.screenY - 60 * hoveredNodePosition.zoom;
2904
+ const safeTop = Math.max(VP_PAD + APPROX_H, rawTop);
2905
+ return /* @__PURE__ */ jsx11(
2906
+ "div",
2907
+ {
2908
+ "data-node-tooltip": "true",
2909
+ style: {
2910
+ position: "fixed",
2911
+ zIndex: 50,
2912
+ left: safeLeft,
2913
+ top: safeTop,
2914
+ transform: "translate(-50%, -100%)",
2915
+ transformOrigin: "bottom center",
2916
+ pointerEvents: "auto"
2917
+ },
2918
+ onMouseEnter: () => {
2919
+ tooltipHoverRef.current = true;
2920
+ if (clearTooltipTimerRef.current) {
2921
+ clearTimeout(clearTooltipTimerRef.current);
2922
+ clearTooltipTimerRef.current = null;
2923
+ }
2924
+ },
2925
+ onMouseLeave: () => {
2926
+ tooltipHoverRef.current = false;
2927
+ if (clearTooltipTimerRef.current) clearTimeout(clearTooltipTimerRef.current);
2928
+ clearTooltipTimerRef.current = setTimeout(() => {
2929
+ if (!keepTooltipOpenRef.current) setHoveredNode(null);
2930
+ }, 100);
2931
+ },
2932
+ children: /* @__PURE__ */ jsx11("div", { style: { maxWidth: "20rem", minWidth: "12rem", maxHeight: "15rem", overflowY: "auto" }, children: /* @__PURE__ */ jsx11(SketchyBoxKonva_default, { fill: "#ffffff", stroke: borderColor, backStroke: borderColor, padding: "1rem 1.25rem", children: /* @__PURE__ */ jsx11("p", { style: {
2933
+ fontFamily: "'Playpen Sans', cursive",
2934
+ fontSize: `${DESCRIPTION_TEXT_BASE_FONT_PX2}px`,
2935
+ fontWeight: 300,
2936
+ color: "#000000",
2937
+ textAlign: "center",
2938
+ lineHeight: 1.6,
2939
+ margin: 0
2940
+ }, children: renderNodeContent ? renderNodeContent({
2941
+ summary: hoveredNode.summary,
2942
+ nodeId: hoveredNode.nodeId,
2943
+ nodeColor: nodeData?.color,
2944
+ zoom: viewport.zoomLevel
2945
+ }) : hoveredNode.summary }) }) })
2946
+ }
2947
+ );
2948
+ })()
2949
+ ]
2950
+ }
2951
+ );
2952
+ }
2953
+
2954
+ // src/VisualliRenderer.tsx
2955
+ import { jsx as jsx12, jsxs as jsxs8 } from "react/jsx-runtime";
2956
+ function useSystemDark() {
2957
+ const mq = typeof window !== "undefined" ? window.matchMedia("(prefers-color-scheme: dark)") : null;
2958
+ const [dark, setDark] = useState4(mq?.matches ?? false);
2959
+ useEffect7(() => {
2960
+ if (!mq) return;
2961
+ const handler = (e) => setDark(e.matches);
2962
+ mq.addEventListener("change", handler);
2963
+ return () => mq.removeEventListener("change", handler);
2964
+ }, [mq]);
2965
+ return dark;
2966
+ }
2967
+ function useFontInjection() {
2968
+ useEffect7(() => {
2969
+ if (typeof document === "undefined") return;
2970
+ const links = [
2971
+ "https://fonts.googleapis.com",
2972
+ "https://fonts.gstatic.com",
2973
+ "https://fonts.googleapis.com/css2?family=Nunito:wght@400;600;700;800&family=Nunito+Sans:ital,wght@0,300;0,400;0,500;0,600;0,700;1,300&display=swap",
2974
+ "https://fonts.googleapis.com/css2?family=Playpen+Sans:wght@300;400;600;800&family=Story+Script:wght@400&display=swap"
2975
+ ];
2976
+ links.forEach((href, i) => {
2977
+ const id = `visualli-font-${i}`;
2978
+ if (!document.getElementById(id)) {
2979
+ const link = document.createElement("link");
2980
+ link.id = id;
2981
+ link.rel = i < 2 ? "preconnect" : "stylesheet";
2982
+ link.href = href;
2983
+ if (i === 1) link.crossOrigin = "anonymous";
2984
+ document.head.appendChild(link);
2985
+ }
2986
+ });
2987
+ }, []);
2988
+ }
2989
+ function EmptyState({ isDark }) {
2990
+ const bg = isDark ? "#131311" : "#F5F3EF";
2991
+ const fg = isDark ? "rgba(240,237,230,0.35)" : "rgba(26,26,24,0.35)";
2992
+ const bd = isDark ? "#2A2A28" : "#DDD9D0";
2993
+ const code = { fontFamily: "monospace", fontSize: 12, padding: "1px 4px", borderRadius: 4, background: isDark ? "#222220" : "#E8E4DC", border: `1px solid ${bd}` };
2994
+ return /* @__PURE__ */ jsxs8("div", { style: { display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", width: "100%", height: "100%", gap: 16, background: bg, color: fg, fontFamily: "'Nunito', system-ui, sans-serif" }, children: [
2995
+ /* @__PURE__ */ jsxs8("svg", { width: "48", height: "48", viewBox: "0 0 48 48", fill: "none", stroke: fg, strokeWidth: "1.5", strokeLinecap: "round", strokeLinejoin: "round", children: [
2996
+ /* @__PURE__ */ jsx12("rect", { x: "10", y: "6", width: "28", height: "36", rx: "3" }),
2997
+ /* @__PURE__ */ jsx12("line", { x1: "16", y1: "16", x2: "32", y2: "16" }),
2998
+ /* @__PURE__ */ jsx12("line", { x1: "16", y1: "22", x2: "32", y2: "22" }),
2999
+ /* @__PURE__ */ jsx12("line", { x1: "16", y1: "28", x2: "26", y2: "28" })
3000
+ ] }),
3001
+ /* @__PURE__ */ jsxs8("div", { style: { textAlign: "center", lineHeight: 1.7 }, children: [
3002
+ /* @__PURE__ */ jsx12("p", { style: { margin: 0, fontSize: 15, fontWeight: 600, color: isDark ? "rgba(240,237,230,0.7)" : "rgba(26,26,24,0.7)" }, children: "No .visualli file provided" }),
3003
+ /* @__PURE__ */ jsxs8("p", { style: { margin: "4px 0 0", fontSize: 13 }, children: [
3004
+ "Pass a ",
3005
+ /* @__PURE__ */ jsx12("code", { style: code, children: "visualliFile" }),
3006
+ " or ",
3007
+ /* @__PURE__ */ jsx12("code", { style: code, children: "visualliString" }),
3008
+ " prop."
3009
+ ] })
3010
+ ] })
3011
+ ] });
3012
+ }
3013
+ function LoadingState({ isDark }) {
3014
+ const bg = isDark ? "#131311" : "#F5F3EF";
3015
+ const fg = isDark ? "rgba(240,237,230,0.5)" : "rgba(26,26,24,0.5)";
3016
+ const dot = isDark ? "rgba(240,237,230,0.6)" : "rgba(26,26,24,0.45)";
3017
+ return /* @__PURE__ */ jsxs8("div", { style: { display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", width: "100%", height: "100%", gap: 16, background: bg, color: fg, fontFamily: "'Nunito', system-ui, sans-serif" }, children: [
3018
+ /* @__PURE__ */ jsx12("div", { style: { display: "flex", gap: 8 }, children: [0, 1, 2].map((i) => /* @__PURE__ */ jsx12("div", { style: { width: 8, height: 8, borderRadius: "50%", background: dot, animation: `vr-pulse 1.2s ease-in-out ${i * 0.2}s infinite` } }, i)) }),
3019
+ /* @__PURE__ */ jsx12("style", { children: `@keyframes vr-pulse{0%,80%,100%{opacity:.2;transform:scale(.9)}40%{opacity:1;transform:scale(1.1)}}` }),
3020
+ /* @__PURE__ */ jsx12("p", { style: { margin: 0, fontSize: 14 }, children: "Loading .visualli file\u2026" })
3021
+ ] });
3022
+ }
3023
+ function ErrorState({ message, isDark }) {
3024
+ const bg = isDark ? "#131311" : "#F5F3EF";
3025
+ const bd = isDark ? "#3a1f1f" : "#F0CECE";
3026
+ const card = isDark ? "#1e1212" : "#FFF5F5";
3027
+ const head = isDark ? "#F87171" : "#DC2626";
3028
+ const body = isDark ? "rgba(248,113,113,0.8)" : "rgba(180,40,40,0.8)";
3029
+ return /* @__PURE__ */ jsx12("div", { style: { display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", width: "100%", height: "100%", padding: "24px", background: bg, fontFamily: "'Nunito', system-ui, sans-serif" }, children: /* @__PURE__ */ jsxs8("div", { style: { maxWidth: 480, width: "100%", padding: "20px 24px", borderRadius: 12, border: `1px solid ${bd}`, background: card }, children: [
3030
+ /* @__PURE__ */ jsxs8("div", { style: { display: "flex", alignItems: "center", gap: 10, marginBottom: 12 }, children: [
3031
+ /* @__PURE__ */ jsxs8("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "none", stroke: head, strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [
3032
+ /* @__PURE__ */ jsx12("circle", { cx: "12", cy: "12", r: "10" }),
3033
+ /* @__PURE__ */ jsx12("line", { x1: "12", y1: "8", x2: "12", y2: "12" }),
3034
+ /* @__PURE__ */ jsx12("line", { x1: "12", y1: "16", x2: "12.01", y2: "16" })
3035
+ ] }),
3036
+ /* @__PURE__ */ jsx12("span", { style: { fontSize: 15, fontWeight: 700, color: head }, children: "Failed to parse .visualli file" })
3037
+ ] }),
3038
+ /* @__PURE__ */ jsx12("p", { style: { margin: 0, fontSize: 13, color: body, lineHeight: 1.6, wordBreak: "break-word", fontFamily: "monospace" }, children: message })
3039
+ ] }) });
3040
+ }
3041
+ var WORKER_SCRIPT = `
3042
+ self.onmessage = function(e) {
3043
+ var content = e.data;
3044
+ try {
3045
+ var lines = content.trim().split('\\n');
3046
+ var doc = {
3047
+ meta: null,
3048
+ layers: new Map(),
3049
+ layersByLevel: new Map(),
3050
+ rootLayer: null,
3051
+ };
3052
+ for (var i = 0; i < lines.length; i++) {
3053
+ var line = lines[i].trim();
3054
+ if (!line) continue;
3055
+ var obj;
3056
+ try { obj = JSON.parse(line); }
3057
+ catch(err) { throw new Error('Failed to parse JSON at line ' + (i+1) + ': ' + err); }
3058
+ if (!obj.type) throw new Error("Missing 'type' field at line " + (i+1));
3059
+ if (obj.type === 'meta') {
3060
+ doc.meta = obj;
3061
+ } else if (obj.type === 'layer') {
3062
+ doc.layers.set(obj.id, obj);
3063
+ if (!doc.layersByLevel.has(obj.level)) doc.layersByLevel.set(obj.level, []);
3064
+ doc.layersByLevel.get(obj.level).push(obj);
3065
+ if (obj.level === 0) doc.rootLayer = obj;
3066
+ }
3067
+ }
3068
+ if (!doc.meta) throw new Error('Missing required meta section');
3069
+ if (!doc.rootLayer) throw new Error('Missing root layer (level 0)');
3070
+ self.postMessage({ ok: true, doc: doc });
3071
+ } catch(err) {
3072
+ self.postMessage({ ok: false, message: err.message || String(err) });
3073
+ }
3074
+ };
3075
+ `;
3076
+ var _workerUrl = null;
3077
+ function getWorkerUrl() {
3078
+ if (!_workerUrl) {
3079
+ _workerUrl = URL.createObjectURL(new Blob([WORKER_SCRIPT], { type: "text/javascript" }));
3080
+ }
3081
+ return _workerUrl;
3082
+ }
3083
+ function useAsyncParse(content, useWorker) {
3084
+ const [state, setState] = useState4({ status: "idle" });
3085
+ const workerRef = useRef10(null);
3086
+ useEffect7(() => {
3087
+ if (!content) {
3088
+ setState({ status: "idle" });
3089
+ return;
3090
+ }
3091
+ setState({ status: "loading" });
3092
+ let cancelled = false;
3093
+ if (useWorker && typeof Worker !== "undefined") {
3094
+ workerRef.current?.terminate();
3095
+ const w = new Worker(getWorkerUrl());
3096
+ workerRef.current = w;
3097
+ w.onmessage = (e) => {
3098
+ if (cancelled) return;
3099
+ if (e.data.ok) {
3100
+ setState({ status: "ready", doc: e.data.doc });
3101
+ } else {
3102
+ setState({ status: "error", message: e.data.message });
3103
+ }
3104
+ };
3105
+ w.onerror = (e) => {
3106
+ if (!cancelled) setState({ status: "error", message: e.message });
3107
+ };
3108
+ w.postMessage(content);
3109
+ return () => {
3110
+ cancelled = true;
3111
+ w.terminate();
3112
+ workerRef.current = null;
3113
+ };
3114
+ }
3115
+ Promise.resolve().then(() => {
3116
+ if (cancelled) return;
3117
+ try {
3118
+ const doc = parseVisualliFile2(content);
3119
+ if (!cancelled) setState({ status: "ready", doc });
3120
+ } catch (err) {
3121
+ if (!cancelled) setState({ status: "error", message: err instanceof Error ? err.message : String(err) });
3122
+ }
3123
+ });
3124
+ return () => {
3125
+ cancelled = true;
3126
+ };
3127
+ }, [content, useWorker]);
3128
+ useEffect7(() => () => {
3129
+ workerRef.current?.terminate();
3130
+ }, []);
3131
+ return state;
3132
+ }
3133
+ function useFileText(file) {
3134
+ const [text, setText] = useState4(void 0);
3135
+ const [error, setError] = useState4(void 0);
3136
+ const [loading, setLoading] = useState4(false);
3137
+ useEffect7(() => {
3138
+ if (!file) {
3139
+ setText(void 0);
3140
+ setError(void 0);
3141
+ setLoading(false);
3142
+ return;
3143
+ }
3144
+ setLoading(true);
3145
+ setText(void 0);
3146
+ setError(void 0);
3147
+ let cancelled = false;
3148
+ if (typeof file === "string") {
3149
+ fetch(file).then((response) => {
3150
+ if (!response.ok) throw new Error(`Failed to fetch file: ${response.statusText}`);
3151
+ return response.text();
3152
+ }).then((t) => {
3153
+ if (!cancelled) {
3154
+ setText(t);
3155
+ setLoading(false);
3156
+ }
3157
+ }).catch((err) => {
3158
+ if (!cancelled) {
3159
+ setError(err instanceof Error ? err.message : String(err));
3160
+ setLoading(false);
3161
+ }
3162
+ });
3163
+ } else {
3164
+ file.text().then((t) => {
3165
+ if (!cancelled) {
3166
+ setText(t);
3167
+ setLoading(false);
3168
+ }
3169
+ }).catch((err) => {
3170
+ if (!cancelled) {
3171
+ setError(err instanceof Error ? err.message : String(err));
3172
+ setLoading(false);
3173
+ }
3174
+ });
3175
+ }
3176
+ return () => {
3177
+ cancelled = true;
3178
+ };
3179
+ }, [file]);
3180
+ return { text, error, loading };
3181
+ }
3182
+ function VisualliRenderer({
3183
+ visualliString,
3184
+ visualliFile,
3185
+ theme = "light",
3186
+ width = "100%",
3187
+ height = "100%",
3188
+ useWorker = true,
3189
+ chromaticImmersion = false,
3190
+ className,
3191
+ style
3192
+ }) {
3193
+ useFontInjection();
3194
+ const systemDark = useSystemDark();
3195
+ const isDark = theme === "dark" ? true : theme === "light" ? false : systemDark;
3196
+ const cssWidth = typeof width === "number" ? `${width}px` : width;
3197
+ const cssHeight = typeof height === "number" ? `${height}px` : height;
3198
+ const { text: fileText, error: fileReadError, loading: fileReading } = useFileText(visualliFile);
3199
+ const rawContent = fileText ?? visualliString;
3200
+ const parseState = useAsyncParse(rawContent, useWorker);
3201
+ const resolvedDoc = useMemo8(() => {
3202
+ if (parseState.status === "ready") return parseState.doc;
3203
+ return null;
3204
+ }, [parseState]);
3205
+ const wrapperStyle = { width: cssWidth, height: cssHeight, overflow: "hidden", ...style };
3206
+ if (!visualliString && !visualliFile) {
3207
+ return /* @__PURE__ */ jsx12("div", { className, style: wrapperStyle, children: /* @__PURE__ */ jsx12(EmptyState, { isDark }) });
3208
+ }
3209
+ if (fileReading || parseState.status === "loading") {
3210
+ return /* @__PURE__ */ jsx12("div", { className, style: wrapperStyle, children: /* @__PURE__ */ jsx12(LoadingState, { isDark }) });
3211
+ }
3212
+ if (fileReadError || parseState.status === "error") {
3213
+ const msg = fileReadError ?? (parseState.status === "error" ? parseState.message : "Unknown error");
3214
+ return /* @__PURE__ */ jsx12("div", { className, style: wrapperStyle, children: /* @__PURE__ */ jsx12(ErrorState, { message: msg, isDark }) });
3215
+ }
3216
+ return /* @__PURE__ */ jsx12("div", { className, style: wrapperStyle, children: /* @__PURE__ */ jsx12(
3217
+ VisualliCanvas,
3218
+ {
3219
+ preParsedVisualli: resolvedDoc ?? void 0,
3220
+ isDark,
3221
+ chromaticImmersion,
3222
+ style: { width: "100%", height: "100%" }
3223
+ }
3224
+ ) });
3225
+ }
3226
+
3227
+ // src/context/VisualliContext.tsx
3228
+ import { createContext, useContext } from "react";
3229
+ import { jsx as jsx13 } from "react/jsx-runtime";
3230
+ var VisualliContext = createContext(null);
3231
+ function VisualliProvider({
3232
+ children
3233
+ }) {
3234
+ const value = {};
3235
+ return /* @__PURE__ */ jsx13(VisualliContext.Provider, { value, children });
3236
+ }
3237
+ function useVisualli() {
3238
+ const ctx = useContext(VisualliContext);
3239
+ if (!ctx) {
3240
+ return {};
3241
+ }
3242
+ return ctx;
3243
+ }
3244
+ export {
3245
+ ACTIVE_BLOB_TYPES,
3246
+ ALL_BLOB_SHAPES2 as ALL_BLOB_SHAPES,
3247
+ BLOB_LAYER_CONFIG,
3248
+ BLOB_TEXT_OFFSETS,
3249
+ DESCRIPTION_TEXT_BASE_FONT_PX,
3250
+ KonvaContainer_default as KonvaContainer,
3251
+ KonvaContainerLayer,
3252
+ KonvaEdge_default as KonvaEdge,
3253
+ KonvaEdgeLayer,
3254
+ KonvaLayerTransitionAnimator,
3255
+ KonvaNode_default as KonvaNode,
3256
+ KonvaNodeLayer,
3257
+ KonvaStage_default as KonvaStage,
3258
+ NODE_LAYER_CONFIG,
3259
+ NODE_TEXT_BASE_FONT_PX,
3260
+ NavigationStack_default as NavigationStack,
3261
+ SketchyBoxKonva_default as SketchyBoxKonva,
3262
+ VisualliCanvas,
3263
+ VisualliProvider,
3264
+ VisualliRenderer,
3265
+ ZoomControls_default as ZoomControls,
3266
+ buildBlobPathData,
3267
+ calculateFitCenter,
3268
+ calculateFitView,
3269
+ calculateFitZoom,
3270
+ computeEdgeLabelScale,
3271
+ computeNodeTextScreenScale,
3272
+ computeNodeTextWorldScale,
3273
+ computeOverlayScale,
3274
+ drawBlobPath,
3275
+ getBlobTypeForLayer,
3276
+ getChildLayerForNode,
3277
+ getConnectionsForLayer,
3278
+ getLayerForNavigation,
3279
+ konvaLayerTransitionAnimator,
3280
+ useKonvaLayerTransition,
3281
+ useKonvaRenderer,
3282
+ useNodeStore,
3283
+ useRenderConfigStore,
3284
+ useSelectionStore,
3285
+ useViewportNodes,
3286
+ useViewportStore,
3287
+ useVisualli
3288
+ };
3289
+ //# sourceMappingURL=index.js.map