@visualli/react 0.1.7 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/README.md +13 -5
  2. package/dist/index.cjs +1900 -2033
  3. package/dist/index.cjs.map +1 -1
  4. package/dist/index.d.cts +354 -97
  5. package/dist/index.d.ts +354 -97
  6. package/dist/index.js +2005 -2095
  7. package/dist/index.js.map +1 -1
  8. package/fonts/Caveat-Variable.ttf +0 -0
  9. package/licenses/visualli-design-system/LICENSE +9 -0
  10. package/licenses/visualli-design-system/NOTICE.md +6 -0
  11. package/package.json +9 -4
  12. package/src/VisualliCanvas.tsx +452 -409
  13. package/src/VisualliRenderer.tsx +90 -112
  14. package/src/components/A11yLayer.tsx +73 -0
  15. package/src/components/KonvaContainer.tsx +28 -237
  16. package/src/components/KonvaContainerLabelLayer.tsx +41 -0
  17. package/src/components/KonvaContainerLayer.tsx +34 -33
  18. package/src/components/KonvaEdge.tsx +25 -240
  19. package/src/components/KonvaEdgeLayer.tsx +69 -49
  20. package/src/components/KonvaNode.tsx +21 -156
  21. package/src/components/KonvaNodeLayer.tsx +102 -34
  22. package/src/components/KonvaStage.tsx +17 -3
  23. package/src/components/NavigationStack.tsx +16 -159
  24. package/src/components/Overlays.tsx +327 -0
  25. package/src/components/ZoomControls.tsx +3 -107
  26. package/src/components/useFrames.ts +30 -0
  27. package/src/context/VisualliContext.tsx +3 -3
  28. package/src/design/choreography.ts +157 -0
  29. package/src/design/containers.ts +150 -0
  30. package/src/design/design.ts +115 -0
  31. package/src/design/drawing.ts +303 -0
  32. package/src/design/measure.ts +34 -0
  33. package/src/design/runtime.ts +154 -0
  34. package/src/design/useDesign.ts +38 -0
  35. package/src/generated/specCss.ts +15 -0
  36. package/src/hooks/index.ts +0 -2
  37. package/src/hooks/useKonvaRenderer.ts +13 -18
  38. package/src/hooks/useLayerChoreography.ts +159 -0
  39. package/src/hooks/useViewportNodes.ts +0 -1
  40. package/src/index.ts +22 -22
  41. package/src/stores/stores.ts +2 -2
  42. package/src/stores/useViewportStore.ts +12 -2
  43. package/src/animations/index.ts +0 -2
  44. package/src/animations/konvaLayerTransition.ts +0 -223
  45. package/src/components/SketchyBoxKonva.tsx +0 -121
  46. package/src/config/blobShapes.ts +0 -204
  47. package/src/config/index.ts +0 -21
  48. package/src/config/textScaling.ts +0 -69
  49. package/src/hooks/useKonvaLayerTransition.ts +0 -221
@@ -1,36 +1,37 @@
1
1
  // ─── VisualliCanvas ───────────────────────────────────────────────────────────
2
2
  //
3
- // Main canvas component for rendering a VisualliDocument.
4
- // Matches the visualli.ai enhanced canvas implementation:
3
+ // Main canvas component for rendering a VisualliDocument, drawn with the Visualli
4
+ // design system (tokens, fonts and geometry from design-system/):
5
+ // • Konva canvas for ideas, rings and connectors; DOM overlays for the peek,
6
+ // term cards, depth trail and controls (the design system's own CSS)
5
7
  // • 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)
8
+ // • 8 themes + comfort settings (readable type, larger text, reduced motion)
9
+ // • Step inside / back out by idea, peek, depth trail, Escape or Backspace — zoom and pinch never navigate
10
+ // • Layer transitions with the design system's motion (CSS-transform driven, no canvas redraws while animating)
11
+ // • A visually hidden DOM mirror of the visible ideas for screen readers / Tab
13
12
  //
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
13
+ // NOT included (read-only viewer): editing, generation, chat, sources, export.
19
14
 
20
- import React, { useRef, useState, useCallback, useEffect, useMemo, useLayoutEffect } from 'react';
15
+ import React, { useRef, useState, useCallback, useEffect, useMemo } from 'react';
21
16
  import type Konva from 'konva';
22
- import type { VisualliDocument, VisualliLayer, FlatNode, MindMapConnection } from '@visualli/core';
17
+ import type { VisualliDocument, VisualliLayer, FlatNode, MindMapConnection, Comfort, ThemeInput } from '@visualli/core';
23
18
  import {
24
19
  parseVisualliFile,
25
20
  getNodesForLayer,
26
- getThemeBackground,
27
- darkenHexColor,
21
+ getSemanticAnchors,
22
+ ideaColor,
23
+ motionVars,
24
+ nodeRadii,
25
+ ringsFor,
26
+ peekPosition,
27
+ TRAIL,
28
28
  RBushSpatialIndex,
29
- NODE_HEIGHT,
30
- TEXT_LABEL_HIDE_BELOW_ZOOM,
31
- ZOOM_NAV_IN_THRESHOLD,
32
- ZOOM_NAV_OUT_THRESHOLD,
33
- ZOOM_MIN,
29
+ GESTURE,
30
+ pinchZoom,
31
+ zoomAround,
32
+ VIEW,
33
+ PEEK,
34
+ SHEET,
34
35
  } from '@visualli/core';
35
36
 
36
37
  import { useNodeStore } from './stores/useNodeStore';
@@ -38,43 +39,25 @@ import { useViewportStore } from './stores/useViewportStore';
38
39
  import { useSelectionStore } from './stores/stores';
39
40
 
40
41
  import { useKonvaRenderer } from './hooks/useKonvaRenderer';
41
- import { useKonvaLayerTransition } from './hooks/useKonvaLayerTransition';
42
+ import { useLayerChoreography } from './hooks/useLayerChoreography';
43
+ import { RevealClock } from './design/choreography';
42
44
 
43
45
  import KonvaStage from './components/KonvaStage';
44
46
  import KonvaNodeLayer from './components/KonvaNodeLayer';
45
47
  import KonvaEdgeLayer from './components/KonvaEdgeLayer';
46
48
  import KonvaContainerLayer from './components/KonvaContainerLayer';
49
+ import KonvaContainerLabelLayer from './components/KonvaContainerLabelLayer';
47
50
  import NavigationStack, { type NavStackEntry } from './components/NavigationStack';
48
- import ZoomControls from './components/ZoomControls';
49
- import SketchyBoxKonva from './components/SketchyBoxKonva';
51
+ import { ZoomControls, PeekCard, PeekSheet } from './components/Overlays';
52
+ import { A11yLayer } from './components/A11yLayer';
50
53
 
51
54
  import { getChildLayerForNode, calculateFitView, getConnectionsForLayer, getContainersForLayer } from './utils/layerNavigation';
52
55
  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
- }
56
+ import type { AnimatorViewport } from './hooks/useLayerChoreography';
57
+ import { useDesign } from './design/useDesign';
58
+ import { ideaMeasure } from './design/measure';
59
+ import { paintVars, type IdeaPaint } from './components/Overlays';
60
+ import { ensureDesignSystemStyles, loadCanvasFonts, type DesignSystemAssets } from './design/runtime';
78
61
 
79
62
  // ── Helpers ───────────────────────────────────────────────────────────────────
80
63
 
@@ -103,6 +86,16 @@ function layerLabel(doc: VisualliDocument, layer: VisualliLayer, layerId: string
103
86
  return layer.description ?? layerId;
104
87
  }
105
88
 
89
+ /** Colour of the idea a layer opens from — the idea's own colour, including the sibling-order topic of an uncoloured
90
+ * idea (the design system's TRAIL); the root layer is the map itself, TRAIL.rootTopic. */
91
+ function layerPaint(doc: VisualliDocument, layer: VisualliLayer): IdeaPaint {
92
+ const parentLayer = layer.parentLayerId ? doc.layers.get(layer.parentLayerId) : null;
93
+ const index = parentLayer ? parentLayer.nodes.findIndex(n => n.id === layer.parentNodeId) : -1;
94
+ if (!parentLayer || index < 0) return { topic: TRAIL.rootTopic };
95
+ const { topic, custom } = ideaColor(parentLayer.nodes[index]!.data.color, index);
96
+ return topic ? { topic } : { custom: custom! };
97
+ }
98
+
106
99
  function getRootLayerId(doc: VisualliDocument): string | null {
107
100
  for (const [id, layer] of doc.layers) {
108
101
  if (!layer.parentLayerId) return id;
@@ -126,14 +119,51 @@ export interface VisualliCanvasProps {
126
119
  * Component handles all fetching and parsing automatically.
127
120
  */
128
121
  visualliFile?: File | string;
122
+ /**
123
+ * Theme: one of the 8 design-system themes ('light', 'dark', 'focus-light', …), a family
124
+ * ('focus' | 'colorsafe' | 'contrast') or 'auto' (follow the reader). Default 'light'.
125
+ */
126
+ theme?: ThemeInput;
127
+ /** @deprecated use `theme`. true -> 'dark', false -> 'light'. Kept for one release. */
129
128
  isDark?: boolean;
129
+ /** Comfort settings: readable type, larger text, reduced motion. */
130
+ comfort?: Comfort;
131
+ /**
132
+ * How a layer arrives. 'gradual' (default): ideas bloom in one by one, then connectors draw
133
+ * (design-system motion). 'instant': everything fades in together, as with reduced motion.
134
+ */
135
+ reveal?: 'gradual' | 'instant';
136
+ /** Switch to the high-contrast theme under forced-colors. Default true. */
137
+ respectForcedColors?: boolean;
138
+ /**
139
+ * Interaction layout. 'auto' (default) follows the reader's input: touch devices get the design
140
+ * system's bottom-sheet peek and larger controls, pointer devices the floating peek on hover.
141
+ * Force one with 'touch' or 'pointer'.
142
+ */
143
+ layout?: 'auto' | 'touch' | 'pointer';
144
+ /**
145
+ * The map is the page (an app such as Visualli's web app): it takes every touch gesture, so the browser never
146
+ * zooms or scrolls the page — the design system's `.vi-map.is-app` (`touch-action: none`). Leave it off when the
147
+ * map is embedded in a scrolling page: one finger then still scrolls the page, two fingers pinch the map.
148
+ */
149
+ app?: boolean;
150
+ /**
151
+ * Where the zoom / fit controls sit. Default 'top-right', the design system's placement (`.vi-map__ctrls`,
152
+ * opposite the depth trail). 'bottom-right' moves them to the bottom corner for hosts that need the top edge;
153
+ * there they rise above the touch peek sheet while it's open.
154
+ */
155
+ controlsPosition?: 'top-right' | 'bottom-right';
156
+ /** Where the bundled Caveat font is served from (directory URL). Defaults to the copy in the npm package, via jsDelivr. */
157
+ fontBaseUrl?: DesignSystemAssets['fontBaseUrl'];
158
+ /** Set false when you load Kalam and Atkinson Hyperlegible yourself. */
159
+ loadWebFonts?: DesignSystemAssets['loadWebFonts'];
130
160
  chromaticImmersion?: boolean;
131
161
  onNodeClick?: (node: FlatNode) => void;
132
162
  onLayerChange?: (layerId: string, layer: VisualliLayer) => void;
133
163
  onNodeHover?: (nodeId: string | null) => void;
134
164
 
135
165
  // Extension points for private features (implement in consuming app)
136
- renderOverlay?: (params: { isDark: boolean; containerWidth: number; containerHeight: number }) => React.ReactNode;
166
+ renderOverlay?: (params: { isDark: boolean; theme: string; containerWidth: number; containerHeight: number }) => React.ReactNode;
137
167
  renderNodeContent?: (params: {
138
168
  summary: string;
139
169
  nodeId: string;
@@ -150,7 +180,17 @@ export interface VisualliCanvasProps {
150
180
  // ── Component ─────────────────────────────────────────────────────────────────
151
181
 
152
182
  export default function VisualliCanvas(props: VisualliCanvasProps) {
153
- const { isDark = false, chromaticImmersion = false, onNodeClick, onLayerChange, onNodeHover, renderOverlay, renderNodeContent, navigationStackTop = '16px', navigationStackLeft = '16px', className = '', style } = props;
183
+ const { chromaticImmersion = false, onNodeClick, onLayerChange, onNodeHover, renderOverlay, renderNodeContent, navigationStackTop, navigationStackLeft, className = '', style } = props;
184
+ // The host's hover callback, read through a ref so the stage's pointer handlers stay stable when it changes.
185
+ const onNodeHoverRef = useRef(onNodeHover);
186
+ onNodeHoverRef.current = onNodeHover;
187
+
188
+ // Design system: resolve theme + comfort, inject its CSS once, and hold the first draw until its fonts are loaded.
189
+ const design = useDesign({ theme: props.theme ?? (props.isDark === undefined ? 'light' : undefined), isDark: props.isDark, comfort: props.comfort, respectForcedColors: props.respectForcedColors });
190
+ const isDark = design.isDark;
191
+ const [fontSheet] = useState(() => ensureDesignSystemStyles({ fontBaseUrl: props.fontBaseUrl, loadWebFonts: props.loadWebFonts }));
192
+ const [fontsReady, setFontsReady] = useState(false);
193
+ useEffect(() => { let live = true; loadCanvasFonts(fontSheet).then(() => { if (live) setFontsReady(true); }); return () => { live = false; }; }, [fontSheet]);
154
194
 
155
195
  // Read file if provided (handles both File objects and string paths)
156
196
  const [fileText, setFileText] = useState<string | undefined>(undefined);
@@ -190,6 +230,8 @@ export default function VisualliCanvas(props: VisualliCanvasProps) {
190
230
  const [navStack, setNavStack] = useState<NavStackEntry[]>([]);
191
231
  const [currentLayerId, setCurrentLayerId] = useState<string | null>(null);
192
232
  const parentViewports = useRef<AnimatorViewport[]>([]);
233
+ /** The idea being stepped into: the depth trail entry takes its colour and label from it, so they always match the idea that was clicked. */
234
+ const stepTargetRef = useRef<{ paint: IdeaPaint; label: string } | null>(null);
193
235
  const [isTransitioning, setIsTransitioning] = useState(false);
194
236
  const isTransitioningRef = useRef(false);
195
237
 
@@ -199,21 +241,25 @@ export default function VisualliCanvas(props: VisualliCanvasProps) {
199
241
  if (!rootId) return;
200
242
  const rootLayer = doc.layers.get(rootId)!;
201
243
  setCurrentLayerId(rootId);
202
- setNavStack([{ layerId: rootId, layer: rootLayer, label: 'Home' }]);
244
+ // The trail starts with the map's title (the design system's DepthTrail).
245
+ setNavStack([{ layerId: rootId, layer: rootLayer, label: doc.meta?.title || 'Home', paint: { topic: TRAIL.rootTopic } }]);
203
246
  parentViewports.current = [];
204
247
  }, [doc]);
205
248
 
206
249
  // ── Active layer → FlatNodes ──────────────────────────────────────────────
250
+ // Ideas are sized for their labels (the design system's idea.ts) with the real label face, so sizes are taken once
251
+ // the fonts are in, and again when the face or the label scale changes (readable type, larger text).
252
+ const sizing = useMemo(() => ({ measure: ideaMeasure(design), labelScale: design.labelScale }), [design.fontHand, design.labelScale, design.comfort.readableType, fontsReady]); // eslint-disable-line react-hooks/exhaustive-deps
207
253
  const { flatNodes, connections, containers } = useMemo(() => {
208
254
  if (!doc || !currentLayerId) return { flatNodes: [] as FlatNode[], connections: [] as MindMapConnection[], containers: [] as ContainerGroup[] };
209
255
  try {
210
256
  return {
211
- flatNodes: getNodesForLayer(doc, currentLayerId),
257
+ flatNodes: getNodesForLayer(doc, currentLayerId, sizing),
212
258
  connections: getConnectionsForLayer(doc, currentLayerId),
213
259
  containers: getContainersForLayer(doc, currentLayerId) as ContainerGroup[],
214
260
  };
215
261
  } catch { return { flatNodes: [] as FlatNode[], connections: [] as MindMapConnection[], containers: [] as ContainerGroup[] }; }
216
- }, [doc, currentLayerId]);
262
+ }, [doc, currentLayerId, sizing]);
217
263
  const viewport = useViewportStore(s => ({
218
264
  centerX: s.centerX, centerY: s.centerY, zoomLevel: s.zoomLevel,
219
265
  canvasWidth: s.canvasWidth, canvasHeight: s.canvasHeight,
@@ -235,18 +281,28 @@ export default function VisualliCanvas(props: VisualliCanvasProps) {
235
281
  clearSel();
236
282
  }, [flatNodes, setNodes, clearSel]);
237
283
 
284
+ // ── Choreography: a layer's arrival (ideas bloom, connectors draw) ────────
285
+ const clock = useMemo(() => new RevealClock(), []);
286
+ const instantReveal = design.comfort.reducedMotion || props.reveal === 'instant';
287
+ useEffect(() => {
288
+ if (!fontsReady || flatNodes.length === 0) return;
289
+ clock.start(flatNodes, connections.length, instantReveal);
290
+ // Lift the step-inside veil two frames from now, once the new layer's first frame has been painted.
291
+ const r1 = requestAnimationFrame(() => { r2 = requestAnimationFrame(arrive); });
292
+ let r2 = 0;
293
+ return () => { cancelAnimationFrame(r1); cancelAnimationFrame(r2); };
294
+ }, [flatNodes, fontsReady]); // eslint-disable-line react-hooks/exhaustive-deps
295
+ useEffect(() => clock.subscribe((running) => {
296
+ const el = containerRef.current;
297
+ if (el) el.dataset.viReveal = running ? 'running' : 'idle';
298
+ }), [clock]);
299
+ useEffect(() => () => clock.stop(), [clock]);
300
+
238
301
  // ── Canvas refs ───────────────────────────────────────────────────────────
239
302
  const containerRef = useRef<HTMLDivElement | null>(null);
240
303
  const stageRef = useRef<Konva.Stage | null>(null);
241
304
  const canvasWrapperRef = useRef<HTMLDivElement>(null);
242
305
 
243
- // ── GPU CSS-transform transition refs (matches visualli.ai) ───────────────
244
- // During animations we express viewport deltas as CSS transforms on Konva's
245
- // container div — zero canvas redraws, handled entirely by the GPU compositor.
246
- const baselineTransformRef = useRef<{ x: number; y: number; scaleX: number; scaleY: number } | null>(null);
247
- const konvaContentDivRef = useRef<HTMLDivElement | null>(null);
248
- const lastAnimViewportRef = useRef<{ centerX: number; centerY: number; zoomLevel: number } | null>(null);
249
- const zoomOutTargetRef = useRef<{ centerX: number; centerY: number; zoomLevel: number } | null>(null);
250
306
  // canvasSizeRef is kept in sync with the measured container size (not window).
251
307
  // Initialise to 0 — it will be updated synchronously before the first frame.
252
308
  const canvasSizeRef = useRef({ width: 0, height: 0 });
@@ -256,34 +312,13 @@ export default function VisualliCanvas(props: VisualliCanvasProps) {
256
312
  const dragMoveLatestRef = useRef<{ nodeId: string; x: number; y: number } | null>(null);
257
313
  const dragStartPosRef = useRef<Map<string, { x: number; y: number }>>(new Map());
258
314
 
259
- // ── Background color (with chromatic immersion support) ──────────────────
260
- const baseBgColor = isDark ? '#141412' : '#F0EDE6';
261
-
262
- // For chromatic immersion: get the parent node color for child layers
263
- const chromaticBgColor = useMemo(() => {
264
- if (!chromaticImmersion || !doc || !currentLayerId) return baseBgColor;
265
-
266
- const currentLayer = doc.layers.get(currentLayerId);
267
- if (!currentLayer) return baseBgColor;
268
-
269
- // Root layer always uses base background
270
- if (currentLayer.level === 0 || !currentLayer.parentNodeId) return baseBgColor;
271
-
272
- // For child layers, find the parent node's color
273
- const parentLayer = currentLayer.parentLayerId ? doc.layers.get(currentLayer.parentLayerId) : null;
274
- if (parentLayer) {
275
- const parentNode = parentLayer.nodes.find(n => n.id === currentLayer.parentNodeId);
276
- if (parentNode) {
277
- const nodeColor = parentNode.data.color || baseBgColor;
278
- // Add transparency to the parent node color for subtle effect
279
- return hexToRgba(nodeColor, 0.15);
280
- }
281
- }
282
-
283
- return baseBgColor;
284
- }, [chromaticImmersion, doc, currentLayerId, baseBgColor]);
285
-
286
- const bgColor = chromaticImmersion ? chromaticBgColor : baseBgColor;
315
+ // ── Chromatic immersion: the colour of the idea stepped into tints the canvas at `immersion-alpha` ──
316
+ const immersionFill = useMemo<string | null>(() => {
317
+ if (!chromaticImmersion || !doc || !currentLayerId) return null;
318
+ const layer = doc.layers.get(currentLayerId);
319
+ if (!layer || layer.level === 0 || !layer.parentNodeId) return null;
320
+ return paintVars(layerPaint(doc, layer)).fill;
321
+ }, [chromaticImmersion, doc, currentLayerId]);
287
322
 
288
323
  // ── Spatial index (for stage-level hit detection) ─────────────────────────
289
324
  const spatialIndexRef = useRef<RBushSpatialIndex | null>(null);
@@ -291,14 +326,8 @@ export default function VisualliCanvas(props: VisualliCanvasProps) {
291
326
  if (flatNodes.length === 0) { spatialIndexRef.current?.clear(); return; }
292
327
  const idx = new RBushSpatialIndex();
293
328
  idx.bulkLoad(flatNodes.map(n => {
294
- const blobHalfH = Math.max((NODE_HEIGHT * 1.6) / 2, (n.width / 2) * 0.74);
295
- return {
296
- nodeId: n.id,
297
- bounds: {
298
- minX: n.x - n.width / 2, minY: n.y - blobHalfH,
299
- maxX: n.x + n.width / 2, maxY: n.y + blobHalfH,
300
- },
301
- };
329
+ const { rx, ry } = nodeRadii(n);
330
+ return { nodeId: n.id, bounds: { minX: n.x - rx, minY: n.y - ry, maxX: n.x + rx, maxY: n.y + ry } };
302
331
  }));
303
332
  spatialIndexRef.current = idx;
304
333
  return () => { spatialIndexRef.current?.clear(); };
@@ -310,6 +339,7 @@ export default function VisualliCanvas(props: VisualliCanvasProps) {
310
339
  handleMouseDown: rendererHandleMouseDown,
311
340
  handleMouseMove: rendererHandleMouseMove,
312
341
  handleMouseUp: rendererHandleMouseUp,
342
+ cancelPan: rendererCancelPan,
313
343
  canvasWidth,
314
344
  canvasHeight,
315
345
  } = useKonvaRenderer({ containerRef, stageRef });
@@ -352,16 +382,16 @@ export default function VisualliCanvas(props: VisualliCanvasProps) {
352
382
  if (node) {
353
383
  const oldPos = dragStartPosRef.current.get(nodeId);
354
384
  if (oldPos) {
355
- const oldHalfH = Math.max((NODE_HEIGHT * 1.6) / 2, (node.width / 2) * 0.74);
385
+ const { rx, ry } = nodeRadii(node);
356
386
  spatialIndexRef.current.remove(nodeId, {
357
- minX: oldPos.x - node.width / 2, minY: oldPos.y - oldHalfH,
358
- maxX: oldPos.x + node.width / 2, maxY: oldPos.y + oldHalfH,
387
+ minX: oldPos.x - rx, minY: oldPos.y - ry,
388
+ maxX: oldPos.x + rx, maxY: oldPos.y + ry,
359
389
  });
360
390
  }
361
- const newHalfH = Math.max((NODE_HEIGHT * 1.6) / 2, (node.width / 2) * 0.74);
391
+ const { rx, ry } = nodeRadii(node);
362
392
  spatialIndexRef.current.insert(nodeId, {
363
- minX: x - node.width / 2, minY: y - newHalfH,
364
- maxX: x + node.width / 2, maxY: y + newHalfH,
393
+ minX: x - rx, minY: y - ry,
394
+ maxX: x + rx, maxY: y + ry,
365
395
  });
366
396
  }
367
397
  }
@@ -378,17 +408,13 @@ export default function VisualliCanvas(props: VisualliCanvasProps) {
378
408
  // relative to the renderer's actual bounding box.
379
409
  const cw = canvasSizeRef.current.width || containerRef.current?.getBoundingClientRect().width || 800;
380
410
  const ch = canvasSizeRef.current.height || containerRef.current?.getBoundingClientRect().height || 600;
381
- const { centerX, centerY } = calculateFitView(flatNodes, cw, ch);
382
- // Root layer: always use 1.0 zoom to match visualli.ai reference behaviour.
383
- // calculateFitZoom for a single node or dense layout on a 1920-default canvas
384
- // produces zoom > 2 which triggers the auto-zoom-in threshold immediately.
385
- const isRootLayer = navStack.length <= 1;
386
- // Root layer: use 1.0 zoom to match visualli.ai reference behaviour.
387
- // Child layers use the bounding-box fit zoom (already clamped to [0.4, 5]).
388
- const zoomLevel = isRootLayer ? 1.0 : calculateFitView(flatNodes, cw, ch).zoomLevel;
411
+ // Every layer, the root included, fits the same way (the design system's fit; never beyond VIEW.fitMax).
412
+ const { centerX, centerY, zoomLevel } = calculateFitView(flatNodes, cw, ch, containers);
413
+ // Zoom limits (VIEW) apply relative to the fit, as in the design system's map.
414
+ useViewportStore.getState().setFit(zoomLevel, centerX, centerY);
389
415
  setCenter(centerX, centerY);
390
416
  setZoom(zoomLevel);
391
- }, [flatNodes, navStack.length, setCenter, setZoom]);
417
+ }, [flatNodes, containers, setCenter, setZoom]);
392
418
 
393
419
  // Keep ref in sync after every render so async callbacks always use latest closure
394
420
  fitToScreenRef.current = fitToScreen;
@@ -409,14 +435,22 @@ export default function VisualliCanvas(props: VisualliCanvasProps) {
409
435
  // Stores the target nav-back index so onSwapBack can read it
410
436
  const navBackTargetRef = useRef<number>(-1);
411
437
 
412
- const { zoomIntoLayer, zoomOutToParent, isTransitioning: isAnimating } = useKonvaLayerTransition({
438
+ const { stepInside: diveInto, backOut, arrive, isTransitioning: isAnimating } = useLayerChoreography({
413
439
  stageRef,
414
440
  canvasWrapperRef,
441
+ reducedMotion: design.comfort.reducedMotion || props.reveal === 'instant',
415
442
  onSwapLayer: (childLayerId) => {
416
443
  const childLayer = doc?.layers.get(childLayerId);
417
444
  if (!childLayer || !doc) return;
418
445
  setCurrentLayerId(childLayerId);
419
- setNavStack(prev => [...prev, { layerId: childLayerId, layer: childLayer, label: layerLabel(doc, childLayer, childLayerId) }]);
446
+ const target = stepTargetRef.current;
447
+ stepTargetRef.current = null;
448
+ setNavStack(prev => [...prev, {
449
+ layerId: childLayerId,
450
+ layer: childLayer,
451
+ label: target?.label || layerLabel(doc, childLayer, childLayerId),
452
+ paint: target?.paint ?? layerPaint(doc, childLayer),
453
+ }]);
420
454
  onLayerChange?.(childLayerId, childLayer);
421
455
  },
422
456
  onSwapBack: () => {
@@ -434,88 +468,8 @@ export default function VisualliCanvas(props: VisualliCanvasProps) {
434
468
  setIsTransitioning(false);
435
469
  isTransitioningRef.current = false;
436
470
  // After every layer switch, snap to the fit view so zoom is always clean.
437
- // rAF ensures React has committed new node data before we compute the fit.
438
471
  requestAnimationFrame(() => { fitToScreenRef.current(); });
439
472
  },
440
- // GPU CSS-transform callbacks (matching visualli.ai exactly):
441
- onViewportFrame: (vp) => {
442
- lastAnimViewportRef.current = vp;
443
- const baseline = baselineTransformRef.current;
444
- const contentDiv = konvaContentDivRef.current;
445
- const { width, height } = canvasSizeRef.current;
446
- if (baseline && contentDiv) {
447
- const targetX = width / 2 - vp.centerX * vp.zoomLevel;
448
- const targetY = height / 2 - vp.centerY * vp.zoomLevel;
449
- const ratio = vp.zoomLevel / baseline.scaleX;
450
- const tx = targetX - baseline.x * ratio;
451
- const ty = targetY - baseline.y * ratio;
452
- contentDiv.style.transform = `translate(${tx}px, ${ty}px) scale(${ratio})`;
453
- } else {
454
- const stage = stageRef.current;
455
- if (stage) {
456
- stage.x(width / 2 - vp.centerX * vp.zoomLevel);
457
- stage.y(height / 2 - vp.centerY * vp.zoomLevel);
458
- stage.scaleX(vp.zoomLevel); stage.scaleY(vp.zoomLevel);
459
- stage.batchDraw();
460
- }
461
- }
462
- },
463
- onViewportFinal: (vp) => {
464
- if (konvaContentDivRef.current) konvaContentDivRef.current.style.transform = '';
465
- const stage = stageRef.current;
466
- if (stage) {
467
- const { width, height } = canvasSizeRef.current;
468
- stage.x(width / 2 - vp.centerX * vp.zoomLevel);
469
- stage.y(height / 2 - vp.centerY * vp.zoomLevel);
470
- stage.scaleX(vp.zoomLevel); stage.scaleY(vp.zoomLevel);
471
- stage.getLayers().forEach(l => l.drawScene());
472
- }
473
- setCenter(vp.centerX, vp.centerY);
474
- setZoom(vp.zoomLevel);
475
- },
476
- onPhaseChange: (phase) => {
477
- const stage = stageRef.current;
478
- const contentDiv = konvaContentDivRef.current;
479
- if (stage && contentDiv && (phase === 'swap' || phase === 'zoom-back')) {
480
- contentDiv.style.transform = '';
481
- const vp = phase === 'zoom-back' ? zoomOutTargetRef.current : lastAnimViewportRef.current;
482
- if (vp) {
483
- const { width, height } = canvasSizeRef.current;
484
- const nx = width / 2 - vp.centerX * vp.zoomLevel;
485
- const ny = height / 2 - vp.centerY * vp.zoomLevel;
486
- stage.x(nx); stage.y(ny);
487
- stage.scaleX(vp.zoomLevel); stage.scaleY(vp.zoomLevel);
488
- stage.getLayers().forEach(l => l.drawScene());
489
- baselineTransformRef.current = { x: nx, y: ny, scaleX: vp.zoomLevel, scaleY: vp.zoomLevel };
490
- }
491
- }
492
- },
493
- onTransitionLifecycle: (phase) => {
494
- const stage = stageRef.current;
495
- if (phase === 'start') {
496
- if (canvasWrapperRef.current) canvasWrapperRef.current.style.willChange = 'opacity, transform';
497
- if (stage) {
498
- baselineTransformRef.current = { x: stage.x(), y: stage.y(), scaleX: stage.scaleX(), scaleY: stage.scaleY() };
499
- const container = stage.container();
500
- const cd = container?.querySelector('.konvajs-content') as HTMLDivElement | null;
501
- if (cd) {
502
- konvaContentDivRef.current = cd;
503
- cd.style.transformOrigin = '0 0';
504
- cd.style.willChange = 'transform';
505
- }
506
- stage.getLayers().forEach(l => { l.listening(false); });
507
- }
508
- } else {
509
- if (canvasWrapperRef.current) canvasWrapperRef.current.style.willChange = 'auto';
510
- if (konvaContentDivRef.current) { konvaContentDivRef.current.style.willChange = 'auto'; konvaContentDivRef.current = null; }
511
- baselineTransformRef.current = null;
512
- zoomOutTargetRef.current = null;
513
- if (stage) {
514
- stage.getLayers().forEach(l => { l.listening(true); });
515
- stage.batchDraw();
516
- }
517
- }
518
- },
519
473
  });
520
474
 
521
475
  const handleNavigate = useCallback((nodeId: string) => {
@@ -534,7 +488,7 @@ export default function VisualliCanvas(props: VisualliCanvasProps) {
534
488
  if (!childLayerId) {
535
489
  return;
536
490
  }
537
- const childNodes = (() => { try { return getNodesForLayer(doc, childLayerId); } catch { return []; } })();
491
+ const childNodes = (() => { try { return getNodesForLayer(doc, childLayerId, sizing); } catch { return []; } })();
538
492
  if (childNodes.length === 0) {
539
493
  return;
540
494
  }
@@ -547,19 +501,15 @@ export default function VisualliCanvas(props: VisualliCanvasProps) {
547
501
  // rather than the arbitrary zoom that happened to trigger the navigation.
548
502
  const cw = canvasSizeRef.current.width || 800;
549
503
  const ch = canvasSizeRef.current.height || 600;
550
- const fitVp = calculateFitView(flatNodes, cw, ch);
551
- const isRootNow = navStack.length <= 1;
552
- parentViewports.current.push({ centerX: fitVp.centerX, centerY: fitVp.centerY, zoomLevel: isRootNow ? 0.85 : fitVp.zoomLevel });
504
+ const fitVp = calculateFitView(flatNodes, cw, ch, containers);
505
+ parentViewports.current.push({ centerX: fitVp.centerX, centerY: fitVp.centerY, zoomLevel: fitVp.zoomLevel });
553
506
 
507
+ stepTargetRef.current = { paint: { topic: node.topic, custom: node.custom }, label: node.title };
554
508
  setIsTransitioning(true);
555
509
  isTransitioningRef.current = true;
556
510
 
557
- zoomIntoLayer(
558
- doc, node, childLayerId, childNodes,
559
- node.color ?? getThemeBackground(isDark, 'secondary'),
560
- node.color ?? getThemeBackground(isDark, 'primary'),
561
- );
562
- }, [doc, currentLayerId, nodes, isDark, isAnimating, zoomIntoLayer]);
511
+ diveInto(node, childLayerId, childNodes);
512
+ }, [doc, currentLayerId, nodes, isAnimating, diveInto, sizing, flatNodes, containers]);
563
513
 
564
514
  const handleNavigateBack = useCallback((targetIndex: number) => {
565
515
  if (targetIndex >= navStack.length - 1) return;
@@ -569,47 +519,8 @@ export default function VisualliCanvas(props: VisualliCanvasProps) {
569
519
  navBackTargetRef.current = targetIndex;
570
520
  setIsTransitioning(true);
571
521
  isTransitioningRef.current = true;
572
- zoomOutToParent(savedVp, getThemeBackground(isDark, 'primary'), getThemeBackground(isDark, 'secondary'));
573
- }, [navStack.length, isDark, isAnimating, zoomOutToParent]);
574
-
575
- // ── Auto-zoom navigation (Google Maps style) ──────────────────────────────
576
- const baseZoomRef = useRef(1.0);
577
- const lastZoomTransitionRef = useRef(0);
578
- const ZOOM_COOLDOWN = 1000;
579
-
580
- useEffect(() => { baseZoomRef.current = 1.0; }, [currentLayerId]);
581
-
582
- useEffect(() => {
583
- if (isTransitioningRef.current || isAnimating()) return;
584
- const now = Date.now();
585
- if (now - lastZoomTransitionRef.current < ZOOM_COOLDOWN) return;
586
- const relative = viewport.zoomLevel / baseZoomRef.current;
587
-
588
- if (relative >= ZOOM_NAV_IN_THRESHOLD) {
589
- // Zoom in threshold — drill into closest node
590
- let closest: FlatNode | null = null;
591
- let minDist = Infinity;
592
- for (const node of nodes.values()) {
593
- const dx = node.x - viewport.centerX;
594
- const dy = node.y - viewport.centerY;
595
- const d = dx * dx + dy * dy;
596
- if (d < minDist) { minDist = d; closest = node; }
597
- }
598
- if (closest) {
599
- const childLayer = doc ? getChildLayerForNode(doc, closest.id, currentLayerId ?? '') : null;
600
- if (childLayer) {
601
- lastZoomTransitionRef.current = now;
602
- handleNavigate(closest.id);
603
- baseZoomRef.current = 1.0;
604
- }
605
- }
606
- } else if (relative < ZOOM_NAV_OUT_THRESHOLD && navStack.length > 1) {
607
- // Zoom out threshold — go back to parent
608
- lastZoomTransitionRef.current = now;
609
- handleNavigateBack(navStack.length - 2);
610
- baseZoomRef.current = 1.0;
611
- }
612
- }, [viewport.zoomLevel, viewport.centerX, viewport.centerY, nodes, doc, currentLayerId, navStack, isAnimating, handleNavigate, handleNavigateBack]); // eslint-disable-line react-hooks/exhaustive-deps
522
+ backOut(savedVp);
523
+ }, [navStack.length, isAnimating, backOut]);
613
524
 
614
525
  // ── Stage-level hit detection ─────────────────────────────────────────────
615
526
  const toWorldCoords = useCallback((stage: Konva.Stage, pointer: { x: number; y: number }) => {
@@ -680,9 +591,55 @@ export default function VisualliCanvas(props: VisualliCanvasProps) {
680
591
  return () => { window.removeEventListener('pointerdown', close); window.removeEventListener('keydown', onKey); };
681
592
  }, [canvasContextMenu]);
682
593
 
594
+ const selectedNodeId = useSelectionStore(st => st.selectedId);
595
+ const [focusedNodeId, setFocusedNodeId] = useState<string | null>(null);
596
+ const anchors = useMemo(() => (doc ? getSemanticAnchors(doc) : []), [doc]);
597
+
598
+ // ── Touch mode + pinned peek (bottom sheet) ───────────────────────────────
599
+ // Touch browsers also fire compatibility mouse events after a tap; those are ignored so
600
+ // they can neither reopen nor close the sheet.
601
+ const [autoTouch, setAutoTouch] = useState<boolean>(() => typeof window !== 'undefined' && !!window.matchMedia && window.matchMedia('(hover: none)').matches);
602
+ const touchMode = props.layout === 'touch' ? true : props.layout === 'pointer' ? false : autoTouch;
603
+ const touchModeRef = useRef(touchMode);
604
+ touchModeRef.current = touchMode;
605
+ const lastTouchAtRef = useRef(0);
606
+ const emptyTapRef = useRef<{ x: number; y: number } | null>(null);
607
+ const [pinnedId, setPinnedId] = useState<string | null>(null);
608
+ const [sheetExpanded, setSheetExpanded] = useState(false);
609
+ const isEmulatedMouse = (evt: Event) => evt.type.startsWith('mouse') && performance.now() - lastTouchAtRef.current < 700;
610
+ /** Client coordinates of a mouse or touch event. */
611
+ const clientOf = (evt: MouseEvent | TouchEvent) => {
612
+ const t = ('touches' in evt && (evt.touches[0] ?? evt.changedTouches[0])) || (evt as MouseEvent);
613
+ return { x: t.clientX, y: t.clientY };
614
+ };
615
+ const pinnedIdRef = useRef<string | null>(null);
616
+ pinnedIdRef.current = pinnedId;
617
+ /** Collapsed sheet height: the design system's SHEET — a share of the map, at most SHEET.maxHeight. */
618
+ const sheetHeight = Math.min(SHEET.maxHeight, Math.round((viewport.canvasHeight || canvasSizeRef.current.height || 600) * SHEET.heightRatio));
619
+
620
+ /** Open the bottom sheet for an idea, scrolling the map so the idea stays visible above it. */
621
+ const openSheet = (node: FlatNode) => {
622
+ setPinnedId(node.id);
623
+ setSheetExpanded(false);
624
+ setHoveredNode(null);
625
+ const vp = useViewportStore.getState();
626
+ const ch = canvasSizeRef.current.height || vp.canvasHeight;
627
+ const sh = Math.min(SHEET.maxHeight, Math.round(ch * SHEET.heightRatio));
628
+ const screenY = (node.y - vp.centerY) * vp.zoomLevel + ch / 2;
629
+ const reach = nodeRadii(node).ry * vp.zoomLevel;
630
+ if (screenY + reach > ch - sh - 12 || screenY - reach < 0) vp.setCenter(vp.centerX, node.y + sh / (2 * vp.zoomLevel));
631
+ };
632
+ // Close the sheet when the layer changes.
633
+ useEffect(() => { setPinnedId(null); }, [currentLayerId]);
634
+
683
635
  // ── Stage event handlers ──────────────────────────────────────────────────
684
636
  const handleStageMouseDown = useCallback((e: Konva.KonvaEventObject<MouseEvent>) => {
685
- if (e.evt.button !== 0) { rendererHandleMouseDown(e); return; }
637
+ if (isEmulatedMouse(e.evt)) return;
638
+ const isTouch = e.evt.type.startsWith('touch');
639
+ if (isTouch) lastTouchAtRef.current = performance.now();
640
+ if (props.layout === undefined || props.layout === 'auto') setAutoTouch(isTouch);
641
+ // Touch events carry no `button`: a touch is always a primary press.
642
+ if (!isTouch && e.evt.button !== 0) { rendererHandleMouseDown(e); return; }
686
643
  const stage = e.target.getStage();
687
644
  if (!stage) { rendererHandleMouseDown(e); return; }
688
645
  const pointer = stage.getPointerPosition();
@@ -695,7 +652,7 @@ export default function VisualliCanvas(props: VisualliCanvasProps) {
695
652
  select(nodeId);
696
653
  stageActiveNodeIdRef.current = nodeId;
697
654
  stageDragNodeOffsetRef.current = { x: worldX - node.x, y: worldY - node.y };
698
- stageMouseDownPosRef.current = { x: e.evt.clientX, y: e.evt.clientY };
655
+ stageMouseDownPosRef.current = clientOf(e.evt);
699
656
  stageDragCommittedRef.current = false;
700
657
  setPressedNodeId(nodeId);
701
658
  setHoveredNode(null);
@@ -703,11 +660,13 @@ export default function VisualliCanvas(props: VisualliCanvasProps) {
703
660
  }
704
661
  }
705
662
  setHoveredNode(null);
663
+ emptyTapRef.current = clientOf(e.evt);
706
664
  isCanvasPanningRef.current = true;
707
665
  rendererHandleMouseDown(e);
708
- }, [hitTestNode, toWorldCoords, nodes, select, rendererHandleMouseDown]);
666
+ }, [hitTestNode, toWorldCoords, nodes, select, rendererHandleMouseDown, props.layout]);
709
667
 
710
668
  const handleStageMouseMove = useCallback((e: Konva.KonvaEventObject<MouseEvent>) => {
669
+ if (isEmulatedMouse(e.evt)) return;
711
670
  const stage = e.target.getStage();
712
671
  if (!stage) { rendererHandleMouseMove(e); return; }
713
672
  const pointer = stage.getPointerPosition();
@@ -715,7 +674,8 @@ export default function VisualliCanvas(props: VisualliCanvasProps) {
715
674
  const { worldX, worldY } = toWorldCoords(stage, pointer);
716
675
 
717
676
  if (stageActiveNodeIdRef.current && stageMouseDownPosRef.current) {
718
- const moved = Math.hypot(e.evt.clientX - stageMouseDownPosRef.current.x, e.evt.clientY - stageMouseDownPosRef.current.y);
677
+ const cur = clientOf(e.evt);
678
+ const moved = Math.hypot(cur.x - stageMouseDownPosRef.current.x, cur.y - stageMouseDownPosRef.current.y);
719
679
  if (!stageDragCommittedRef.current && moved >= 5) {
720
680
  stageDragCommittedRef.current = true;
721
681
  isDraggingRef.current = true;
@@ -727,7 +687,6 @@ export default function VisualliCanvas(props: VisualliCanvasProps) {
727
687
  if (stageDragCommittedRef.current) {
728
688
  const offset = stageDragNodeOffsetRef.current;
729
689
  handleDragMove(stageActiveNodeIdRef.current, worldX - offset.x, worldY - offset.y);
730
- stage.container().style.cursor = 'grabbing';
731
690
  return;
732
691
  }
733
692
  return;
@@ -747,19 +706,17 @@ export default function VisualliCanvas(props: VisualliCanvasProps) {
747
706
  if (hovered !== pointerNodeIdRef.current) {
748
707
  pointerNodeIdRef.current = hovered;
749
708
  setPointerNodeId(hovered);
750
- onNodeHover?.(hovered);
751
- }
752
- if (hovered) {
753
- stage.container().style.cursor = 'pointer';
754
- } else {
755
- stage.container().style.cursor = 'grab';
709
+ onNodeHoverRef.current?.(hovered);
756
710
  }
711
+ // Cursors as the design system's: an idea is a button (pointer); the canvas keeps the default.
712
+ stage.container().style.cursor = hovered ? 'pointer' : '';
757
713
  }
758
714
 
759
715
  rendererHandleMouseMove(e);
760
716
  }, [toWorldCoords, hitTestNode, handleDragStart, handleDragMove, rendererHandleMouseMove]);
761
717
 
762
718
  const handleStageMouseUp = useCallback((e: Konva.KonvaEventObject<MouseEvent>) => {
719
+ if (isEmulatedMouse(e.evt)) return;
763
720
  setPressedNodeId(null);
764
721
  if (stageActiveNodeIdRef.current) {
765
722
  const nodeId = stageActiveNodeIdRef.current;
@@ -779,7 +736,14 @@ export default function VisualliCanvas(props: VisualliCanvasProps) {
779
736
  const node = nodes.get(nodeId);
780
737
  if (node) {
781
738
  const childLayer = doc ? getChildLayerForNode(doc, nodeId, currentLayerId ?? '') : null;
782
- if (childLayer) {
739
+ if (touchModeRef.current && childLayer && pinnedIdRef.current === nodeId) {
740
+ // Touch: tapping the idea whose sheet is open steps inside (same as the sheet's Step inside).
741
+ setPinnedId(null);
742
+ handleNavigate(nodeId);
743
+ } else if (touchModeRef.current && (node.description || childLayer)) {
744
+ // Touch: the first tap opens the sheet; nothing flashes or navigates by itself.
745
+ openSheet(node);
746
+ } else if (childLayer) {
783
747
  handleNavigate(nodeId);
784
748
  }
785
749
  onNodeClick?.(node);
@@ -792,9 +756,16 @@ export default function VisualliCanvas(props: VisualliCanvasProps) {
792
756
  stageDragCommittedRef.current = false;
793
757
  return;
794
758
  }
759
+ // A tap (not a pan) on empty canvas dismisses the sheet.
760
+ const tap = emptyTapRef.current;
761
+ emptyTapRef.current = null;
762
+ if (tap && pinnedIdRef.current) {
763
+ const end = clientOf(e.evt);
764
+ if (!Number.isFinite(end.x) || Math.hypot(end.x - tap.x, end.y - tap.y) < 6) setPinnedId(null);
765
+ }
795
766
  isCanvasPanningRef.current = false;
796
767
  rendererHandleMouseUp(e);
797
- }, [toWorldCoords, handleDragEnd, nodes, handleNavigate, onNodeClick, rendererHandleMouseUp]);
768
+ }, [toWorldCoords, handleDragEnd, nodes, handleNavigate, onNodeClick, rendererHandleMouseUp]); // eslint-disable-line react-hooks/exhaustive-deps
798
769
 
799
770
  const handleStageContextMenu = useCallback((e: Konva.KonvaEventObject<MouseEvent>) => {
800
771
  e.evt.preventDefault();
@@ -805,41 +776,81 @@ export default function VisualliCanvas(props: VisualliCanvasProps) {
805
776
  setCanvasContextMenu({ x: localX, y: localY });
806
777
  }, [navStack.length]);
807
778
 
808
- // Touch passthrough
809
- const handleStageTouchStart = useCallback((e: Konva.KonvaEventObject<TouchEvent>) =>
810
- handleStageMouseDown(e as unknown as Konva.KonvaEventObject<MouseEvent>), [handleStageMouseDown]);
811
- const handleStageTouchMove = useCallback((e: Konva.KonvaEventObject<TouchEvent>) =>
812
- handleStageMouseMove(e as unknown as Konva.KonvaEventObject<MouseEvent>), [handleStageMouseMove]);
813
- const handleStageTouchEnd = useCallback((e: Konva.KonvaEventObject<TouchEvent>) =>
814
- handleStageMouseUp(e as unknown as Konva.KonvaEventObject<MouseEvent>), [handleStageMouseUp]);
815
-
816
- // Wheel with auto-nav guard on non-root layers
817
- const handleWheel = useCallback((e: Konva.KonvaEventObject<WheelEvent>) => {
818
- const isZoomOut = e.evt.deltaY > 0;
819
- if (!isZoomOut) { rendererHandleWheel(e); return; }
820
-
821
- const liveZoom = useViewportStore.getState().zoomLevel;
822
- const projected = Math.max(ZOOM_MIN, liveZoom * (1 - e.evt.deltaY / 1000));
823
-
824
- // Root layer: no parent to navigate to — let the user zoom freely down to ZOOM_MIN
825
- // (do NOT clamp at ZOOM_NAV_OUT_THRESHOLD; content may require lower zoom to fit)
826
-
827
- if (navStack.length > 1 && projected <= ZOOM_NAV_OUT_THRESHOLD && !isTransitioningRef.current && !isAnimating()) {
828
- e.evt.preventDefault();
829
- const now = Date.now();
830
- if (now - lastZoomTransitionRef.current >= ZOOM_COOLDOWN) {
831
- lastZoomTransitionRef.current = now;
832
- handleNavigateBack(navStack.length - 2);
833
- baseZoomRef.current = 1.0;
834
- }
835
- return;
779
+ // ── Pinch to zoom (the design system's GESTURE, geometry/interaction.ts) ─────
780
+ // Two touch pointers zoom the contents around the point between them (pinchZoom / zoomAround, within VIEW's
781
+ // limits) and pan as they move together. Only the Konva world moves — the trail, controls, peek and sheet are
782
+ // DOM and never scale. A pinch never navigates, and never ends as a tap, a pan or an idea drag.
783
+ const touchPointsRef = useRef(new Map<number, { x: number; y: number }>());
784
+ const pinchRef = useRef<{ d0: number; m0: { x: number; y: number }; z0: number; c0: { x: number; y: number } } | null>(null);
785
+ const pinchEndedAtRef = useRef(-Infinity);
786
+ const [pinching, setPinching] = useState(false);
787
+ const localOf = (e: React.PointerEvent) => { const r = containerRef.current!.getBoundingClientRect(); return { x: e.clientX - r.left, y: e.clientY - r.top }; };
788
+ const onPinchDown = (e: React.PointerEvent) => {
789
+ if (!GESTURE.pinchZoom || !GESTURE.pointers.includes(e.pointerType)) return;
790
+ touchPointsRef.current.set(e.pointerId, localOf(e));
791
+ if (touchPointsRef.current.size !== 2 || isTransitioningRef.current || isAnimating()) return;
792
+ const [a, b] = [...touchPointsRef.current.values()];
793
+ const vp = useViewportStore.getState();
794
+ pinchRef.current = { d0: Math.hypot(a.x - b.x, a.y - b.y), m0: { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 }, z0: vp.zoomLevel, c0: { x: vp.centerX, y: vp.centerY } };
795
+ // The first finger's press becomes part of the pinch: no tap, pan or drag comes of it.
796
+ rendererCancelPan();
797
+ isCanvasPanningRef.current = false;
798
+ emptyTapRef.current = null;
799
+ stageActiveNodeIdRef.current = null;
800
+ stageDragCommittedRef.current = false;
801
+ isDraggingRef.current = false;
802
+ setPressedNodeId(null);
803
+ setPinching(true);
804
+ };
805
+ const onPinchMove = (e: React.PointerEvent) => {
806
+ if (!touchPointsRef.current.has(e.pointerId)) return;
807
+ touchPointsRef.current.set(e.pointerId, localOf(e));
808
+ const p = pinchRef.current;
809
+ if (!p || touchPointsRef.current.size < 2) return;
810
+ const [a, b] = [...touchPointsRef.current.values()];
811
+ const fit = useViewportStore.getState().fitScale;
812
+ const z = pinchZoom(p.z0 / fit, p.d0, Math.hypot(a.x - b.x, a.y - b.y)) * fit; // VIEW's limits relative to the fit
813
+ const m = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
814
+ const cw = canvasSizeRef.current.width || containerRef.current?.clientWidth || 800;
815
+ const ch = canvasSizeRef.current.height || containerRef.current?.clientHeight || 600;
816
+ // screen = (world − centre) × zoom + size / 2, i.e. translation t = size / 2 − centre × zoom
817
+ const t = zoomAround({ x: cw / 2 - p.c0.x * p.z0, y: ch / 2 - p.c0.y * p.z0 }, p.z0, z, p.m0, GESTURE.twoFingerPan ? m : p.m0);
818
+ setZoom(z);
819
+ setCenter((cw / 2 - t.x) / z, (ch / 2 - t.y) / z);
820
+ };
821
+ const onPinchUp = (e: React.PointerEvent) => {
822
+ if (!touchPointsRef.current.delete(e.pointerId)) return;
823
+ if (pinchRef.current && touchPointsRef.current.size < 2) {
824
+ pinchRef.current = null;
825
+ pinchEndedAtRef.current = performance.now();
826
+ setPinching(false);
836
827
  }
837
- rendererHandleWheel(e);
838
- }, [rendererHandleWheel, navStack, setZoom, isAnimating, handleNavigateBack]);
828
+ };
829
+ /** True while two fingers pinch, and for a moment after: the lifting fingers are not a tap. */
830
+ const inPinch = () => pinchRef.current !== null || touchPointsRef.current.size > 1 || performance.now() - pinchEndedAtRef.current < 300;
831
+
832
+ // Touch passthrough (one finger); a pinch owns the touches while it lasts.
833
+ const handleStageTouchStart = useCallback((e: Konva.KonvaEventObject<TouchEvent>) => {
834
+ if (inPinch() || e.evt.touches.length > 1) return;
835
+ handleStageMouseDown(e as unknown as Konva.KonvaEventObject<MouseEvent>);
836
+ }, [handleStageMouseDown]);
837
+ const handleStageTouchMove = useCallback((e: Konva.KonvaEventObject<TouchEvent>) => {
838
+ if (inPinch() || e.evt.touches.length > 1) return;
839
+ handleStageMouseMove(e as unknown as Konva.KonvaEventObject<MouseEvent>);
840
+ }, [handleStageMouseMove]);
841
+ const handleStageTouchEnd = useCallback((e: Konva.KonvaEventObject<TouchEvent>) => {
842
+ if (inPinch()) { isCanvasPanningRef.current = false; return; }
843
+ handleStageMouseUp(e as unknown as Konva.KonvaEventObject<MouseEvent>);
844
+ }, [handleStageMouseUp]);
845
+
846
+ // Wheel / trackpad: zoom within VIEW's limits. It never navigates — stepping inside and backing out are
847
+ // the design system's explicit actions (the idea, its peek, the depth trail, Escape / Backspace).
848
+ const handleWheel = useCallback((e: Konva.KonvaEventObject<WheelEvent>) => { rendererHandleWheel(e); }, [rendererHandleWheel]);
839
849
 
840
850
  // Canvas mouse move for description tooltip
841
851
  const lastHoverCheckRef = useRef(0);
842
852
  const handleCanvasMouseMove = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
853
+ if (touchModeRef.current || isEmulatedMouse(e.nativeEvent)) return; // no hover peek on touch
843
854
  if (!containerRef.current || isDraggingRef.current || isCanvasPanningRef.current) {
844
855
  setHoveredNode(null);
845
856
  return;
@@ -884,7 +895,7 @@ export default function VisualliCanvas(props: VisualliCanvasProps) {
884
895
  if (clearTooltipTimerRef.current) clearTimeout(clearTooltipTimerRef.current);
885
896
  clearTooltipTimerRef.current = setTimeout(() => {
886
897
  if (!tooltipHoverRef.current && !keepTooltipOpenRef.current) setHoveredNode(null);
887
- }, 150);
898
+ }, PEEK.hoverClose); // the design system's peek grace (geometry/interaction.ts)
888
899
  } else {
889
900
  if (clearTooltipTimerRef.current) { clearTimeout(clearTooltipTimerRef.current); clearTooltipTimerRef.current = null; }
890
901
  }
@@ -896,12 +907,11 @@ export default function VisualliCanvas(props: VisualliCanvasProps) {
896
907
  keepTooltipOpenRef.current = event.detail.keep;
897
908
  };
898
909
 
899
- // @ts-ignore - custom event
900
- window.addEventListener('keepNodeTooltipOpen', handleKeepTooltipOpen);
910
+ // A custom event: its listener takes a CustomEvent, so it is cast to the DOM's EventListener.
911
+ window.addEventListener('keepNodeTooltipOpen', handleKeepTooltipOpen as EventListener);
901
912
 
902
913
  return () => {
903
- // @ts-ignore - custom event
904
- window.removeEventListener('keepNodeTooltipOpen', handleKeepTooltipOpen);
914
+ window.removeEventListener('keepNodeTooltipOpen', handleKeepTooltipOpen as EventListener);
905
915
  };
906
916
  }, []);
907
917
 
@@ -914,78 +924,126 @@ export default function VisualliCanvas(props: VisualliCanvasProps) {
914
924
  };
915
925
  }, []);
916
926
 
917
- // ── Zoom controls ─────────────────────────────────────────────────────────
918
- const handleZoomIn = useCallback(() => setZoom(Math.min(viewport.zoomLevel * 1.2, 5)), [viewport.zoomLevel, setZoom]);
919
- const handleZoomOut = useCallback(() => {
920
- // Non-root: allow zoom to ZOOM_MIN so the wheel-driven ZOOM_NAV_OUT_THRESHOLD
921
- // transition can still trigger via scroll; button just navigates zoom value.
922
- // Root: also ZOOM_MIN (no parent layer to navigate to, allow full zoom-out).
923
- setZoom(Math.max(viewport.zoomLevel / 1.2, ZOOM_MIN));
924
- }, [viewport.zoomLevel, setZoom]);
925
-
926
- // Extension rendering removed - not part of open spec
927
+ // ── Activate an idea (click, or Enter/Space on its accessible button) ───────
928
+ const activateNode = useCallback((node: FlatNode) => {
929
+ const childLayer = doc ? getChildLayerForNode(doc, node.id, currentLayerId ?? '') : null;
930
+ if (touchModeRef.current && childLayer && pinnedIdRef.current === node.id) { setPinnedId(null); handleNavigate(node.id); }
931
+ else if (touchModeRef.current && (node.description || childLayer)) openSheet(node);
932
+ else if (childLayer) handleNavigate(node.id);
933
+ onNodeClick?.(node);
934
+ }, [doc, currentLayerId, handleNavigate, onNodeClick]);
935
+
936
+ const stepInside = useCallback((nodeId: string) => { setHoveredNode(null); handleNavigate(nodeId); }, [handleNavigate]);
937
+
938
+ // The peek can unmount while the pointer is on it (Step inside, layer change): no mouseleave
939
+ // fires then, so clear the "pointer is on the peek" flag whenever it closes.
940
+ useEffect(() => { if (!hoveredNode) tooltipHoverRef.current = false; }, [hoveredNode]);
941
+
942
+ // Keyboard, as the design system's canvas language: Escape or Backspace steps back out (Escape first closes a
943
+ // pinned peek); ⌘ / Ctrl + + / − / 0 zoom in, zoom out and fit (by VIEW.zoomStep, within VIEW's limits).
944
+ const handleKeyDown = useCallback((e: React.KeyboardEvent<HTMLDivElement>) => {
945
+ if ((e.target as HTMLElement).closest('input, textarea, [contenteditable="true"]')) return;
946
+ const cmd = e.metaKey || e.ctrlKey;
947
+ if (cmd && !e.altKey) {
948
+ if (e.key === '+' || e.key === '=') { e.preventDefault(); setZoom(viewport.zoomLevel * VIEW.zoomStep); }
949
+ else if (e.key === '-' || e.key === '_') { e.preventDefault(); setZoom(viewport.zoomLevel / VIEW.zoomStep); }
950
+ else if (e.key === '0') { e.preventDefault(); fitToScreen(); }
951
+ return;
952
+ }
953
+ if (e.altKey || e.shiftKey) return;
954
+ if (e.key === 'Escape' && pinnedIdRef.current) { e.preventDefault(); setPinnedId(null); }
955
+ else if ((e.key === 'Escape' || e.key === 'Backspace') && navStack.length > 1) { e.preventDefault(); handleNavigateBack(navStack.length - 2); }
956
+ }, [navStack.length, handleNavigateBack, setZoom, viewport.zoomLevel, fitToScreen]);
927
957
 
928
958
  // ── Render ────────────────────────────────────────────────────────────────
929
959
  if (!doc) {
930
960
  return (
931
- <div className={className} style={{ position: 'relative', display: 'flex', alignItems: 'center', justifyContent: 'center', background: bgColor, ...style }}>
932
- <span style={{ color: isDark ? 'rgba(255,255,255,0.4)' : 'rgba(0,0,0,0.3)', fontSize: 14 }}>No document</span>
961
+ <div className={`vi-map ${className}`} {...design.attrs} style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', ...style }}>
962
+ <span className="body-sm" style={{ color: 'var(--ink-muted)' }}>No document</span>
933
963
  </div>
934
964
  );
935
965
  }
936
966
 
937
967
  const hasBackOption = navStack.length > 1;
968
+ const currentLayerTitle = navStack[navStack.length - 1]?.label ?? '';
969
+ const peekNode = hoveredNode ? nodes.get(hoveredNode.nodeId) : undefined;
970
+ const pinnedNode = pinnedId ? nodes.get(pinnedId) : undefined;
971
+ const pinnedHasChild = !!(pinnedNode && getChildLayerForNode(doc, pinnedNode.id, currentLayerId ?? ''));
972
+ const compact = (viewport.canvasWidth || canvasSizeRef.current.width || 1024) < 560;
973
+ const medium = !compact && (viewport.canvasWidth || canvasSizeRef.current.width || 1024) < 900;
974
+ const peekHasChild = !!(peekNode && doc && getChildLayerForNode(doc, peekNode.id, currentLayerId ?? ''));
938
975
 
939
976
  return (
940
977
  <div
941
978
  ref={containerRef}
942
- className={className}
943
- style={{
944
- position: 'relative',
945
- overflow: 'hidden',
946
- userSelect: 'none',
947
- background: bgColor,
948
- width: '100%',
949
- height: '100%',
950
- ...style
951
- }}
979
+ className={`vi-map${touchMode ? ' is-touch' : ''}${compact ? ' is-compact' : ''}${medium ? ' is-medium' : ''}${pinching ? ' is-pinching' : ''}${props.app ? ' is-app' : ''} ${className}`}
980
+ {...design.attrs}
981
+ data-reveal={instantReveal ? 'instant' : undefined}
982
+ role="group"
983
+ aria-label={`${doc.meta?.title ?? 'Map'}${currentLayerTitle ? `, ${currentLayerTitle}` : ''}`}
984
+ onKeyDown={handleKeyDown}
985
+ // Focusable by pointer so Esc / + / - / 0 work after clicking the map (Tab order is the mirrored ideas).
986
+ tabIndex={-1}
987
+ onPointerDownCapture={(e) => { onPinchDown(e); if (!(e.target as HTMLElement).closest('button, a, input')) containerRef.current?.focus({ preventScroll: true }); }}
988
+ onPointerMoveCapture={onPinchMove}
989
+ onPointerUpCapture={onPinchUp}
990
+ onPointerCancelCapture={onPinchUp}
991
+ style={{ position: 'relative', overflow: 'hidden', userSelect: 'none', width: '100%', height: '100%', outline: 'none', ...(motionVars() as React.CSSProperties), ...style }}
952
992
  onMouseMove={handleCanvasMouseMove}
953
993
  onMouseLeave={() => {
954
- // Only close tooltip on mouse leave if not keeping it open
994
+ // Only close the peek on mouse leave if not keeping it open
955
995
  if (!tooltipHoverRef.current && !keepTooltipOpenRef.current) {
956
996
  setHoveredNode(null);
957
997
  }
958
998
  }}
959
999
  >
960
- {/* Konva canvas — opacity driven imperatively during transitions */}
1000
+ {/* Chromatic immersion tint */}
1001
+ <div className="vi-map__immersion" style={{ background: immersionFill ?? 'transparent' }} aria-hidden="true" />
1002
+
1003
+ {/* Konva canvas — opacity driven imperatively during transitions. Held back until the fonts are ready. */}
961
1004
  <div ref={canvasWrapperRef} style={{ position: 'relative', zIndex: 1, pointerEvents: 'auto' }}>
962
- <KonvaStage
963
- ref={stageRef}
964
- onWheel={handleWheel}
965
- onMouseDown={handleStageMouseDown}
966
- onMouseMove={handleStageMouseMove}
967
- onMouseUp={handleStageMouseUp}
968
- onContextMenu={handleStageContextMenu}
969
- onTouchStart={handleStageTouchStart}
970
- onTouchMove={handleStageTouchMove}
971
- onTouchEnd={handleStageTouchEnd}
972
- >
973
- <KonvaContainerLayer nodes={flatNodes} containers={containers} isDark={isDark} />
974
- <KonvaEdgeLayer
975
- nodes={flatNodes}
976
- connections={connections}
977
- isDark={isDark}
978
- isDragging={isDraggingState}
979
- />
980
- <KonvaNodeLayer
981
- isTransitioning={isTransitioning}
982
- isDragging={isDraggingState}
983
- hoveredNodeId={pointerNodeId}
984
- pressedNodeId={pressedNodeId}
985
- />
986
- </KonvaStage>
1005
+ {fontsReady && (
1006
+ <KonvaStage
1007
+ ref={stageRef}
1008
+ onWheel={handleWheel}
1009
+ onMouseDown={handleStageMouseDown}
1010
+ onMouseMove={handleStageMouseMove}
1011
+ onMouseUp={handleStageMouseUp}
1012
+ onContextMenu={handleStageContextMenu}
1013
+ onTouchStart={handleStageTouchStart}
1014
+ onTouchMove={handleStageTouchMove}
1015
+ onTouchEnd={handleStageTouchEnd}
1016
+ >
1017
+ <KonvaContainerLayer nodes={flatNodes} containers={containers} connections={connections} design={design} />
1018
+ <KonvaEdgeLayer nodes={flatNodes} connections={connections} design={design} isDragging={isDraggingState} clock={clock} />
1019
+ <KonvaNodeLayer
1020
+ design={design}
1021
+ clock={clock}
1022
+ isTransitioning={isTransitioning}
1023
+ isDragging={isDraggingState}
1024
+ hoveredNodeId={pointerNodeId}
1025
+ pressedNodeId={pressedNodeId}
1026
+ selectedNodeId={selectedNodeId}
1027
+ focusedNodeId={focusedNodeId}
1028
+ touch={touchMode}
1029
+ peekedNodeId={touchMode ? pinnedId : null}
1030
+ />
1031
+ {/* Container names above connectors and ideas, so nothing runs over them. */}
1032
+ <KonvaContainerLabelLayer containers={containers} connections={connections} design={design} />
1033
+ </KonvaStage>
1034
+ )}
987
1035
  </div>
988
1036
 
1037
+ {/* Screen-reader / keyboard mirror of the visible ideas */}
1038
+ <A11yLayer
1039
+ nodes={flatNodes}
1040
+ label={`Ideas in ${currentLayerTitle || 'this map'}`}
1041
+ hasChildLayer={(id) => !!getChildLayerForNode(doc, id, currentLayerId ?? '')}
1042
+ onActivate={activateNode}
1043
+ onFocusNode={setFocusedNodeId}
1044
+ announcement={hasBackOption ? `Now inside ${currentLayerTitle}` : undefined}
1045
+ />
1046
+
989
1047
  {/* Context menu (right-click to go back) */}
990
1048
  {canvasContextMenu && hasBackOption && (
991
1049
  <div
@@ -993,7 +1051,7 @@ export default function VisualliCanvas(props: VisualliCanvasProps) {
993
1051
  aria-label="Canvas options"
994
1052
  style={{
995
1053
  position: 'absolute',
996
- zIndex: 60,
1054
+ zIndex: 'var(--z-menu)' as unknown as number,
997
1055
  pointerEvents: 'auto',
998
1056
  left: `${Math.min(canvasContextMenu.x, Math.max(8, (canvasSizeRef.current.width || 800) - 152))}px`,
999
1057
  top: `${Math.min(canvasContextMenu.y, Math.max(8, (canvasSizeRef.current.height || 600) - 72))}px`,
@@ -1003,16 +1061,9 @@ export default function VisualliCanvas(props: VisualliCanvasProps) {
1003
1061
  >
1004
1062
  <button
1005
1063
  type="button"
1006
- style={{
1007
- padding: '6px 16px',
1008
- borderRadius: '10px',
1009
- border: `1px solid ${isDark ? '#333330' : '#DDD9D0'}`,
1010
- background: isDark ? '#1C1C1A' : '#FAF8F4',
1011
- color: isDark ? '#F0EDE6' : '#1A1A18',
1012
- cursor: 'pointer',
1013
- fontSize: 13,
1014
- boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
1015
- }}
1064
+ role="menuitem"
1065
+ className="vi-iconbtn vi-iconbtn--surface"
1066
+ style={{ width: 'auto', padding: '6px var(--space-4)', boxShadow: 'var(--shadow-pop)' }}
1016
1067
  onClick={() => { setCanvasContextMenu(null); handleNavigateBack(navStack.length - 2); }}
1017
1068
  >
1018
1069
  Back
@@ -1020,70 +1071,62 @@ export default function VisualliCanvas(props: VisualliCanvasProps) {
1020
1071
  </div>
1021
1072
  )}
1022
1073
 
1023
- {/* Navigation stack (left, matches reference) */}
1024
- <NavigationStack stack={navStack} onNavigateBack={handleNavigateBack} isDark={isDark} top={navigationStackTop} left={navigationStackLeft} />
1025
-
1026
- {/* Zoom controls (top-right) */}
1027
- <div data-help="zoom-controls" style={{ position: 'absolute', top: '24px', right: '16px', zIndex: 50, pointerEvents: 'auto' }}>
1028
- <ZoomControls isDark={isDark} />
1074
+ {/* Depth trail (top-left) */}
1075
+ <NavigationStack stack={navStack} onNavigateBack={handleNavigateBack} top={navigationStackTop} left={navigationStackLeft} />
1076
+
1077
+ {/* Canvas controls: the design system's placement (top-right); 'bottom-right' only on request */}
1078
+ <div
1079
+ className="vi-map__ctrls"
1080
+ style={props.controlsPosition === 'bottom-right'
1081
+ ? { top: 'auto', bottom: pinnedNode && touchMode ? `calc(var(--space-4) + ${sheetHeight}px)` : 'var(--space-4)' }
1082
+ : undefined}
1083
+ >
1084
+ <ZoomControls onFit={fitToScreen} touch={touchMode} />
1029
1085
  </div>
1030
1086
 
1031
- {/* Custom overlay from consuming app (for private features like help button, chromatic bg, etc.) */}
1032
- {renderOverlay?.({ isDark, containerWidth: canvasSizeRef.current.width || 0, containerHeight: canvasSizeRef.current.height || 0 })}
1033
-
1034
- {/* Node description tooltip (sketchy-box, above node) - rendered before extensions so extensions appear on top */}
1035
- {hoveredNode && hoveredNodePosition && viewport.zoomLevel < 3 && viewport.zoomLevel >= TEXT_LABEL_HIDE_BELOW_ZOOM && (() => {
1036
- const nodeData = nodes.get(hoveredNode.nodeId);
1037
- const borderColor = nodeData?.color ? darkenHexColor(nodeData.color, 0.13) : '#5BA8D4';
1038
- const VP_PAD = 8;
1039
- const HALF_W = 160;
1040
- const APPROX_H = 90;
1041
- const containerW = canvasSizeRef.current.width || 800;
1042
- const safeLeft = Math.max(HALF_W + VP_PAD, Math.min(hoveredNodePosition.screenX, containerW - HALF_W - VP_PAD));
1043
- const rawTop = hoveredNodePosition.screenY - 60 * hoveredNodePosition.zoom;
1044
- const safeTop = Math.max(VP_PAD + APPROX_H, rawTop);
1087
+ {/* Custom overlay from the consuming app */}
1088
+ {renderOverlay?.({ isDark, theme: design.theme, containerWidth: canvasSizeRef.current.width || 0, containerHeight: canvasSizeRef.current.height || 0 })}
1089
+
1090
+ {/* Touch: the peek is the design system's bottom sheet, pinned until dismissed */}
1091
+ {touchMode && pinnedNode && (
1092
+ <PeekSheet
1093
+ node={pinnedNode}
1094
+ anchors={anchors}
1095
+ height={sheetHeight}
1096
+ expanded={sheetExpanded}
1097
+ onToggleExpanded={() => setSheetExpanded((v) => !v)}
1098
+ onClose={() => setPinnedId(null)}
1099
+ onStepInside={pinnedHasChild ? () => { setPinnedId(null); handleNavigate(pinnedNode.id); } : undefined}
1100
+ renderContent={renderNodeContent ? (summary) => renderNodeContent({ summary, nodeId: pinnedNode.id, nodeColor: pinnedNode.color, zoom: viewport.zoomLevel }) : undefined}
1101
+ />
1102
+ )}
1103
+
1104
+ {/* Pointer: the peek floats over the hovered idea — the design system's card */}
1105
+ {!touchMode && hoveredNode && hoveredNodePosition && peekNode && (() => {
1106
+ // Beside the idea — right of it if it fits, else left — level with it (the design system's peekPosition).
1107
+ const rings = ringsFor(peekNode.branchCount);
1108
+ const half = nodeRadii(peekNode).rx * (rings.length ? rings[rings.length - 1]!.scale : 1) * hoveredNodePosition.zoom;
1109
+ const at = peekPosition({ x: hoveredNodePosition.screenX, y: hoveredNodePosition.screenY, half }, { w: canvasSizeRef.current.width || 800, h: canvasSizeRef.current.height || 600 });
1045
1110
  return (
1046
1111
  <div
1047
- data-node-tooltip="true"
1048
- style={{
1049
- position: 'absolute',
1050
- zIndex: 50,
1051
- left: safeLeft,
1052
- top: safeTop,
1053
- transform: 'translate(-50%, -100%)',
1054
- transformOrigin: 'bottom center',
1055
- pointerEvents: 'auto',
1056
- }}
1112
+ className="vi-map__fact"
1113
+ style={{ left: at.left, top: at.top, pointerEvents: 'auto', zIndex: 'var(--z-card)' as unknown as number }}
1057
1114
  onMouseEnter={() => { tooltipHoverRef.current = true; if (clearTooltipTimerRef.current) { clearTimeout(clearTooltipTimerRef.current); clearTooltipTimerRef.current = null; } }}
1058
- onMouseLeave={() => {
1059
- tooltipHoverRef.current = false;
1060
- // Delay clearing to allow external extensions to request keeping tooltip open
1115
+ onMouseLeave={() => {
1116
+ tooltipHoverRef.current = false;
1117
+ // Delay clearing to allow external extensions to request keeping the peek open
1061
1118
  if (clearTooltipTimerRef.current) clearTimeout(clearTooltipTimerRef.current);
1062
1119
  clearTooltipTimerRef.current = setTimeout(() => {
1063
1120
  if (!keepTooltipOpenRef.current) setHoveredNode(null);
1064
- }, 100);
1121
+ }, PEEK.hoverClose);
1065
1122
  }}
1066
1123
  >
1067
- <div style={{ maxWidth: '20rem', minWidth: '12rem', maxHeight: '15rem', overflowY: 'auto' }}>
1068
- <SketchyBoxKonva fill="#ffffff" stroke={borderColor} backStroke={borderColor} padding="1rem 1.25rem">
1069
- <p style={{
1070
- fontFamily: "'Playpen Sans', cursive",
1071
- fontSize: `${DESCRIPTION_TEXT_BASE_FONT_PX}px`,
1072
- fontWeight: 300,
1073
- color: '#000000',
1074
- textAlign: 'center',
1075
- lineHeight: 1.6,
1076
- margin: 0,
1077
- }}>
1078
- {renderNodeContent ? renderNodeContent({
1079
- summary: hoveredNode.summary,
1080
- nodeId: hoveredNode.nodeId,
1081
- nodeColor: nodeData?.color,
1082
- zoom: viewport.zoomLevel,
1083
- }) : hoveredNode.summary}
1084
- </p>
1085
- </SketchyBoxKonva>
1086
- </div>
1124
+ <PeekCard
1125
+ node={peekNode}
1126
+ anchors={anchors}
1127
+ onStepInside={peekHasChild ? () => stepInside(peekNode.id) : undefined}
1128
+ renderContent={renderNodeContent ? (summary) => renderNodeContent({ summary, nodeId: peekNode.id, nodeColor: peekNode.color, zoom: viewport.zoomLevel }) : undefined}
1129
+ />
1087
1130
  </div>
1088
1131
  );
1089
1132
  })()}