@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
@@ -0,0 +1,1087 @@
1
+ // ─── VisualliCanvas ───────────────────────────────────────────────────────────
2
+ //
3
+ // Main canvas component for rendering a VisualliDocument.
4
+ // Matches the visualli.ai enhanced canvas implementation:
5
+ // • Stage-level hit detection via RBush spatial index (no Konva hit-canvas)
6
+ // • Node description tooltip (sketchy-box style)
7
+ // • Chromatic immersion background (color from current node)
8
+ // • Auto-zoom navigation (zoom in → child layer, zoom out → parent layer)
9
+ // • Professional layer transitions (zoom in / zoom out animations)
10
+ // • Vertical left-side navigation stack
11
+ // • Zoom controls (icon-based, top-right)
12
+ // • Context menu (right-click to go back)
13
+ //
14
+ // NOT included (SDK-only, no generation UI):
15
+ // • Sidebar, search, generation
16
+ // • Semantic anchor tooltips (special word meanings)
17
+ // • Branding logo
18
+ // • Help overlay / view-source panel
19
+
20
+ import React, { useRef, useState, useCallback, useEffect, useMemo, useLayoutEffect } from 'react';
21
+ import type Konva from 'konva';
22
+ import type { VisualliDocument, VisualliLayer, FlatNode, MindMapConnection } from '@visualli/core';
23
+ import {
24
+ parseVisualliFile,
25
+ getNodesForLayer,
26
+ getThemeBackground,
27
+ darkenHexColor,
28
+ RBushSpatialIndex,
29
+ NODE_HEIGHT,
30
+ TEXT_LABEL_HIDE_BELOW_ZOOM,
31
+ ZOOM_NAV_IN_THRESHOLD,
32
+ ZOOM_NAV_OUT_THRESHOLD,
33
+ ZOOM_MIN,
34
+ } from '@visualli/core';
35
+
36
+ import { useNodeStore } from './stores/useNodeStore';
37
+ import { useViewportStore } from './stores/useViewportStore';
38
+ import { useSelectionStore } from './stores/stores';
39
+
40
+ import { useKonvaRenderer } from './hooks/useKonvaRenderer';
41
+ import { useKonvaLayerTransition } from './hooks/useKonvaLayerTransition';
42
+
43
+ import KonvaStage from './components/KonvaStage';
44
+ import KonvaNodeLayer from './components/KonvaNodeLayer';
45
+ import KonvaEdgeLayer from './components/KonvaEdgeLayer';
46
+ import KonvaContainerLayer from './components/KonvaContainerLayer';
47
+ import NavigationStack, { type NavStackEntry } from './components/NavigationStack';
48
+ import ZoomControls from './components/ZoomControls';
49
+ import SketchyBoxKonva from './components/SketchyBoxKonva';
50
+
51
+ import { getChildLayerForNode, calculateFitView, getConnectionsForLayer, getContainersForLayer } from './utils/layerNavigation';
52
+ import type { ContainerGroup } from './components/KonvaContainerLayer';
53
+ import type { AnimatorViewport } from './animations/konvaLayerTransition';
54
+ import { DESCRIPTION_TEXT_BASE_FONT_PX } from './config/textScaling';
55
+ import { useVisualli } from './context/VisualliContext';
56
+
57
+ // Helper to convert hex color to rgba with transparency
58
+ function hexToRgba(hex: string, alpha: number): string {
59
+ // Remove # if present
60
+ hex = hex.replace(/^#/, '');
61
+
62
+ // Parse hex values
63
+ let r: number, g: number, b: number;
64
+ if (hex.length === 3) {
65
+ r = parseInt(hex[0] + hex[0], 16);
66
+ g = parseInt(hex[1] + hex[1], 16);
67
+ b = parseInt(hex[2] + hex[2], 16);
68
+ } else if (hex.length === 6) {
69
+ r = parseInt(hex.substring(0, 2), 16);
70
+ g = parseInt(hex.substring(2, 4), 16);
71
+ b = parseInt(hex.substring(4, 6), 16);
72
+ } else {
73
+ return `rgba(240, 237, 230, ${alpha})`; // fallback
74
+ }
75
+
76
+ return `rgba(${r}, ${g}, ${b}, ${alpha})`;
77
+ }
78
+
79
+ // ── Helpers ───────────────────────────────────────────────────────────────────
80
+
81
+ function resolveDocument(props: VisualliCanvasProps, fileText?: string): VisualliDocument | null {
82
+ if (props.preParsedVisualli) return props.preParsedVisualli;
83
+ const rawContent = fileText ?? props.visualliString;
84
+ if (rawContent) {
85
+ try { return parseVisualliFile(rawContent); } catch { return null; }
86
+ }
87
+ return null;
88
+ }
89
+
90
+ function layerLabel(doc: VisualliDocument, layer: VisualliLayer, layerId: string): string {
91
+ if (layer.parentNodeId) {
92
+ // Find the parent node in the parent layer to get its title
93
+ const parentLayer = layer.parentLayerId ? doc.layers.get(layer.parentLayerId) : null;
94
+ if (parentLayer) {
95
+ const parentNode = parentLayer.nodes.find(n => n.id === layer.parentNodeId);
96
+ if (parentNode) {
97
+ return Array.isArray(parentNode.data.label)
98
+ ? (parentNode.data.label as string[]).join(' ')
99
+ : (parentNode.data.label || 'Untitled');
100
+ }
101
+ }
102
+ }
103
+ return layer.description ?? layerId;
104
+ }
105
+
106
+ function getRootLayerId(doc: VisualliDocument): string | null {
107
+ for (const [id, layer] of doc.layers) {
108
+ if (!layer.parentLayerId) return id;
109
+ }
110
+ return doc.layers.keys().next().value ?? null;
111
+ }
112
+
113
+ // ── Props ─────────────────────────────────────────────────────────────────────
114
+
115
+ export interface VisualliCanvasProps {
116
+ preParsedVisualli?: VisualliDocument;
117
+ /**
118
+ * Raw JSONL string — just pass the string data directly.
119
+ * No preprocessing needed by user.
120
+ */
121
+ visualliString?: string;
122
+ /**
123
+ * A .visualli file — can be:
124
+ * - File object (from file input)
125
+ * - String path to file (e.g. '/data/file.visualli')
126
+ * Component handles all fetching and parsing automatically.
127
+ */
128
+ visualliFile?: File | string;
129
+ isDark?: boolean;
130
+ chromaticImmersion?: boolean;
131
+ onNodeClick?: (node: FlatNode) => void;
132
+ onLayerChange?: (layerId: string, layer: VisualliLayer) => void;
133
+
134
+ // Extension points for private features (implement in consuming app)
135
+ renderOverlay?: (params: { isDark: boolean; containerWidth: number; containerHeight: number }) => React.ReactNode;
136
+ renderNodeContent?: (params: {
137
+ summary: string;
138
+ nodeId: string;
139
+ nodeColor?: string;
140
+ zoom: number;
141
+ }) => React.ReactNode;
142
+ navigationStackTop?: string;
143
+ navigationStackLeft?: string;
144
+
145
+ className?: string;
146
+ style?: React.CSSProperties;
147
+ }
148
+
149
+ // ── Component ─────────────────────────────────────────────────────────────────
150
+
151
+ export default function VisualliCanvas(props: VisualliCanvasProps) {
152
+ const { isDark = false, chromaticImmersion = false, onNodeClick, onLayerChange, renderOverlay, renderNodeContent, navigationStackTop = '16px', navigationStackLeft = '16px', className = '', style } = props;
153
+
154
+ // Read file if provided (handles both File objects and string paths)
155
+ const [fileText, setFileText] = useState<string | undefined>(undefined);
156
+ useEffect(() => {
157
+ if (!props.visualliFile) { setFileText(undefined); return; }
158
+ let cancelled = false;
159
+
160
+ // Handle string path - fetch the file
161
+ if (typeof props.visualliFile === 'string') {
162
+ fetch(props.visualliFile)
163
+ .then(response => {
164
+ if (!response.ok) throw new Error(`Failed to fetch: ${response.statusText}`);
165
+ return response.text();
166
+ })
167
+ .then(t => {
168
+ if (!cancelled) setFileText(t);
169
+ })
170
+ .catch(() => {
171
+ if (!cancelled) setFileText(undefined);
172
+ });
173
+ }
174
+ // Handle File object - read as text
175
+ else {
176
+ props.visualliFile.text().then(t => {
177
+ if (!cancelled) setFileText(t);
178
+ }).catch(() => {
179
+ if (!cancelled) setFileText(undefined);
180
+ });
181
+ }
182
+
183
+ return () => { cancelled = true; };
184
+ }, [props.visualliFile]);
185
+
186
+ const doc = useMemo(() => resolveDocument(props, fileText), [props.preParsedVisualli, props.visualliString, props.visualliFile, fileText]); // eslint-disable-line react-hooks/exhaustive-deps
187
+
188
+ // ── Navigation ────────────────────────────────────────────────────────────
189
+ const [navStack, setNavStack] = useState<NavStackEntry[]>([]);
190
+ const [currentLayerId, setCurrentLayerId] = useState<string | null>(null);
191
+ const parentViewports = useRef<AnimatorViewport[]>([]);
192
+ const [isTransitioning, setIsTransitioning] = useState(false);
193
+ const isTransitioningRef = useRef(false);
194
+
195
+ useEffect(() => {
196
+ if (!doc) return;
197
+ const rootId = getRootLayerId(doc);
198
+ if (!rootId) return;
199
+ const rootLayer = doc.layers.get(rootId)!;
200
+ setCurrentLayerId(rootId);
201
+ setNavStack([{ layerId: rootId, layer: rootLayer, label: 'Home' }]);
202
+ parentViewports.current = [];
203
+ }, [doc]);
204
+
205
+ // ── Active layer → FlatNodes ──────────────────────────────────────────────
206
+ const { flatNodes, connections, containers } = useMemo(() => {
207
+ if (!doc || !currentLayerId) return { flatNodes: [] as FlatNode[], connections: [] as MindMapConnection[], containers: [] as ContainerGroup[] };
208
+ try {
209
+ return {
210
+ flatNodes: getNodesForLayer(doc, currentLayerId),
211
+ connections: getConnectionsForLayer(doc, currentLayerId),
212
+ containers: getContainersForLayer(doc, currentLayerId) as ContainerGroup[],
213
+ };
214
+ } catch { return { flatNodes: [] as FlatNode[], connections: [] as MindMapConnection[], containers: [] as ContainerGroup[] }; }
215
+ }, [doc, currentLayerId]);
216
+
217
+ // ── Stores ────────────────────────────────────────────────────────────────
218
+ const setNodes = useNodeStore(s => s.setNodes);
219
+ const nodes = useNodeStore(s => s.nodes);
220
+ const updateNodePosition = useNodeStore(s => s.updateNodePosition);
221
+ const setCenter = useViewportStore(s => s.setCenter);
222
+ const setZoom = useViewportStore(s => s.setZoom);
223
+ const viewport = useViewportStore(s => ({
224
+ centerX: s.centerX, centerY: s.centerY, zoomLevel: s.zoomLevel,
225
+ canvasWidth: s.canvasWidth, canvasHeight: s.canvasHeight,
226
+ }));
227
+ const clearSel = useSelectionStore(s => s.clear);
228
+ const select = useSelectionStore(s => s.select);
229
+
230
+ // Sync nodes → store
231
+ useEffect(() => {
232
+ const map = new Map(flatNodes.map(n => [n.id, n]));
233
+ setNodes(map);
234
+ clearSel();
235
+ }, [flatNodes, setNodes, clearSel]);
236
+
237
+ // ── Canvas refs ───────────────────────────────────────────────────────────
238
+ const containerRef = useRef<HTMLDivElement | null>(null);
239
+ const stageRef = useRef<Konva.Stage | null>(null);
240
+ const canvasWrapperRef = useRef<HTMLDivElement>(null);
241
+
242
+ // ── GPU CSS-transform transition refs (matches visualli.ai) ───────────────
243
+ // During animations we express viewport deltas as CSS transforms on Konva's
244
+ // container div — zero canvas redraws, handled entirely by the GPU compositor.
245
+ const baselineTransformRef = useRef<{ x: number; y: number; scaleX: number; scaleY: number } | null>(null);
246
+ const konvaContentDivRef = useRef<HTMLDivElement | null>(null);
247
+ const lastAnimViewportRef = useRef<{ centerX: number; centerY: number; zoomLevel: number } | null>(null);
248
+ const zoomOutTargetRef = useRef<{ centerX: number; centerY: number; zoomLevel: number } | null>(null);
249
+ // canvasSizeRef is kept in sync with the measured container size (not window).
250
+ // Initialise to 0 — it will be updated synchronously before the first frame.
251
+ const canvasSizeRef = useRef({ width: 0, height: 0 });
252
+
253
+ // ── Node drag refs (RAF-throttled, matches visualli.ai) ───────────────────
254
+ const dragMoveRafRef = useRef<number | null>(null);
255
+ const dragMoveLatestRef = useRef<{ nodeId: string; x: number; y: number } | null>(null);
256
+ const dragStartPosRef = useRef<Map<string, { x: number; y: number }>>(new Map());
257
+
258
+ // ── Background color (with chromatic immersion support) ──────────────────
259
+ const baseBgColor = isDark ? '#141412' : '#F0EDE6';
260
+
261
+ // For chromatic immersion: get the parent node color for child layers
262
+ const chromaticBgColor = useMemo(() => {
263
+ if (!chromaticImmersion || !doc || !currentLayerId) return baseBgColor;
264
+
265
+ const currentLayer = doc.layers.get(currentLayerId);
266
+ if (!currentLayer) return baseBgColor;
267
+
268
+ // Root layer always uses base background
269
+ if (currentLayer.level === 0 || !currentLayer.parentNodeId) return baseBgColor;
270
+
271
+ // For child layers, find the parent node's color
272
+ const parentLayer = currentLayer.parentLayerId ? doc.layers.get(currentLayer.parentLayerId) : null;
273
+ if (parentLayer) {
274
+ const parentNode = parentLayer.nodes.find(n => n.id === currentLayer.parentNodeId);
275
+ if (parentNode) {
276
+ const nodeColor = parentNode.data.color || baseBgColor;
277
+ // Add transparency to the parent node color for subtle effect
278
+ return hexToRgba(nodeColor, 0.15);
279
+ }
280
+ }
281
+
282
+ return baseBgColor;
283
+ }, [chromaticImmersion, doc, currentLayerId, baseBgColor]);
284
+
285
+ const bgColor = chromaticImmersion ? chromaticBgColor : baseBgColor;
286
+
287
+ // ── Spatial index (for stage-level hit detection) ─────────────────────────
288
+ const spatialIndexRef = useRef<RBushSpatialIndex | null>(null);
289
+ useEffect(() => {
290
+ if (flatNodes.length === 0) { spatialIndexRef.current?.clear(); return; }
291
+ const idx = new RBushSpatialIndex();
292
+ idx.bulkLoad(flatNodes.map(n => {
293
+ const blobHalfH = Math.max((NODE_HEIGHT * 1.6) / 2, (n.width / 2) * 0.74);
294
+ return {
295
+ nodeId: n.id,
296
+ bounds: {
297
+ minX: n.x - n.width / 2, minY: n.y - blobHalfH,
298
+ maxX: n.x + n.width / 2, maxY: n.y + blobHalfH,
299
+ },
300
+ };
301
+ }));
302
+ spatialIndexRef.current = idx;
303
+ return () => { spatialIndexRef.current?.clear(); };
304
+ }, [flatNodes]);
305
+
306
+ // ── Konva renderer (resize + FPS) ─────────────────────────────────────────
307
+ const {
308
+ handleWheel: rendererHandleWheel,
309
+ handleMouseDown: rendererHandleMouseDown,
310
+ handleMouseMove: rendererHandleMouseMove,
311
+ handleMouseUp: rendererHandleMouseUp,
312
+ canvasWidth,
313
+ canvasHeight,
314
+ } = useKonvaRenderer({ containerRef, stageRef });
315
+
316
+ // Keep canvasSizeRef in sync with the measured container size.
317
+ // The fallback to containerRef.getBoundingClientRect covers the period before
318
+ // the first ResizeObserver callback fires.
319
+ const _cw = canvasWidth || containerRef.current?.getBoundingClientRect().width || 800;
320
+ const _ch = canvasHeight || containerRef.current?.getBoundingClientRect().height || 600;
321
+ canvasSizeRef.current = { width: _cw, height: _ch };
322
+
323
+ // ── Individual node drag (RAF-throttled, matches visualli.ai) ────────────
324
+ const handleDragStart = useCallback((nodeId: string) => {
325
+ isDraggingRef.current = true;
326
+ setIsDraggingState(true);
327
+ setHoveredNode(null);
328
+ const node = nodes.get(nodeId);
329
+ if (node) dragStartPosRef.current.set(nodeId, { x: node.x, y: node.y });
330
+ }, [nodes]);
331
+
332
+ const handleDragMove = useCallback((nodeId: string, x: number, y: number) => {
333
+ dragMoveLatestRef.current = { nodeId, x, y };
334
+ if (dragMoveRafRef.current !== null) return;
335
+ dragMoveRafRef.current = requestAnimationFrame(() => {
336
+ dragMoveRafRef.current = null;
337
+ const latest = dragMoveLatestRef.current;
338
+ if (latest) { dragMoveLatestRef.current = null; updateNodePosition(latest.nodeId, latest.x, latest.y); }
339
+ });
340
+ }, [updateNodePosition]);
341
+
342
+ const handleDragEnd = useCallback((nodeId: string, x: number, y: number) => {
343
+ if (dragMoveRafRef.current !== null) { cancelAnimationFrame(dragMoveRafRef.current); dragMoveRafRef.current = null; }
344
+ dragMoveLatestRef.current = null;
345
+ isDraggingRef.current = false;
346
+ setIsDraggingState(false);
347
+ updateNodePosition(nodeId, x, y);
348
+ // Update spatial index entry
349
+ if (spatialIndexRef.current) {
350
+ const node = nodes.get(nodeId);
351
+ if (node) {
352
+ const oldPos = dragStartPosRef.current.get(nodeId);
353
+ if (oldPos) {
354
+ const oldHalfH = Math.max((NODE_HEIGHT * 1.6) / 2, (node.width / 2) * 0.74);
355
+ spatialIndexRef.current.remove(nodeId, {
356
+ minX: oldPos.x - node.width / 2, minY: oldPos.y - oldHalfH,
357
+ maxX: oldPos.x + node.width / 2, maxY: oldPos.y + oldHalfH,
358
+ });
359
+ }
360
+ const newHalfH = Math.max((NODE_HEIGHT * 1.6) / 2, (node.width / 2) * 0.74);
361
+ spatialIndexRef.current.insert(nodeId, {
362
+ minX: x - node.width / 2, minY: y - newHalfH,
363
+ maxX: x + node.width / 2, maxY: y + newHalfH,
364
+ });
365
+ }
366
+ }
367
+ dragStartPosRef.current.delete(nodeId);
368
+ }, [updateNodePosition, nodes]);
369
+ // Keep a ref to the latest fitToScreen so async callbacks (onComplete) always
370
+ // call the most current version without re-creating the animation pipeline.
371
+ const fitToScreenRef = useRef<() => void>(() => {});
372
+
373
+ const fitToScreen = useCallback(() => {
374
+ if (flatNodes.length === 0) return;
375
+ if (isTransitioningRef.current) return; // animation is controlling viewport
376
+ // Use the container's measured size (not window) so the fit is always
377
+ // relative to the renderer's actual bounding box.
378
+ const cw = canvasSizeRef.current.width || containerRef.current?.getBoundingClientRect().width || 800;
379
+ const ch = canvasSizeRef.current.height || containerRef.current?.getBoundingClientRect().height || 600;
380
+ const { centerX, centerY } = calculateFitView(flatNodes, cw, ch);
381
+ // Root layer: always use 1.0 zoom to match visualli.ai reference behaviour.
382
+ // calculateFitZoom for a single node or dense layout on a 1920-default canvas
383
+ // produces zoom > 2 which triggers the auto-zoom-in threshold immediately.
384
+ const isRootLayer = navStack.length <= 1;
385
+ // Root layer: use 1.0 zoom to match visualli.ai reference behaviour.
386
+ // Child layers use the bounding-box fit zoom (already clamped to [0.4, 5]).
387
+ const zoomLevel = isRootLayer ? 1.0 : calculateFitView(flatNodes, cw, ch).zoomLevel;
388
+ setCenter(centerX, centerY);
389
+ setZoom(zoomLevel);
390
+ }, [flatNodes, navStack.length, setCenter, setZoom]);
391
+
392
+ // Keep ref in sync after every render so async callbacks always use latest closure
393
+ fitToScreenRef.current = fitToScreen;
394
+
395
+ useEffect(() => { fitToScreen(); }, [currentLayerId]); // eslint-disable-line react-hooks/exhaustive-deps
396
+
397
+ // Re-fit when the canvas is properly measured for the first time (the RAF in
398
+ // useKonvaRenderer updates canvasWidth from 0 → container width asynchronously).
399
+ const didFitAfterMeasureRef = useRef(false);
400
+ useEffect(() => {
401
+ if (!canvasWidth || didFitAfterMeasureRef.current) return;
402
+ didFitAfterMeasureRef.current = true;
403
+ fitToScreen();
404
+ }, [canvasWidth, fitToScreen]);
405
+
406
+ // ── Layer transitions ─────────────────────────────────────────────────────
407
+
408
+ // Stores the target nav-back index so onSwapBack can read it
409
+ const navBackTargetRef = useRef<number>(-1);
410
+
411
+ const { zoomIntoLayer, zoomOutToParent, isTransitioning: isAnimating } = useKonvaLayerTransition({
412
+ stageRef,
413
+ canvasWrapperRef,
414
+ onSwapLayer: (childLayerId) => {
415
+ const childLayer = doc?.layers.get(childLayerId);
416
+ if (!childLayer || !doc) return;
417
+ setCurrentLayerId(childLayerId);
418
+ setNavStack(prev => [...prev, { layerId: childLayerId, layer: childLayer, label: layerLabel(doc, childLayer, childLayerId) }]);
419
+ onLayerChange?.(childLayerId, childLayer);
420
+ },
421
+ onSwapBack: () => {
422
+ const targetIndex = navBackTargetRef.current;
423
+ setNavStack(prev => {
424
+ const newStack = prev.slice(0, targetIndex + 1);
425
+ parentViewports.current = parentViewports.current.slice(0, targetIndex);
426
+ const newLayerId = newStack[newStack.length - 1].layerId;
427
+ setCurrentLayerId(newLayerId);
428
+ onLayerChange?.(newLayerId, newStack[newStack.length - 1].layer);
429
+ return newStack;
430
+ });
431
+ },
432
+ onComplete: () => {
433
+ setIsTransitioning(false);
434
+ isTransitioningRef.current = false;
435
+ // After every layer switch, snap to the fit view so zoom is always clean.
436
+ // rAF ensures React has committed new node data before we compute the fit.
437
+ requestAnimationFrame(() => { fitToScreenRef.current(); });
438
+ },
439
+ // GPU CSS-transform callbacks (matching visualli.ai exactly):
440
+ onViewportFrame: (vp) => {
441
+ lastAnimViewportRef.current = vp;
442
+ const baseline = baselineTransformRef.current;
443
+ const contentDiv = konvaContentDivRef.current;
444
+ const { width, height } = canvasSizeRef.current;
445
+ if (baseline && contentDiv) {
446
+ const targetX = width / 2 - vp.centerX * vp.zoomLevel;
447
+ const targetY = height / 2 - vp.centerY * vp.zoomLevel;
448
+ const ratio = vp.zoomLevel / baseline.scaleX;
449
+ const tx = targetX - baseline.x * ratio;
450
+ const ty = targetY - baseline.y * ratio;
451
+ contentDiv.style.transform = `translate(${tx}px, ${ty}px) scale(${ratio})`;
452
+ } else {
453
+ const stage = stageRef.current;
454
+ if (stage) {
455
+ stage.x(width / 2 - vp.centerX * vp.zoomLevel);
456
+ stage.y(height / 2 - vp.centerY * vp.zoomLevel);
457
+ stage.scaleX(vp.zoomLevel); stage.scaleY(vp.zoomLevel);
458
+ stage.batchDraw();
459
+ }
460
+ }
461
+ },
462
+ onViewportFinal: (vp) => {
463
+ if (konvaContentDivRef.current) konvaContentDivRef.current.style.transform = '';
464
+ const stage = stageRef.current;
465
+ if (stage) {
466
+ const { width, height } = canvasSizeRef.current;
467
+ stage.x(width / 2 - vp.centerX * vp.zoomLevel);
468
+ stage.y(height / 2 - vp.centerY * vp.zoomLevel);
469
+ stage.scaleX(vp.zoomLevel); stage.scaleY(vp.zoomLevel);
470
+ stage.getLayers().forEach(l => l.drawScene());
471
+ }
472
+ setCenter(vp.centerX, vp.centerY);
473
+ setZoom(vp.zoomLevel);
474
+ },
475
+ onPhaseChange: (phase) => {
476
+ const stage = stageRef.current;
477
+ const contentDiv = konvaContentDivRef.current;
478
+ if (stage && contentDiv && (phase === 'swap' || phase === 'zoom-back')) {
479
+ contentDiv.style.transform = '';
480
+ const vp = phase === 'zoom-back' ? zoomOutTargetRef.current : lastAnimViewportRef.current;
481
+ if (vp) {
482
+ const { width, height } = canvasSizeRef.current;
483
+ const nx = width / 2 - vp.centerX * vp.zoomLevel;
484
+ const ny = height / 2 - vp.centerY * vp.zoomLevel;
485
+ stage.x(nx); stage.y(ny);
486
+ stage.scaleX(vp.zoomLevel); stage.scaleY(vp.zoomLevel);
487
+ stage.getLayers().forEach(l => l.drawScene());
488
+ baselineTransformRef.current = { x: nx, y: ny, scaleX: vp.zoomLevel, scaleY: vp.zoomLevel };
489
+ }
490
+ }
491
+ },
492
+ onTransitionLifecycle: (phase) => {
493
+ const stage = stageRef.current;
494
+ if (phase === 'start') {
495
+ if (canvasWrapperRef.current) canvasWrapperRef.current.style.willChange = 'opacity, transform';
496
+ if (stage) {
497
+ baselineTransformRef.current = { x: stage.x(), y: stage.y(), scaleX: stage.scaleX(), scaleY: stage.scaleY() };
498
+ const container = stage.container();
499
+ const cd = container?.querySelector('.konvajs-content') as HTMLDivElement | null;
500
+ if (cd) {
501
+ konvaContentDivRef.current = cd;
502
+ cd.style.transformOrigin = '0 0';
503
+ cd.style.willChange = 'transform';
504
+ }
505
+ stage.getLayers().forEach(l => { l.listening(false); });
506
+ }
507
+ } else {
508
+ if (canvasWrapperRef.current) canvasWrapperRef.current.style.willChange = 'auto';
509
+ if (konvaContentDivRef.current) { konvaContentDivRef.current.style.willChange = 'auto'; konvaContentDivRef.current = null; }
510
+ baselineTransformRef.current = null;
511
+ zoomOutTargetRef.current = null;
512
+ if (stage) {
513
+ stage.getLayers().forEach(l => { l.listening(true); });
514
+ stage.batchDraw();
515
+ }
516
+ }
517
+ },
518
+ });
519
+
520
+ const handleNavigate = useCallback((nodeId: string) => {
521
+ if (!doc || !currentLayerId) {
522
+ return;
523
+ }
524
+ if (isTransitioningRef.current || isAnimating()) {
525
+ return;
526
+ }
527
+
528
+ const childLayer = getChildLayerForNode(doc, nodeId, currentLayerId);
529
+ if (!childLayer) {
530
+ return;
531
+ }
532
+ const childLayerId = [...doc.layers.entries()].find(([, l]) => l === childLayer)?.[0];
533
+ if (!childLayerId) {
534
+ return;
535
+ }
536
+ const childNodes = (() => { try { return getNodesForLayer(doc, childLayerId); } catch { return []; } })();
537
+ if (childNodes.length === 0) {
538
+ return;
539
+ }
540
+
541
+ const node = nodes.get(nodeId);
542
+ if (!node) return;
543
+
544
+ // Save the FIT viewport for this layer (not the current scroll position).
545
+ // This ensures navigating back always returns to a clean, properly-zoomed view
546
+ // rather than the arbitrary zoom that happened to trigger the navigation.
547
+ const cw = canvasSizeRef.current.width || 800;
548
+ const ch = canvasSizeRef.current.height || 600;
549
+ const fitVp = calculateFitView(flatNodes, cw, ch);
550
+ const isRootNow = navStack.length <= 1;
551
+ parentViewports.current.push({ centerX: fitVp.centerX, centerY: fitVp.centerY, zoomLevel: isRootNow ? 0.85 : fitVp.zoomLevel });
552
+
553
+ setIsTransitioning(true);
554
+ isTransitioningRef.current = true;
555
+
556
+ zoomIntoLayer(
557
+ doc, node, childLayerId, childNodes,
558
+ node.color ?? getThemeBackground(isDark, 'secondary'),
559
+ node.color ?? getThemeBackground(isDark, 'primary'),
560
+ );
561
+ }, [doc, currentLayerId, nodes, isDark, isAnimating, zoomIntoLayer]);
562
+
563
+ const handleNavigateBack = useCallback((targetIndex: number) => {
564
+ if (targetIndex >= navStack.length - 1) return;
565
+ if (isTransitioningRef.current || isAnimating()) return;
566
+ const savedVp = parentViewports.current[targetIndex];
567
+ if (!savedVp) return;
568
+ navBackTargetRef.current = targetIndex;
569
+ setIsTransitioning(true);
570
+ isTransitioningRef.current = true;
571
+ zoomOutToParent(savedVp, getThemeBackground(isDark, 'primary'), getThemeBackground(isDark, 'secondary'));
572
+ }, [navStack.length, isDark, isAnimating, zoomOutToParent]);
573
+
574
+ // ── Auto-zoom navigation (Google Maps style) ──────────────────────────────
575
+ const baseZoomRef = useRef(1.0);
576
+ const lastZoomTransitionRef = useRef(0);
577
+ const ZOOM_COOLDOWN = 1000;
578
+
579
+ useEffect(() => { baseZoomRef.current = 1.0; }, [currentLayerId]);
580
+
581
+ useEffect(() => {
582
+ if (isTransitioningRef.current || isAnimating()) return;
583
+ const now = Date.now();
584
+ if (now - lastZoomTransitionRef.current < ZOOM_COOLDOWN) return;
585
+ const relative = viewport.zoomLevel / baseZoomRef.current;
586
+
587
+ if (relative >= ZOOM_NAV_IN_THRESHOLD) {
588
+ // Zoom in threshold — drill into closest node
589
+ let closest: FlatNode | null = null;
590
+ let minDist = Infinity;
591
+ for (const node of nodes.values()) {
592
+ const dx = node.x - viewport.centerX;
593
+ const dy = node.y - viewport.centerY;
594
+ const d = dx * dx + dy * dy;
595
+ if (d < minDist) { minDist = d; closest = node; }
596
+ }
597
+ if (closest) {
598
+ const childLayer = doc ? getChildLayerForNode(doc, closest.id, currentLayerId ?? '') : null;
599
+ if (childLayer) {
600
+ lastZoomTransitionRef.current = now;
601
+ handleNavigate(closest.id);
602
+ baseZoomRef.current = 1.0;
603
+ }
604
+ }
605
+ } else if (relative < ZOOM_NAV_OUT_THRESHOLD && navStack.length > 1) {
606
+ // Zoom out threshold — go back to parent
607
+ lastZoomTransitionRef.current = now;
608
+ handleNavigateBack(navStack.length - 2);
609
+ baseZoomRef.current = 1.0;
610
+ }
611
+ }, [viewport.zoomLevel, viewport.centerX, viewport.centerY, nodes, doc, currentLayerId, navStack, isAnimating, handleNavigate, handleNavigateBack]); // eslint-disable-line react-hooks/exhaustive-deps
612
+
613
+ // ── Stage-level hit detection ─────────────────────────────────────────────
614
+ const toWorldCoords = useCallback((stage: Konva.Stage, pointer: { x: number; y: number }) => {
615
+ const sp = stage.position();
616
+ const sc = stage.scaleX();
617
+ return { worldX: (pointer.x - sp.x) / sc, worldY: (pointer.y - sp.y) / sc };
618
+ }, []);
619
+
620
+ const hitTestNode = useCallback((worldX: number, worldY: number): string | null => {
621
+ if (!spatialIndexRef.current) return null;
622
+ const hits = spatialIndexRef.current.query({ minX: worldX, minY: worldY, maxX: worldX, maxY: worldY });
623
+ if (!hits.length) return null;
624
+ let targetId: string | null = null, minDist = Infinity;
625
+ for (const nodeId of hits) {
626
+ const node = nodes.get(nodeId);
627
+ if (!node) continue;
628
+ const d = (worldX - node.x) ** 2 + (worldY - node.y) ** 2;
629
+ if (d < minDist) { minDist = d; targetId = nodeId; }
630
+ }
631
+ return targetId;
632
+ }, [nodes]);
633
+
634
+ // Stage pointer interaction state
635
+ const stageActiveNodeIdRef = useRef<string | null>(null);
636
+ const stageDragNodeOffsetRef = useRef({ x: 0, y: 0 });
637
+ const stageMouseDownPosRef = useRef<{ x: number; y: number } | null>(null);
638
+ const stageDragCommittedRef = useRef(false);
639
+ const stageLastPointerCheckRef = useRef<number>(0);
640
+ const pointerNodeIdRef = useRef<string | null>(null);
641
+ const isCanvasPanningRef = useRef(false);
642
+ const isDraggingRef = useRef(false);
643
+
644
+ const [pointerNodeId, setPointerNodeId] = useState<string | null>(null);
645
+ const [pressedNodeId, setPressedNodeId] = useState<string | null>(null);
646
+ const [isDraggingState, setIsDraggingState] = useState(false);
647
+
648
+ // ── Node hover tooltip ────────────────────────────────────────────────────
649
+ const [hoveredNode, setHoveredNode] = useState<{
650
+ nodeId: string; summary: string; screenX: number; screenY: number;
651
+ } | null>(null);
652
+
653
+ const tooltipHoverRef = useRef(false);
654
+ const keepTooltipOpenRef = useRef(false); // External request to keep tooltip open (e.g., from extensions)
655
+ const clearTooltipTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
656
+
657
+ const hoveredNodePosition = useMemo(() => {
658
+ if (!hoveredNode) return null;
659
+ const node = nodes.get(hoveredNode.nodeId);
660
+ if (!node) return null;
661
+ const cw = viewport.canvasWidth || canvasSizeRef.current.width;
662
+ const ch = viewport.canvasHeight || canvasSizeRef.current.height;
663
+ return {
664
+ screenX: (node.x - viewport.centerX) * viewport.zoomLevel + cw / 2,
665
+ screenY: (node.y - viewport.centerY) * viewport.zoomLevel + ch / 2,
666
+ zoom: viewport.zoomLevel,
667
+ };
668
+ }, [hoveredNode, nodes, viewport]);
669
+
670
+ // ── Context menu ──────────────────────────────────────────────────────────
671
+ const [canvasContextMenu, setCanvasContextMenu] = useState<{ x: number; y: number } | null>(null);
672
+
673
+ useEffect(() => {
674
+ if (!canvasContextMenu) return;
675
+ const close = () => setCanvasContextMenu(null);
676
+ const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') close(); };
677
+ window.addEventListener('pointerdown', close);
678
+ window.addEventListener('keydown', onKey);
679
+ return () => { window.removeEventListener('pointerdown', close); window.removeEventListener('keydown', onKey); };
680
+ }, [canvasContextMenu]);
681
+
682
+ // ── Stage event handlers ──────────────────────────────────────────────────
683
+ const handleStageMouseDown = useCallback((e: Konva.KonvaEventObject<MouseEvent>) => {
684
+ if (e.evt.button !== 0) { rendererHandleMouseDown(e); return; }
685
+ const stage = e.target.getStage();
686
+ if (!stage) { rendererHandleMouseDown(e); return; }
687
+ const pointer = stage.getPointerPosition();
688
+ if (!pointer) { rendererHandleMouseDown(e); return; }
689
+ const { worldX, worldY } = toWorldCoords(stage, pointer);
690
+ const nodeId = hitTestNode(worldX, worldY);
691
+ if (nodeId) {
692
+ const node = nodes.get(nodeId);
693
+ if (node) {
694
+ select(nodeId);
695
+ stageActiveNodeIdRef.current = nodeId;
696
+ stageDragNodeOffsetRef.current = { x: worldX - node.x, y: worldY - node.y };
697
+ stageMouseDownPosRef.current = { x: e.evt.clientX, y: e.evt.clientY };
698
+ stageDragCommittedRef.current = false;
699
+ setPressedNodeId(nodeId);
700
+ setHoveredNode(null);
701
+ return;
702
+ }
703
+ }
704
+ setHoveredNode(null);
705
+ isCanvasPanningRef.current = true;
706
+ rendererHandleMouseDown(e);
707
+ }, [hitTestNode, toWorldCoords, nodes, select, rendererHandleMouseDown]);
708
+
709
+ const handleStageMouseMove = useCallback((e: Konva.KonvaEventObject<MouseEvent>) => {
710
+ const stage = e.target.getStage();
711
+ if (!stage) { rendererHandleMouseMove(e); return; }
712
+ const pointer = stage.getPointerPosition();
713
+ if (!pointer) { rendererHandleMouseMove(e); return; }
714
+ const { worldX, worldY } = toWorldCoords(stage, pointer);
715
+
716
+ if (stageActiveNodeIdRef.current && stageMouseDownPosRef.current) {
717
+ const moved = Math.hypot(e.evt.clientX - stageMouseDownPosRef.current.x, e.evt.clientY - stageMouseDownPosRef.current.y);
718
+ if (!stageDragCommittedRef.current && moved >= 5) {
719
+ stageDragCommittedRef.current = true;
720
+ isDraggingRef.current = true;
721
+ setIsDraggingState(true);
722
+ pointerNodeIdRef.current = null;
723
+ setPointerNodeId(null);
724
+ handleDragStart(stageActiveNodeIdRef.current);
725
+ }
726
+ if (stageDragCommittedRef.current) {
727
+ const offset = stageDragNodeOffsetRef.current;
728
+ handleDragMove(stageActiveNodeIdRef.current, worldX - offset.x, worldY - offset.y);
729
+ stage.container().style.cursor = 'grabbing';
730
+ return;
731
+ }
732
+ return;
733
+ }
734
+
735
+ // During canvas pan — just apply the pan, skip hover detection to avoid re-renders
736
+ if (isCanvasPanningRef.current) {
737
+ rendererHandleMouseMove(e);
738
+ return;
739
+ }
740
+
741
+ // Throttled hover detection (only when not panning or dragging)
742
+ const now = performance.now();
743
+ if (now - stageLastPointerCheckRef.current >= 50) {
744
+ stageLastPointerCheckRef.current = now;
745
+ const hovered = hitTestNode(worldX, worldY);
746
+ if (hovered !== pointerNodeIdRef.current) {
747
+ pointerNodeIdRef.current = hovered;
748
+ setPointerNodeId(hovered);
749
+ }
750
+ if (hovered) {
751
+ stage.container().style.cursor = 'pointer';
752
+ } else {
753
+ stage.container().style.cursor = 'grab';
754
+ }
755
+ }
756
+
757
+ rendererHandleMouseMove(e);
758
+ }, [toWorldCoords, hitTestNode, handleDragStart, handleDragMove, rendererHandleMouseMove]);
759
+
760
+ const handleStageMouseUp = useCallback((e: Konva.KonvaEventObject<MouseEvent>) => {
761
+ setPressedNodeId(null);
762
+ if (stageActiveNodeIdRef.current) {
763
+ const nodeId = stageActiveNodeIdRef.current;
764
+ if (stageDragCommittedRef.current) {
765
+ // End drag — flush final world position
766
+ const stage = e.target.getStage();
767
+ if (stage) {
768
+ const pointer = stage.getPointerPosition();
769
+ if (pointer) {
770
+ const { worldX, worldY } = toWorldCoords(stage, pointer);
771
+ const offset = stageDragNodeOffsetRef.current;
772
+ handleDragEnd(nodeId, worldX - offset.x, worldY - offset.y);
773
+ }
774
+ }
775
+ } else {
776
+ // Click — check if node has child layer (regardless of branchCount)
777
+ const node = nodes.get(nodeId);
778
+ if (node) {
779
+ const childLayer = doc ? getChildLayerForNode(doc, nodeId, currentLayerId ?? '') : null;
780
+ if (childLayer) {
781
+ handleNavigate(nodeId);
782
+ }
783
+ onNodeClick?.(node);
784
+ }
785
+ }
786
+ isDraggingRef.current = false;
787
+ setIsDraggingState(false);
788
+ stageActiveNodeIdRef.current = null;
789
+ stageMouseDownPosRef.current = null;
790
+ stageDragCommittedRef.current = false;
791
+ return;
792
+ }
793
+ isCanvasPanningRef.current = false;
794
+ rendererHandleMouseUp(e);
795
+ }, [toWorldCoords, handleDragEnd, nodes, handleNavigate, onNodeClick, rendererHandleMouseUp]);
796
+
797
+ const handleStageContextMenu = useCallback((e: Konva.KonvaEventObject<MouseEvent>) => {
798
+ e.evt.preventDefault();
799
+ if (navStack.length <= 1) { setCanvasContextMenu(null); return; }
800
+ setCanvasContextMenu({ x: e.evt.clientX, y: e.evt.clientY });
801
+ }, [navStack.length]);
802
+
803
+ // Touch passthrough
804
+ const handleStageTouchStart = useCallback((e: Konva.KonvaEventObject<TouchEvent>) =>
805
+ handleStageMouseDown(e as unknown as Konva.KonvaEventObject<MouseEvent>), [handleStageMouseDown]);
806
+ const handleStageTouchMove = useCallback((e: Konva.KonvaEventObject<TouchEvent>) =>
807
+ handleStageMouseMove(e as unknown as Konva.KonvaEventObject<MouseEvent>), [handleStageMouseMove]);
808
+ const handleStageTouchEnd = useCallback((e: Konva.KonvaEventObject<TouchEvent>) =>
809
+ handleStageMouseUp(e as unknown as Konva.KonvaEventObject<MouseEvent>), [handleStageMouseUp]);
810
+
811
+ // Wheel with auto-nav guard on non-root layers
812
+ const handleWheel = useCallback((e: Konva.KonvaEventObject<WheelEvent>) => {
813
+ const isZoomOut = e.evt.deltaY > 0;
814
+ if (!isZoomOut) { rendererHandleWheel(e); return; }
815
+
816
+ const liveZoom = useViewportStore.getState().zoomLevel;
817
+ const projected = Math.max(ZOOM_MIN, liveZoom * (1 - e.evt.deltaY / 1000));
818
+
819
+ // Root layer: no parent to navigate to — let the user zoom freely down to ZOOM_MIN
820
+ // (do NOT clamp at ZOOM_NAV_OUT_THRESHOLD; content may require lower zoom to fit)
821
+
822
+ if (navStack.length > 1 && projected <= ZOOM_NAV_OUT_THRESHOLD && !isTransitioningRef.current && !isAnimating()) {
823
+ e.evt.preventDefault();
824
+ const now = Date.now();
825
+ if (now - lastZoomTransitionRef.current >= ZOOM_COOLDOWN) {
826
+ lastZoomTransitionRef.current = now;
827
+ handleNavigateBack(navStack.length - 2);
828
+ baseZoomRef.current = 1.0;
829
+ }
830
+ return;
831
+ }
832
+ rendererHandleWheel(e);
833
+ }, [rendererHandleWheel, navStack, setZoom, isAnimating, handleNavigateBack]);
834
+
835
+ // Canvas mouse move for description tooltip
836
+ const lastHoverCheckRef = useRef(0);
837
+ const handleCanvasMouseMove = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
838
+ if (!containerRef.current || isDraggingRef.current || isCanvasPanningRef.current) {
839
+ setHoveredNode(null);
840
+ return;
841
+ }
842
+ const now = Date.now();
843
+ if (now - lastHoverCheckRef.current < 50) return;
844
+ lastHoverCheckRef.current = now;
845
+
846
+ // Check if mouse is over a tooltip - if so, don't change hovered node
847
+ const target = e.target as HTMLElement;
848
+ const isOverTooltip = target.closest('[data-node-tooltip="true"]') || target.closest('[data-semantic-tooltip="true"]');
849
+
850
+ // If tooltip is open and we're hovering it or keeping it open, don't detect new nodes
851
+ if (tooltipHoverRef.current || keepTooltipOpenRef.current || isOverTooltip) {
852
+ return;
853
+ }
854
+
855
+ const cw = viewport.canvasWidth || canvasSizeRef.current.width;
856
+ const ch = viewport.canvasHeight || canvasSizeRef.current.height;
857
+ // e.clientX is viewport-relative; subtract the container's left/top offset
858
+ // so world-coordinate conversion is correct when the renderer isn't full-screen.
859
+ const rect = containerRef.current?.getBoundingClientRect();
860
+ const localX = rect ? e.clientX - rect.left : e.clientX;
861
+ const localY = rect ? e.clientY - rect.top : e.clientY;
862
+ const worldX = (localX - cw / 2) / viewport.zoomLevel + viewport.centerX;
863
+ const worldY = (localY - ch / 2) / viewport.zoomLevel + viewport.centerY;
864
+
865
+ // Use RBush spatial index for O(log n) hit detection
866
+ let found = false;
867
+ if (spatialIndexRef.current) {
868
+ const hits = spatialIndexRef.current.query({ minX: worldX, minY: worldY, maxX: worldX, maxY: worldY });
869
+ for (const nodeId of hits) {
870
+ const node = nodes.get(nodeId);
871
+ if (node?.description) {
872
+ setHoveredNode({ nodeId: node.id, summary: node.description, screenX: e.clientX, screenY: e.clientY });
873
+ found = true;
874
+ break;
875
+ }
876
+ }
877
+ }
878
+ if (!found) {
879
+ if (clearTooltipTimerRef.current) clearTimeout(clearTooltipTimerRef.current);
880
+ clearTooltipTimerRef.current = setTimeout(() => {
881
+ if (!tooltipHoverRef.current && !keepTooltipOpenRef.current) setHoveredNode(null);
882
+ }, 150);
883
+ } else {
884
+ if (clearTooltipTimerRef.current) { clearTimeout(clearTooltipTimerRef.current); clearTooltipTimerRef.current = null; }
885
+ }
886
+ }, [nodes, viewport, spatialIndexRef]);
887
+
888
+ // Listen for external requests to keep tooltip open (e.g., from extensions)
889
+ useEffect(() => {
890
+ const handleKeepTooltipOpen = (event: CustomEvent) => {
891
+ keepTooltipOpenRef.current = event.detail.keep;
892
+ };
893
+
894
+ // @ts-ignore - custom event
895
+ window.addEventListener('keepNodeTooltipOpen', handleKeepTooltipOpen);
896
+
897
+ return () => {
898
+ // @ts-ignore - custom event
899
+ window.removeEventListener('keepNodeTooltipOpen', handleKeepTooltipOpen);
900
+ };
901
+ }, []);
902
+
903
+ // Cleanup on unmount
904
+ useEffect(() => {
905
+ return () => {
906
+ if (clearTooltipTimerRef.current) clearTimeout(clearTooltipTimerRef.current);
907
+ spatialIndexRef.current?.clear();
908
+ if (dragMoveRafRef.current !== null) cancelAnimationFrame(dragMoveRafRef.current);
909
+ };
910
+ }, []);
911
+
912
+ // ── Zoom controls ─────────────────────────────────────────────────────────
913
+ const handleZoomIn = useCallback(() => setZoom(Math.min(viewport.zoomLevel * 1.2, 5)), [viewport.zoomLevel, setZoom]);
914
+ const handleZoomOut = useCallback(() => {
915
+ // Non-root: allow zoom to ZOOM_MIN so the wheel-driven ZOOM_NAV_OUT_THRESHOLD
916
+ // transition can still trigger via scroll; button just navigates zoom value.
917
+ // Root: also ZOOM_MIN (no parent layer to navigate to, allow full zoom-out).
918
+ setZoom(Math.max(viewport.zoomLevel / 1.2, ZOOM_MIN));
919
+ }, [viewport.zoomLevel, setZoom]);
920
+
921
+ // Extension rendering removed - not part of open spec
922
+
923
+ // ── Render ────────────────────────────────────────────────────────────────
924
+ if (!doc) {
925
+ return (
926
+ <div className={className} style={{ position: 'relative', display: 'flex', alignItems: 'center', justifyContent: 'center', background: bgColor, ...style }}>
927
+ <span style={{ color: isDark ? 'rgba(255,255,255,0.4)' : 'rgba(0,0,0,0.3)', fontSize: 14 }}>No document</span>
928
+ </div>
929
+ );
930
+ }
931
+
932
+ const hasBackOption = navStack.length > 1;
933
+
934
+ return (
935
+ <div
936
+ ref={containerRef}
937
+ className={className}
938
+ style={{
939
+ position: 'relative',
940
+ overflow: 'hidden',
941
+ userSelect: 'none',
942
+ background: bgColor,
943
+ width: '100%',
944
+ height: '100%',
945
+ ...style
946
+ }}
947
+ onMouseMove={handleCanvasMouseMove}
948
+ onMouseLeave={() => {
949
+ // Only close tooltip on mouse leave if not keeping it open
950
+ if (!tooltipHoverRef.current && !keepTooltipOpenRef.current) {
951
+ setHoveredNode(null);
952
+ }
953
+ }}
954
+ >
955
+ {/* Konva canvas — opacity driven imperatively during transitions */}
956
+ <div ref={canvasWrapperRef} style={{ position: 'relative', zIndex: 1, pointerEvents: 'auto' }}>
957
+ <KonvaStage
958
+ ref={stageRef}
959
+ onWheel={handleWheel}
960
+ onMouseDown={handleStageMouseDown}
961
+ onMouseMove={handleStageMouseMove}
962
+ onMouseUp={handleStageMouseUp}
963
+ onContextMenu={handleStageContextMenu}
964
+ onTouchStart={handleStageTouchStart}
965
+ onTouchMove={handleStageTouchMove}
966
+ onTouchEnd={handleStageTouchEnd}
967
+ >
968
+ <KonvaContainerLayer nodes={flatNodes} containers={containers} isDark={isDark} />
969
+ <KonvaEdgeLayer
970
+ nodes={flatNodes}
971
+ connections={connections}
972
+ isDark={isDark}
973
+ isDragging={isDraggingState}
974
+ />
975
+ <KonvaNodeLayer
976
+ isTransitioning={isTransitioning}
977
+ isDragging={isDraggingState}
978
+ hoveredNodeId={pointerNodeId}
979
+ pressedNodeId={pressedNodeId}
980
+ />
981
+ </KonvaStage>
982
+ </div>
983
+
984
+ {/* Context menu (right-click to go back) */}
985
+ {canvasContextMenu && hasBackOption && (
986
+ <div
987
+ role="menu"
988
+ aria-label="Canvas options"
989
+ style={{
990
+ position: 'fixed',
991
+ zIndex: 60,
992
+ pointerEvents: 'auto',
993
+ left: `${Math.min(canvasContextMenu.x, Math.max(8, (canvasSizeRef.current.width || 800) - 152))}px`,
994
+ top: `${Math.min(canvasContextMenu.y, Math.max(8, (canvasSizeRef.current.height || 600) - 72))}px`,
995
+ }}
996
+ onContextMenu={e => e.preventDefault()}
997
+ onPointerDown={e => e.stopPropagation()}
998
+ >
999
+ <button
1000
+ type="button"
1001
+ style={{
1002
+ padding: '6px 16px',
1003
+ borderRadius: '10px',
1004
+ border: `1px solid ${isDark ? '#333330' : '#DDD9D0'}`,
1005
+ background: isDark ? '#1C1C1A' : '#FAF8F4',
1006
+ color: isDark ? '#F0EDE6' : '#1A1A18',
1007
+ cursor: 'pointer',
1008
+ fontSize: 13,
1009
+ boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
1010
+ }}
1011
+ onClick={() => { setCanvasContextMenu(null); handleNavigateBack(navStack.length - 2); }}
1012
+ >
1013
+ Back
1014
+ </button>
1015
+ </div>
1016
+ )}
1017
+
1018
+ {/* Navigation stack (left, matches reference) */}
1019
+ <NavigationStack stack={navStack} onNavigateBack={handleNavigateBack} isDark={isDark} top={navigationStackTop} left={navigationStackLeft} />
1020
+
1021
+ {/* Zoom controls (top-right) */}
1022
+ <div data-help="zoom-controls" style={{ position: 'absolute', top: '24px', right: '16px', zIndex: 50, pointerEvents: 'auto' }}>
1023
+ <ZoomControls isDark={isDark} />
1024
+ </div>
1025
+
1026
+ {/* Custom overlay from consuming app (for private features like help button, chromatic bg, etc.) */}
1027
+ {renderOverlay?.({ isDark, containerWidth: canvasSizeRef.current.width || 0, containerHeight: canvasSizeRef.current.height || 0 })}
1028
+
1029
+ {/* Node description tooltip (sketchy-box, above node) - rendered before extensions so extensions appear on top */}
1030
+ {hoveredNode && hoveredNodePosition && viewport.zoomLevel < 3 && viewport.zoomLevel >= TEXT_LABEL_HIDE_BELOW_ZOOM && (() => {
1031
+ const nodeData = nodes.get(hoveredNode.nodeId);
1032
+ const borderColor = nodeData?.color ? darkenHexColor(nodeData.color, 0.13) : '#5BA8D4';
1033
+ const VP_PAD = 8;
1034
+ const HALF_W = 160;
1035
+ const APPROX_H = 90;
1036
+ const containerW = canvasSizeRef.current.width || 800;
1037
+ const safeLeft = Math.max(HALF_W + VP_PAD, Math.min(hoveredNodePosition.screenX, containerW - HALF_W - VP_PAD));
1038
+ const rawTop = hoveredNodePosition.screenY - 60 * hoveredNodePosition.zoom;
1039
+ const safeTop = Math.max(VP_PAD + APPROX_H, rawTop);
1040
+ return (
1041
+ <div
1042
+ data-node-tooltip="true"
1043
+ style={{
1044
+ position: 'fixed',
1045
+ zIndex: 50,
1046
+ left: safeLeft,
1047
+ top: safeTop,
1048
+ transform: 'translate(-50%, -100%)',
1049
+ transformOrigin: 'bottom center',
1050
+ pointerEvents: 'auto',
1051
+ }}
1052
+ onMouseEnter={() => { tooltipHoverRef.current = true; if (clearTooltipTimerRef.current) { clearTimeout(clearTooltipTimerRef.current); clearTooltipTimerRef.current = null; } }}
1053
+ onMouseLeave={() => {
1054
+ tooltipHoverRef.current = false;
1055
+ // Delay clearing to allow external extensions to request keeping tooltip open
1056
+ if (clearTooltipTimerRef.current) clearTimeout(clearTooltipTimerRef.current);
1057
+ clearTooltipTimerRef.current = setTimeout(() => {
1058
+ if (!keepTooltipOpenRef.current) setHoveredNode(null);
1059
+ }, 100);
1060
+ }}
1061
+ >
1062
+ <div style={{ maxWidth: '20rem', minWidth: '12rem', maxHeight: '15rem', overflowY: 'auto' }}>
1063
+ <SketchyBoxKonva fill="#ffffff" stroke={borderColor} backStroke={borderColor} padding="1rem 1.25rem">
1064
+ <p style={{
1065
+ fontFamily: "'Playpen Sans', cursive",
1066
+ fontSize: `${DESCRIPTION_TEXT_BASE_FONT_PX}px`,
1067
+ fontWeight: 300,
1068
+ color: '#000000',
1069
+ textAlign: 'center',
1070
+ lineHeight: 1.6,
1071
+ margin: 0,
1072
+ }}>
1073
+ {renderNodeContent ? renderNodeContent({
1074
+ summary: hoveredNode.summary,
1075
+ nodeId: hoveredNode.nodeId,
1076
+ nodeColor: nodeData?.color,
1077
+ zoom: viewport.zoomLevel,
1078
+ }) : hoveredNode.summary}
1079
+ </p>
1080
+ </SketchyBoxKonva>
1081
+ </div>
1082
+ </div>
1083
+ );
1084
+ })()}
1085
+ </div>
1086
+ );
1087
+ }