@visualli/react 0.1.6 → 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.
- package/README.md +13 -5
- package/dist/index.cjs +1900 -2032
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +355 -97
- package/dist/index.d.ts +355 -97
- package/dist/index.js +2005 -2094
- package/dist/index.js.map +1 -1
- package/fonts/Caveat-Variable.ttf +0 -0
- package/licenses/visualli-design-system/LICENSE +9 -0
- package/licenses/visualli-design-system/NOTICE.md +6 -0
- package/package.json +9 -4
- package/src/VisualliCanvas.tsx +453 -408
- package/src/VisualliRenderer.tsx +90 -112
- package/src/components/A11yLayer.tsx +73 -0
- package/src/components/KonvaContainer.tsx +28 -237
- package/src/components/KonvaContainerLabelLayer.tsx +41 -0
- package/src/components/KonvaContainerLayer.tsx +34 -33
- package/src/components/KonvaEdge.tsx +25 -240
- package/src/components/KonvaEdgeLayer.tsx +69 -49
- package/src/components/KonvaNode.tsx +21 -156
- package/src/components/KonvaNodeLayer.tsx +102 -34
- package/src/components/KonvaStage.tsx +17 -3
- package/src/components/NavigationStack.tsx +16 -159
- package/src/components/Overlays.tsx +327 -0
- package/src/components/ZoomControls.tsx +3 -107
- package/src/components/useFrames.ts +30 -0
- package/src/context/VisualliContext.tsx +3 -3
- package/src/design/choreography.ts +157 -0
- package/src/design/containers.ts +150 -0
- package/src/design/design.ts +115 -0
- package/src/design/drawing.ts +303 -0
- package/src/design/measure.ts +34 -0
- package/src/design/runtime.ts +154 -0
- package/src/design/useDesign.ts +38 -0
- package/src/generated/specCss.ts +15 -0
- package/src/hooks/index.ts +0 -2
- package/src/hooks/useKonvaRenderer.ts +13 -18
- package/src/hooks/useLayerChoreography.ts +159 -0
- package/src/hooks/useViewportNodes.ts +0 -1
- package/src/index.ts +22 -22
- package/src/stores/stores.ts +2 -2
- package/src/stores/useViewportStore.ts +12 -2
- package/src/animations/index.ts +0 -2
- package/src/animations/konvaLayerTransition.ts +0 -223
- package/src/components/SketchyBoxKonva.tsx +0 -121
- package/src/config/blobShapes.ts +0 -204
- package/src/config/index.ts +0 -21
- package/src/config/textScaling.ts +0 -69
- package/src/hooks/useKonvaLayerTransition.ts +0 -221
package/src/VisualliCanvas.tsx
CHANGED
|
@@ -1,36 +1,37 @@
|
|
|
1
1
|
// ─── VisualliCanvas ───────────────────────────────────────────────────────────
|
|
2
2
|
//
|
|
3
|
-
// Main canvas component for rendering a VisualliDocument
|
|
4
|
-
//
|
|
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
|
-
// •
|
|
7
|
-
// •
|
|
8
|
-
// •
|
|
9
|
-
// •
|
|
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 (
|
|
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
|
|
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
|
-
|
|
27
|
-
|
|
21
|
+
getSemanticAnchors,
|
|
22
|
+
ideaColor,
|
|
23
|
+
motionVars,
|
|
24
|
+
nodeRadii,
|
|
25
|
+
ringsFor,
|
|
26
|
+
peekPosition,
|
|
27
|
+
TRAIL,
|
|
28
28
|
RBushSpatialIndex,
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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 {
|
|
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
|
|
49
|
-
import
|
|
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 './
|
|
54
|
-
import {
|
|
55
|
-
import {
|
|
56
|
-
|
|
57
|
-
|
|
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,13 +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;
|
|
163
|
+
onNodeHover?: (nodeId: string | null) => void;
|
|
133
164
|
|
|
134
165
|
// Extension points for private features (implement in consuming app)
|
|
135
|
-
renderOverlay?: (params: { isDark: boolean; containerWidth: number; containerHeight: number }) => React.ReactNode;
|
|
166
|
+
renderOverlay?: (params: { isDark: boolean; theme: string; containerWidth: number; containerHeight: number }) => React.ReactNode;
|
|
136
167
|
renderNodeContent?: (params: {
|
|
137
168
|
summary: string;
|
|
138
169
|
nodeId: string;
|
|
@@ -149,7 +180,17 @@ export interface VisualliCanvasProps {
|
|
|
149
180
|
// ── Component ─────────────────────────────────────────────────────────────────
|
|
150
181
|
|
|
151
182
|
export default function VisualliCanvas(props: VisualliCanvasProps) {
|
|
152
|
-
const {
|
|
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]);
|
|
153
194
|
|
|
154
195
|
// Read file if provided (handles both File objects and string paths)
|
|
155
196
|
const [fileText, setFileText] = useState<string | undefined>(undefined);
|
|
@@ -189,6 +230,8 @@ export default function VisualliCanvas(props: VisualliCanvasProps) {
|
|
|
189
230
|
const [navStack, setNavStack] = useState<NavStackEntry[]>([]);
|
|
190
231
|
const [currentLayerId, setCurrentLayerId] = useState<string | null>(null);
|
|
191
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);
|
|
192
235
|
const [isTransitioning, setIsTransitioning] = useState(false);
|
|
193
236
|
const isTransitioningRef = useRef(false);
|
|
194
237
|
|
|
@@ -198,21 +241,25 @@ export default function VisualliCanvas(props: VisualliCanvasProps) {
|
|
|
198
241
|
if (!rootId) return;
|
|
199
242
|
const rootLayer = doc.layers.get(rootId)!;
|
|
200
243
|
setCurrentLayerId(rootId);
|
|
201
|
-
|
|
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 } }]);
|
|
202
246
|
parentViewports.current = [];
|
|
203
247
|
}, [doc]);
|
|
204
248
|
|
|
205
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
|
|
206
253
|
const { flatNodes, connections, containers } = useMemo(() => {
|
|
207
254
|
if (!doc || !currentLayerId) return { flatNodes: [] as FlatNode[], connections: [] as MindMapConnection[], containers: [] as ContainerGroup[] };
|
|
208
255
|
try {
|
|
209
256
|
return {
|
|
210
|
-
flatNodes: getNodesForLayer(doc, currentLayerId),
|
|
257
|
+
flatNodes: getNodesForLayer(doc, currentLayerId, sizing),
|
|
211
258
|
connections: getConnectionsForLayer(doc, currentLayerId),
|
|
212
259
|
containers: getContainersForLayer(doc, currentLayerId) as ContainerGroup[],
|
|
213
260
|
};
|
|
214
261
|
} catch { return { flatNodes: [] as FlatNode[], connections: [] as MindMapConnection[], containers: [] as ContainerGroup[] }; }
|
|
215
|
-
}, [doc, currentLayerId]);
|
|
262
|
+
}, [doc, currentLayerId, sizing]);
|
|
216
263
|
const viewport = useViewportStore(s => ({
|
|
217
264
|
centerX: s.centerX, centerY: s.centerY, zoomLevel: s.zoomLevel,
|
|
218
265
|
canvasWidth: s.canvasWidth, canvasHeight: s.canvasHeight,
|
|
@@ -234,18 +281,28 @@ export default function VisualliCanvas(props: VisualliCanvasProps) {
|
|
|
234
281
|
clearSel();
|
|
235
282
|
}, [flatNodes, setNodes, clearSel]);
|
|
236
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
|
+
|
|
237
301
|
// ── Canvas refs ───────────────────────────────────────────────────────────
|
|
238
302
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
|
239
303
|
const stageRef = useRef<Konva.Stage | null>(null);
|
|
240
304
|
const canvasWrapperRef = useRef<HTMLDivElement>(null);
|
|
241
305
|
|
|
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
306
|
// canvasSizeRef is kept in sync with the measured container size (not window).
|
|
250
307
|
// Initialise to 0 — it will be updated synchronously before the first frame.
|
|
251
308
|
const canvasSizeRef = useRef({ width: 0, height: 0 });
|
|
@@ -255,34 +312,13 @@ export default function VisualliCanvas(props: VisualliCanvasProps) {
|
|
|
255
312
|
const dragMoveLatestRef = useRef<{ nodeId: string; x: number; y: number } | null>(null);
|
|
256
313
|
const dragStartPosRef = useRef<Map<string, { x: number; y: number }>>(new Map());
|
|
257
314
|
|
|
258
|
-
// ──
|
|
259
|
-
const
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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;
|
|
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]);
|
|
286
322
|
|
|
287
323
|
// ── Spatial index (for stage-level hit detection) ─────────────────────────
|
|
288
324
|
const spatialIndexRef = useRef<RBushSpatialIndex | null>(null);
|
|
@@ -290,14 +326,8 @@ export default function VisualliCanvas(props: VisualliCanvasProps) {
|
|
|
290
326
|
if (flatNodes.length === 0) { spatialIndexRef.current?.clear(); return; }
|
|
291
327
|
const idx = new RBushSpatialIndex();
|
|
292
328
|
idx.bulkLoad(flatNodes.map(n => {
|
|
293
|
-
const
|
|
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
|
-
};
|
|
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 } };
|
|
301
331
|
}));
|
|
302
332
|
spatialIndexRef.current = idx;
|
|
303
333
|
return () => { spatialIndexRef.current?.clear(); };
|
|
@@ -309,6 +339,7 @@ export default function VisualliCanvas(props: VisualliCanvasProps) {
|
|
|
309
339
|
handleMouseDown: rendererHandleMouseDown,
|
|
310
340
|
handleMouseMove: rendererHandleMouseMove,
|
|
311
341
|
handleMouseUp: rendererHandleMouseUp,
|
|
342
|
+
cancelPan: rendererCancelPan,
|
|
312
343
|
canvasWidth,
|
|
313
344
|
canvasHeight,
|
|
314
345
|
} = useKonvaRenderer({ containerRef, stageRef });
|
|
@@ -351,16 +382,16 @@ export default function VisualliCanvas(props: VisualliCanvasProps) {
|
|
|
351
382
|
if (node) {
|
|
352
383
|
const oldPos = dragStartPosRef.current.get(nodeId);
|
|
353
384
|
if (oldPos) {
|
|
354
|
-
const
|
|
385
|
+
const { rx, ry } = nodeRadii(node);
|
|
355
386
|
spatialIndexRef.current.remove(nodeId, {
|
|
356
|
-
minX: oldPos.x -
|
|
357
|
-
maxX: oldPos.x +
|
|
387
|
+
minX: oldPos.x - rx, minY: oldPos.y - ry,
|
|
388
|
+
maxX: oldPos.x + rx, maxY: oldPos.y + ry,
|
|
358
389
|
});
|
|
359
390
|
}
|
|
360
|
-
const
|
|
391
|
+
const { rx, ry } = nodeRadii(node);
|
|
361
392
|
spatialIndexRef.current.insert(nodeId, {
|
|
362
|
-
minX: x -
|
|
363
|
-
maxX: x +
|
|
393
|
+
minX: x - rx, minY: y - ry,
|
|
394
|
+
maxX: x + rx, maxY: y + ry,
|
|
364
395
|
});
|
|
365
396
|
}
|
|
366
397
|
}
|
|
@@ -377,17 +408,13 @@ export default function VisualliCanvas(props: VisualliCanvasProps) {
|
|
|
377
408
|
// relative to the renderer's actual bounding box.
|
|
378
409
|
const cw = canvasSizeRef.current.width || containerRef.current?.getBoundingClientRect().width || 800;
|
|
379
410
|
const ch = canvasSizeRef.current.height || containerRef.current?.getBoundingClientRect().height || 600;
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
//
|
|
383
|
-
|
|
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;
|
|
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);
|
|
388
415
|
setCenter(centerX, centerY);
|
|
389
416
|
setZoom(zoomLevel);
|
|
390
|
-
}, [flatNodes,
|
|
417
|
+
}, [flatNodes, containers, setCenter, setZoom]);
|
|
391
418
|
|
|
392
419
|
// Keep ref in sync after every render so async callbacks always use latest closure
|
|
393
420
|
fitToScreenRef.current = fitToScreen;
|
|
@@ -408,14 +435,22 @@ export default function VisualliCanvas(props: VisualliCanvasProps) {
|
|
|
408
435
|
// Stores the target nav-back index so onSwapBack can read it
|
|
409
436
|
const navBackTargetRef = useRef<number>(-1);
|
|
410
437
|
|
|
411
|
-
const {
|
|
438
|
+
const { stepInside: diveInto, backOut, arrive, isTransitioning: isAnimating } = useLayerChoreography({
|
|
412
439
|
stageRef,
|
|
413
440
|
canvasWrapperRef,
|
|
441
|
+
reducedMotion: design.comfort.reducedMotion || props.reveal === 'instant',
|
|
414
442
|
onSwapLayer: (childLayerId) => {
|
|
415
443
|
const childLayer = doc?.layers.get(childLayerId);
|
|
416
444
|
if (!childLayer || !doc) return;
|
|
417
445
|
setCurrentLayerId(childLayerId);
|
|
418
|
-
|
|
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
|
+
}]);
|
|
419
454
|
onLayerChange?.(childLayerId, childLayer);
|
|
420
455
|
},
|
|
421
456
|
onSwapBack: () => {
|
|
@@ -433,88 +468,8 @@ export default function VisualliCanvas(props: VisualliCanvasProps) {
|
|
|
433
468
|
setIsTransitioning(false);
|
|
434
469
|
isTransitioningRef.current = false;
|
|
435
470
|
// 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
471
|
requestAnimationFrame(() => { fitToScreenRef.current(); });
|
|
438
472
|
},
|
|
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
473
|
});
|
|
519
474
|
|
|
520
475
|
const handleNavigate = useCallback((nodeId: string) => {
|
|
@@ -533,7 +488,7 @@ export default function VisualliCanvas(props: VisualliCanvasProps) {
|
|
|
533
488
|
if (!childLayerId) {
|
|
534
489
|
return;
|
|
535
490
|
}
|
|
536
|
-
const childNodes = (() => { try { return getNodesForLayer(doc, childLayerId); } catch { return []; } })();
|
|
491
|
+
const childNodes = (() => { try { return getNodesForLayer(doc, childLayerId, sizing); } catch { return []; } })();
|
|
537
492
|
if (childNodes.length === 0) {
|
|
538
493
|
return;
|
|
539
494
|
}
|
|
@@ -546,19 +501,15 @@ export default function VisualliCanvas(props: VisualliCanvasProps) {
|
|
|
546
501
|
// rather than the arbitrary zoom that happened to trigger the navigation.
|
|
547
502
|
const cw = canvasSizeRef.current.width || 800;
|
|
548
503
|
const ch = canvasSizeRef.current.height || 600;
|
|
549
|
-
const fitVp = calculateFitView(flatNodes, cw, ch);
|
|
550
|
-
|
|
551
|
-
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 });
|
|
552
506
|
|
|
507
|
+
stepTargetRef.current = { paint: { topic: node.topic, custom: node.custom }, label: node.title };
|
|
553
508
|
setIsTransitioning(true);
|
|
554
509
|
isTransitioningRef.current = true;
|
|
555
510
|
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
node.color ?? getThemeBackground(isDark, 'secondary'),
|
|
559
|
-
node.color ?? getThemeBackground(isDark, 'primary'),
|
|
560
|
-
);
|
|
561
|
-
}, [doc, currentLayerId, nodes, isDark, isAnimating, zoomIntoLayer]);
|
|
511
|
+
diveInto(node, childLayerId, childNodes);
|
|
512
|
+
}, [doc, currentLayerId, nodes, isAnimating, diveInto, sizing, flatNodes, containers]);
|
|
562
513
|
|
|
563
514
|
const handleNavigateBack = useCallback((targetIndex: number) => {
|
|
564
515
|
if (targetIndex >= navStack.length - 1) return;
|
|
@@ -568,47 +519,8 @@ export default function VisualliCanvas(props: VisualliCanvasProps) {
|
|
|
568
519
|
navBackTargetRef.current = targetIndex;
|
|
569
520
|
setIsTransitioning(true);
|
|
570
521
|
isTransitioningRef.current = true;
|
|
571
|
-
|
|
572
|
-
}, [navStack.length,
|
|
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
|
|
522
|
+
backOut(savedVp);
|
|
523
|
+
}, [navStack.length, isAnimating, backOut]);
|
|
612
524
|
|
|
613
525
|
// ── Stage-level hit detection ─────────────────────────────────────────────
|
|
614
526
|
const toWorldCoords = useCallback((stage: Konva.Stage, pointer: { x: number; y: number }) => {
|
|
@@ -679,9 +591,55 @@ export default function VisualliCanvas(props: VisualliCanvasProps) {
|
|
|
679
591
|
return () => { window.removeEventListener('pointerdown', close); window.removeEventListener('keydown', onKey); };
|
|
680
592
|
}, [canvasContextMenu]);
|
|
681
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
|
+
|
|
682
635
|
// ── Stage event handlers ──────────────────────────────────────────────────
|
|
683
636
|
const handleStageMouseDown = useCallback((e: Konva.KonvaEventObject<MouseEvent>) => {
|
|
684
|
-
if (e.evt
|
|
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; }
|
|
685
643
|
const stage = e.target.getStage();
|
|
686
644
|
if (!stage) { rendererHandleMouseDown(e); return; }
|
|
687
645
|
const pointer = stage.getPointerPosition();
|
|
@@ -694,7 +652,7 @@ export default function VisualliCanvas(props: VisualliCanvasProps) {
|
|
|
694
652
|
select(nodeId);
|
|
695
653
|
stageActiveNodeIdRef.current = nodeId;
|
|
696
654
|
stageDragNodeOffsetRef.current = { x: worldX - node.x, y: worldY - node.y };
|
|
697
|
-
stageMouseDownPosRef.current =
|
|
655
|
+
stageMouseDownPosRef.current = clientOf(e.evt);
|
|
698
656
|
stageDragCommittedRef.current = false;
|
|
699
657
|
setPressedNodeId(nodeId);
|
|
700
658
|
setHoveredNode(null);
|
|
@@ -702,11 +660,13 @@ export default function VisualliCanvas(props: VisualliCanvasProps) {
|
|
|
702
660
|
}
|
|
703
661
|
}
|
|
704
662
|
setHoveredNode(null);
|
|
663
|
+
emptyTapRef.current = clientOf(e.evt);
|
|
705
664
|
isCanvasPanningRef.current = true;
|
|
706
665
|
rendererHandleMouseDown(e);
|
|
707
|
-
}, [hitTestNode, toWorldCoords, nodes, select, rendererHandleMouseDown]);
|
|
666
|
+
}, [hitTestNode, toWorldCoords, nodes, select, rendererHandleMouseDown, props.layout]);
|
|
708
667
|
|
|
709
668
|
const handleStageMouseMove = useCallback((e: Konva.KonvaEventObject<MouseEvent>) => {
|
|
669
|
+
if (isEmulatedMouse(e.evt)) return;
|
|
710
670
|
const stage = e.target.getStage();
|
|
711
671
|
if (!stage) { rendererHandleMouseMove(e); return; }
|
|
712
672
|
const pointer = stage.getPointerPosition();
|
|
@@ -714,7 +674,8 @@ export default function VisualliCanvas(props: VisualliCanvasProps) {
|
|
|
714
674
|
const { worldX, worldY } = toWorldCoords(stage, pointer);
|
|
715
675
|
|
|
716
676
|
if (stageActiveNodeIdRef.current && stageMouseDownPosRef.current) {
|
|
717
|
-
const
|
|
677
|
+
const cur = clientOf(e.evt);
|
|
678
|
+
const moved = Math.hypot(cur.x - stageMouseDownPosRef.current.x, cur.y - stageMouseDownPosRef.current.y);
|
|
718
679
|
if (!stageDragCommittedRef.current && moved >= 5) {
|
|
719
680
|
stageDragCommittedRef.current = true;
|
|
720
681
|
isDraggingRef.current = true;
|
|
@@ -726,7 +687,6 @@ export default function VisualliCanvas(props: VisualliCanvasProps) {
|
|
|
726
687
|
if (stageDragCommittedRef.current) {
|
|
727
688
|
const offset = stageDragNodeOffsetRef.current;
|
|
728
689
|
handleDragMove(stageActiveNodeIdRef.current, worldX - offset.x, worldY - offset.y);
|
|
729
|
-
stage.container().style.cursor = 'grabbing';
|
|
730
690
|
return;
|
|
731
691
|
}
|
|
732
692
|
return;
|
|
@@ -746,18 +706,17 @@ export default function VisualliCanvas(props: VisualliCanvasProps) {
|
|
|
746
706
|
if (hovered !== pointerNodeIdRef.current) {
|
|
747
707
|
pointerNodeIdRef.current = hovered;
|
|
748
708
|
setPointerNodeId(hovered);
|
|
709
|
+
onNodeHoverRef.current?.(hovered);
|
|
749
710
|
}
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
} else {
|
|
753
|
-
stage.container().style.cursor = 'grab';
|
|
754
|
-
}
|
|
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' : '';
|
|
755
713
|
}
|
|
756
714
|
|
|
757
715
|
rendererHandleMouseMove(e);
|
|
758
716
|
}, [toWorldCoords, hitTestNode, handleDragStart, handleDragMove, rendererHandleMouseMove]);
|
|
759
717
|
|
|
760
718
|
const handleStageMouseUp = useCallback((e: Konva.KonvaEventObject<MouseEvent>) => {
|
|
719
|
+
if (isEmulatedMouse(e.evt)) return;
|
|
761
720
|
setPressedNodeId(null);
|
|
762
721
|
if (stageActiveNodeIdRef.current) {
|
|
763
722
|
const nodeId = stageActiveNodeIdRef.current;
|
|
@@ -777,7 +736,14 @@ export default function VisualliCanvas(props: VisualliCanvasProps) {
|
|
|
777
736
|
const node = nodes.get(nodeId);
|
|
778
737
|
if (node) {
|
|
779
738
|
const childLayer = doc ? getChildLayerForNode(doc, nodeId, currentLayerId ?? '') : null;
|
|
780
|
-
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) {
|
|
781
747
|
handleNavigate(nodeId);
|
|
782
748
|
}
|
|
783
749
|
onNodeClick?.(node);
|
|
@@ -790,9 +756,16 @@ export default function VisualliCanvas(props: VisualliCanvasProps) {
|
|
|
790
756
|
stageDragCommittedRef.current = false;
|
|
791
757
|
return;
|
|
792
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
|
+
}
|
|
793
766
|
isCanvasPanningRef.current = false;
|
|
794
767
|
rendererHandleMouseUp(e);
|
|
795
|
-
}, [toWorldCoords, handleDragEnd, nodes, handleNavigate, onNodeClick, rendererHandleMouseUp]);
|
|
768
|
+
}, [toWorldCoords, handleDragEnd, nodes, handleNavigate, onNodeClick, rendererHandleMouseUp]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
796
769
|
|
|
797
770
|
const handleStageContextMenu = useCallback((e: Konva.KonvaEventObject<MouseEvent>) => {
|
|
798
771
|
e.evt.preventDefault();
|
|
@@ -803,41 +776,81 @@ export default function VisualliCanvas(props: VisualliCanvasProps) {
|
|
|
803
776
|
setCanvasContextMenu({ x: localX, y: localY });
|
|
804
777
|
}, [navStack.length]);
|
|
805
778
|
|
|
806
|
-
//
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
const
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
const
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
const
|
|
820
|
-
const
|
|
821
|
-
|
|
822
|
-
//
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
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);
|
|
834
827
|
}
|
|
835
|
-
|
|
836
|
-
|
|
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]);
|
|
837
849
|
|
|
838
850
|
// Canvas mouse move for description tooltip
|
|
839
851
|
const lastHoverCheckRef = useRef(0);
|
|
840
852
|
const handleCanvasMouseMove = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
|
853
|
+
if (touchModeRef.current || isEmulatedMouse(e.nativeEvent)) return; // no hover peek on touch
|
|
841
854
|
if (!containerRef.current || isDraggingRef.current || isCanvasPanningRef.current) {
|
|
842
855
|
setHoveredNode(null);
|
|
843
856
|
return;
|
|
@@ -882,7 +895,7 @@ export default function VisualliCanvas(props: VisualliCanvasProps) {
|
|
|
882
895
|
if (clearTooltipTimerRef.current) clearTimeout(clearTooltipTimerRef.current);
|
|
883
896
|
clearTooltipTimerRef.current = setTimeout(() => {
|
|
884
897
|
if (!tooltipHoverRef.current && !keepTooltipOpenRef.current) setHoveredNode(null);
|
|
885
|
-
},
|
|
898
|
+
}, PEEK.hoverClose); // the design system's peek grace (geometry/interaction.ts)
|
|
886
899
|
} else {
|
|
887
900
|
if (clearTooltipTimerRef.current) { clearTimeout(clearTooltipTimerRef.current); clearTooltipTimerRef.current = null; }
|
|
888
901
|
}
|
|
@@ -894,12 +907,11 @@ export default function VisualliCanvas(props: VisualliCanvasProps) {
|
|
|
894
907
|
keepTooltipOpenRef.current = event.detail.keep;
|
|
895
908
|
};
|
|
896
909
|
|
|
897
|
-
//
|
|
898
|
-
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);
|
|
899
912
|
|
|
900
913
|
return () => {
|
|
901
|
-
|
|
902
|
-
window.removeEventListener('keepNodeTooltipOpen', handleKeepTooltipOpen);
|
|
914
|
+
window.removeEventListener('keepNodeTooltipOpen', handleKeepTooltipOpen as EventListener);
|
|
903
915
|
};
|
|
904
916
|
}, []);
|
|
905
917
|
|
|
@@ -912,78 +924,126 @@ export default function VisualliCanvas(props: VisualliCanvasProps) {
|
|
|
912
924
|
};
|
|
913
925
|
}, []);
|
|
914
926
|
|
|
915
|
-
// ──
|
|
916
|
-
const
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
}, [
|
|
923
|
-
|
|
924
|
-
|
|
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]);
|
|
925
957
|
|
|
926
958
|
// ── Render ────────────────────────────────────────────────────────────────
|
|
927
959
|
if (!doc) {
|
|
928
960
|
return (
|
|
929
|
-
<div className={className} style={{
|
|
930
|
-
<span style={{ color:
|
|
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>
|
|
931
963
|
</div>
|
|
932
964
|
);
|
|
933
965
|
}
|
|
934
966
|
|
|
935
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 ?? ''));
|
|
936
975
|
|
|
937
976
|
return (
|
|
938
977
|
<div
|
|
939
978
|
ref={containerRef}
|
|
940
|
-
className={className}
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
}
|
|
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 }}
|
|
950
992
|
onMouseMove={handleCanvasMouseMove}
|
|
951
993
|
onMouseLeave={() => {
|
|
952
|
-
// Only close
|
|
994
|
+
// Only close the peek on mouse leave if not keeping it open
|
|
953
995
|
if (!tooltipHoverRef.current && !keepTooltipOpenRef.current) {
|
|
954
996
|
setHoveredNode(null);
|
|
955
997
|
}
|
|
956
998
|
}}
|
|
957
999
|
>
|
|
958
|
-
{/*
|
|
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. */}
|
|
959
1004
|
<div ref={canvasWrapperRef} style={{ position: 'relative', zIndex: 1, pointerEvents: 'auto' }}>
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
nodes={flatNodes}
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
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
|
+
)}
|
|
985
1035
|
</div>
|
|
986
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
|
+
|
|
987
1047
|
{/* Context menu (right-click to go back) */}
|
|
988
1048
|
{canvasContextMenu && hasBackOption && (
|
|
989
1049
|
<div
|
|
@@ -991,7 +1051,7 @@ export default function VisualliCanvas(props: VisualliCanvasProps) {
|
|
|
991
1051
|
aria-label="Canvas options"
|
|
992
1052
|
style={{
|
|
993
1053
|
position: 'absolute',
|
|
994
|
-
zIndex:
|
|
1054
|
+
zIndex: 'var(--z-menu)' as unknown as number,
|
|
995
1055
|
pointerEvents: 'auto',
|
|
996
1056
|
left: `${Math.min(canvasContextMenu.x, Math.max(8, (canvasSizeRef.current.width || 800) - 152))}px`,
|
|
997
1057
|
top: `${Math.min(canvasContextMenu.y, Math.max(8, (canvasSizeRef.current.height || 600) - 72))}px`,
|
|
@@ -1001,16 +1061,9 @@ export default function VisualliCanvas(props: VisualliCanvasProps) {
|
|
|
1001
1061
|
>
|
|
1002
1062
|
<button
|
|
1003
1063
|
type="button"
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
border: `1px solid ${isDark ? '#333330' : '#DDD9D0'}`,
|
|
1008
|
-
background: isDark ? '#1C1C1A' : '#FAF8F4',
|
|
1009
|
-
color: isDark ? '#F0EDE6' : '#1A1A18',
|
|
1010
|
-
cursor: 'pointer',
|
|
1011
|
-
fontSize: 13,
|
|
1012
|
-
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
|
|
1013
|
-
}}
|
|
1064
|
+
role="menuitem"
|
|
1065
|
+
className="vi-iconbtn vi-iconbtn--surface"
|
|
1066
|
+
style={{ width: 'auto', padding: '6px var(--space-4)', boxShadow: 'var(--shadow-pop)' }}
|
|
1014
1067
|
onClick={() => { setCanvasContextMenu(null); handleNavigateBack(navStack.length - 2); }}
|
|
1015
1068
|
>
|
|
1016
1069
|
Back
|
|
@@ -1018,70 +1071,62 @@ export default function VisualliCanvas(props: VisualliCanvasProps) {
|
|
|
1018
1071
|
</div>
|
|
1019
1072
|
)}
|
|
1020
1073
|
|
|
1021
|
-
{/*
|
|
1022
|
-
<NavigationStack stack={navStack} onNavigateBack={handleNavigateBack}
|
|
1023
|
-
|
|
1024
|
-
{/*
|
|
1025
|
-
<div
|
|
1026
|
-
|
|
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} />
|
|
1027
1085
|
</div>
|
|
1028
1086
|
|
|
1029
|
-
{/* Custom overlay from consuming app
|
|
1030
|
-
{renderOverlay?.({ isDark, containerWidth: canvasSizeRef.current.width || 0, containerHeight: canvasSizeRef.current.height || 0 })}
|
|
1031
|
-
|
|
1032
|
-
{/*
|
|
1033
|
-
{
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
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 });
|
|
1043
1110
|
return (
|
|
1044
1111
|
<div
|
|
1045
|
-
|
|
1046
|
-
style={{
|
|
1047
|
-
position: 'absolute',
|
|
1048
|
-
zIndex: 50,
|
|
1049
|
-
left: safeLeft,
|
|
1050
|
-
top: safeTop,
|
|
1051
|
-
transform: 'translate(-50%, -100%)',
|
|
1052
|
-
transformOrigin: 'bottom center',
|
|
1053
|
-
pointerEvents: 'auto',
|
|
1054
|
-
}}
|
|
1112
|
+
className="vi-map__fact"
|
|
1113
|
+
style={{ left: at.left, top: at.top, pointerEvents: 'auto', zIndex: 'var(--z-card)' as unknown as number }}
|
|
1055
1114
|
onMouseEnter={() => { tooltipHoverRef.current = true; if (clearTooltipTimerRef.current) { clearTimeout(clearTooltipTimerRef.current); clearTooltipTimerRef.current = null; } }}
|
|
1056
|
-
onMouseLeave={() => {
|
|
1057
|
-
tooltipHoverRef.current = false;
|
|
1058
|
-
// Delay clearing to allow external extensions to request keeping
|
|
1115
|
+
onMouseLeave={() => {
|
|
1116
|
+
tooltipHoverRef.current = false;
|
|
1117
|
+
// Delay clearing to allow external extensions to request keeping the peek open
|
|
1059
1118
|
if (clearTooltipTimerRef.current) clearTimeout(clearTooltipTimerRef.current);
|
|
1060
1119
|
clearTooltipTimerRef.current = setTimeout(() => {
|
|
1061
1120
|
if (!keepTooltipOpenRef.current) setHoveredNode(null);
|
|
1062
|
-
},
|
|
1121
|
+
}, PEEK.hoverClose);
|
|
1063
1122
|
}}
|
|
1064
1123
|
>
|
|
1065
|
-
<
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
color: '#000000',
|
|
1072
|
-
textAlign: 'center',
|
|
1073
|
-
lineHeight: 1.6,
|
|
1074
|
-
margin: 0,
|
|
1075
|
-
}}>
|
|
1076
|
-
{renderNodeContent ? renderNodeContent({
|
|
1077
|
-
summary: hoveredNode.summary,
|
|
1078
|
-
nodeId: hoveredNode.nodeId,
|
|
1079
|
-
nodeColor: nodeData?.color,
|
|
1080
|
-
zoom: viewport.zoomLevel,
|
|
1081
|
-
}) : hoveredNode.summary}
|
|
1082
|
-
</p>
|
|
1083
|
-
</SketchyBoxKonva>
|
|
1084
|
-
</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
|
+
/>
|
|
1085
1130
|
</div>
|
|
1086
1131
|
);
|
|
1087
1132
|
})()}
|