@visualli/react 0.1.7 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -5
- package/dist/index.cjs +1900 -2033
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +354 -97
- package/dist/index.d.ts +354 -97
- package/dist/index.js +2005 -2095
- 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 +452 -409
- 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,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 {
|
|
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
|
-
|
|
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
|
-
// ──
|
|
260
|
-
const
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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
|
|
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
|
|
385
|
+
const { rx, ry } = nodeRadii(node);
|
|
356
386
|
spatialIndexRef.current.remove(nodeId, {
|
|
357
|
-
minX: oldPos.x -
|
|
358
|
-
maxX: oldPos.x +
|
|
387
|
+
minX: oldPos.x - rx, minY: oldPos.y - ry,
|
|
388
|
+
maxX: oldPos.x + rx, maxY: oldPos.y + ry,
|
|
359
389
|
});
|
|
360
390
|
}
|
|
361
|
-
const
|
|
391
|
+
const { rx, ry } = nodeRadii(node);
|
|
362
392
|
spatialIndexRef.current.insert(nodeId, {
|
|
363
|
-
minX: x -
|
|
364
|
-
maxX: x +
|
|
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
|
-
|
|
382
|
-
|
|
383
|
-
//
|
|
384
|
-
|
|
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,
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
558
|
-
|
|
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
|
-
|
|
573
|
-
}, [navStack.length,
|
|
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
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
const
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
const
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
const
|
|
822
|
-
const
|
|
823
|
-
|
|
824
|
-
//
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
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
|
-
|
|
838
|
-
|
|
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
|
-
},
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
// ──
|
|
918
|
-
const
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
}, [
|
|
925
|
-
|
|
926
|
-
|
|
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={{
|
|
932
|
-
<span style={{ color:
|
|
961
|
+
<div className={`vi-map ${className}`} {...design.attrs} style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', ...style }}>
|
|
962
|
+
<span className="vi-text-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
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
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
|
|
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
|
-
{/*
|
|
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
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
nodes={flatNodes}
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
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:
|
|
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
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
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
|
-
{/*
|
|
1024
|
-
<NavigationStack stack={navStack} onNavigateBack={handleNavigateBack}
|
|
1025
|
-
|
|
1026
|
-
{/*
|
|
1027
|
-
<div
|
|
1028
|
-
|
|
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
|
|
1032
|
-
{renderOverlay?.({ isDark, containerWidth: canvasSizeRef.current.width || 0, containerHeight: canvasSizeRef.current.height || 0 })}
|
|
1033
|
-
|
|
1034
|
-
{/*
|
|
1035
|
-
{
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
},
|
|
1121
|
+
}, PEEK.hoverClose);
|
|
1065
1122
|
}}
|
|
1066
1123
|
>
|
|
1067
|
-
<
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
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
|
})()}
|