@visualli/react 0.1.7 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/VisualliRenderer.tsx
CHANGED
|
@@ -16,13 +16,20 @@
|
|
|
16
16
|
// • Delegates all canvas/navigation/zoom logic to VisualliCanvas
|
|
17
17
|
|
|
18
18
|
import React, { useEffect, useRef, useState, useMemo } from 'react';
|
|
19
|
-
import type { VisualliDocument,
|
|
20
|
-
import { parseVisualliFile } from '@visualli/core';
|
|
19
|
+
import type { VisualliDocument, Comfort, ThemeInput, TopicName } from '@visualli/core';
|
|
20
|
+
import { blobPath, parseVisualliFile, shapeForLevel } from '@visualli/core';
|
|
21
21
|
import VisualliCanvas from './VisualliCanvas';
|
|
22
|
+
import { useDesign } from './design/useDesign';
|
|
23
|
+
import { ensureDesignSystemStyles, type DesignSystemAssets } from './design/runtime';
|
|
22
24
|
|
|
23
25
|
// ── Types ─────────────────────────────────────────────────────────────────────
|
|
24
26
|
|
|
25
|
-
|
|
27
|
+
/**
|
|
28
|
+
* Any of the 8 design-system themes ('light', 'dark', 'focus-light', 'focus-dark', 'colorsafe-light',
|
|
29
|
+
* 'colorsafe-dark', 'contrast-light', 'contrast-dark'), a family ('focus' | 'colorsafe' | 'contrast'),
|
|
30
|
+
* or 'auto' (follow the reader's colour scheme). 'light' | 'dark' | 'auto' are the pre-0.2 values and keep working.
|
|
31
|
+
*/
|
|
32
|
+
export type VisualliTheme = ThemeInput;
|
|
26
33
|
|
|
27
34
|
export interface VisualliRendererProps {
|
|
28
35
|
/**
|
|
@@ -50,19 +57,40 @@ export interface VisualliRendererProps {
|
|
|
50
57
|
useWorker?: boolean;
|
|
51
58
|
|
|
52
59
|
/**
|
|
53
|
-
* Colour theme.
|
|
54
|
-
* - '
|
|
55
|
-
* - 'light' → always light background
|
|
56
|
-
* - 'auto' → follows the OS/browser prefers-color-scheme
|
|
60
|
+
* Colour theme (see VisualliTheme).
|
|
61
|
+
* - 'auto' follows prefers-color-scheme (and switches to high contrast under forced-colors)
|
|
57
62
|
* @default 'light'
|
|
58
63
|
*/
|
|
59
64
|
theme?: VisualliTheme;
|
|
60
65
|
|
|
66
|
+
/** Comfort settings: readable type, larger text, reduced motion ('system' follows the OS). */
|
|
67
|
+
comfort?: Comfort;
|
|
68
|
+
|
|
69
|
+
/** Switch to the high-contrast theme under forced-colors. @default true */
|
|
70
|
+
respectForcedColors?: boolean;
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Interaction layout: 'auto' follows the reader's input (touch -> bottom-sheet peek and larger
|
|
74
|
+
* controls, pointer -> floating peek on hover); force one with 'touch' or 'pointer'.
|
|
75
|
+
* @default 'auto'
|
|
76
|
+
*/
|
|
77
|
+
layout?: 'auto' | 'touch' | 'pointer';
|
|
78
|
+
|
|
79
|
+
/** Where the zoom / fit controls sit: the design system's 'bottom-right' (default) or the pre-0.2 'top-right'. */
|
|
80
|
+
/**
|
|
81
|
+
* The map is the page (an app such as Visualli's web app): it takes every touch gesture, so the browser never
|
|
82
|
+
* zooms or scrolls the page — the design system's `.vi-map.is-app` (`touch-action: none`). Leave it off when the
|
|
83
|
+
* map is embedded in a scrolling page: one finger then still scrolls the page, two fingers pinch the map.
|
|
84
|
+
*/
|
|
85
|
+
app?: boolean;
|
|
86
|
+
/** Where the zoom / fit controls sit: 'top-right' (default, the design system's placement) or 'bottom-right'. */
|
|
87
|
+
controlsPosition?: 'top-right' | 'bottom-right';
|
|
88
|
+
|
|
61
89
|
/**
|
|
62
90
|
* Enable chromatic immersion background effect.
|
|
63
91
|
* When true:
|
|
64
|
-
* - Root layer (level 0) shows
|
|
65
|
-
* - Child layers
|
|
92
|
+
* - Root layer (level 0) shows the plain canvas
|
|
93
|
+
* - Child layers are tinted with the parent idea's topic colour
|
|
66
94
|
* @default false
|
|
67
95
|
*/
|
|
68
96
|
chromaticImmersion?: boolean;
|
|
@@ -81,6 +109,12 @@ export interface VisualliRendererProps {
|
|
|
81
109
|
*/
|
|
82
110
|
height?: string | number;
|
|
83
111
|
|
|
112
|
+
/** Directory URL the bundled Caveat font is served from (defaults to the copy in the npm package, via jsDelivr). */
|
|
113
|
+
fontBaseUrl?: DesignSystemAssets['fontBaseUrl'];
|
|
114
|
+
|
|
115
|
+
/** Set false when you load Kalam and Atkinson Hyperlegible yourself. */
|
|
116
|
+
loadWebFonts?: DesignSystemAssets['loadWebFonts'];
|
|
117
|
+
|
|
84
118
|
/** Additional CSS class on the root element. */
|
|
85
119
|
className?: string;
|
|
86
120
|
|
|
@@ -88,116 +122,42 @@ export interface VisualliRendererProps {
|
|
|
88
122
|
style?: React.CSSProperties;
|
|
89
123
|
}
|
|
90
124
|
|
|
91
|
-
// ── System dark-mode hook ─────────────────────────────────────────────────────
|
|
92
|
-
|
|
93
|
-
function useSystemDark(): boolean {
|
|
94
|
-
const mq = typeof window !== 'undefined'
|
|
95
|
-
? window.matchMedia('(prefers-color-scheme: dark)')
|
|
96
|
-
: null;
|
|
97
|
-
|
|
98
|
-
const [dark, setDark] = useState<boolean>(mq?.matches ?? false);
|
|
99
|
-
|
|
100
|
-
useEffect(() => {
|
|
101
|
-
if (!mq) return;
|
|
102
|
-
const handler = (e: MediaQueryListEvent) => setDark(e.matches);
|
|
103
|
-
mq.addEventListener('change', handler);
|
|
104
|
-
return () => mq.removeEventListener('change', handler);
|
|
105
|
-
}, [mq]);
|
|
106
|
-
|
|
107
|
-
return dark;
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
// ── Font Injection Hook ───────────────────────────────────────────────────────
|
|
111
|
-
|
|
112
|
-
function useFontInjection() {
|
|
113
|
-
useEffect(() => {
|
|
114
|
-
if (typeof document === 'undefined') return;
|
|
115
|
-
|
|
116
|
-
const links = [
|
|
117
|
-
'https://fonts.googleapis.com',
|
|
118
|
-
'https://fonts.gstatic.com',
|
|
119
|
-
'https://fonts.googleapis.com/css2?family=Nunito:wght@400;600;700;800&family=Nunito+Sans:ital,wght@0,300;0,400;0,500;0,600;0,700;1,300&display=swap',
|
|
120
|
-
'https://fonts.googleapis.com/css2?family=Playpen+Sans:wght@300;400;600;800&family=Story+Script:wght@400&display=swap'
|
|
121
|
-
];
|
|
122
|
-
|
|
123
|
-
links.forEach((href, i) => {
|
|
124
|
-
const id = `visualli-font-${i}`;
|
|
125
|
-
if (!document.getElementById(id)) {
|
|
126
|
-
const link = document.createElement('link');
|
|
127
|
-
link.id = id;
|
|
128
|
-
link.rel = i < 2 ? 'preconnect' : 'stylesheet';
|
|
129
|
-
link.href = href;
|
|
130
|
-
if (i === 1) link.crossOrigin = 'anonymous';
|
|
131
|
-
document.head.appendChild(link);
|
|
132
|
-
}
|
|
133
|
-
});
|
|
134
|
-
}, []);
|
|
135
|
-
}
|
|
136
|
-
|
|
137
125
|
// ── Empty / Loading / Error states ───────────────────────────────────────────
|
|
126
|
+
// A still note in the middle of the map: an idea's blob in a topic colour, a title and a line of text — the design
|
|
127
|
+
// system's shapes, tokens and type styles only, and no motion of its own (the product's own state screens live in
|
|
128
|
+
// Visualli's apps).
|
|
138
129
|
|
|
139
|
-
|
|
130
|
+
const stateBox: React.CSSProperties = { display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', width: '100%', height: '100%', gap: 'var(--space-3)', padding: 'var(--space-7)', boxSizing: 'border-box', textAlign: 'center', color: 'var(--ink-muted)', background: 'var(--canvas)' };
|
|
140
131
|
|
|
141
|
-
function
|
|
142
|
-
const bg = isDark ? '#131311' : '#F5F3EF';
|
|
143
|
-
const fg = isDark ? 'rgba(240,237,230,0.35)' : 'rgba(26,26,24,0.35)';
|
|
144
|
-
const bd = isDark ? '#2A2A28' : '#DDD9D0';
|
|
145
|
-
const code = { fontFamily: 'monospace', fontSize: 12, padding: '1px 4px', borderRadius: 4, background: isDark ? '#222220' : '#E8E4DC', border: `1px solid ${bd}` };
|
|
132
|
+
function StateNote({ topic, title, children, role }: { topic: TopicName; title: string; children?: React.ReactNode; role?: 'status' | 'alert' }) {
|
|
146
133
|
return (
|
|
147
|
-
<div style={{
|
|
148
|
-
<svg width="
|
|
149
|
-
<
|
|
150
|
-
<line x1="16" y1="16" x2="32" y2="16" />
|
|
151
|
-
<line x1="16" y1="22" x2="32" y2="22" />
|
|
152
|
-
<line x1="16" y1="28" x2="26" y2="28" />
|
|
134
|
+
<div className="vi-map" style={stateBox} role={role} aria-live={role === 'status' ? 'polite' : undefined}>
|
|
135
|
+
<svg width="64" height="52" viewBox="-32 -26 64 52" aria-hidden="true">
|
|
136
|
+
<path d={blobPath(shapeForLevel(0), 26, 20)} fill={`var(--topic-${topic})`} stroke={`var(--topic-${topic}-ring)`} strokeWidth="2" />
|
|
153
137
|
</svg>
|
|
154
|
-
<
|
|
155
|
-
|
|
156
|
-
No .visualli file provided
|
|
157
|
-
</p>
|
|
158
|
-
<p style={{ margin: '4px 0 0', fontSize: 13 }}>
|
|
159
|
-
Pass a <code style={code}>visualliFile</code> or <code style={code}>visualliString</code> prop.
|
|
160
|
-
</p>
|
|
161
|
-
</div>
|
|
138
|
+
<p className="label" style={{ margin: 0, color: 'var(--ink)' }}>{title}</p>
|
|
139
|
+
{children && <div className="body-sm" style={{ margin: 0, maxWidth: 480, overflowWrap: 'anywhere' }}>{children}</div>}
|
|
162
140
|
</div>
|
|
163
141
|
);
|
|
164
142
|
}
|
|
165
143
|
|
|
166
|
-
function
|
|
167
|
-
const bg = isDark ? '#131311' : '#F5F3EF';
|
|
168
|
-
const fg = isDark ? 'rgba(240,237,230,0.5)' : 'rgba(26,26,24,0.5)';
|
|
169
|
-
const dot = isDark ? 'rgba(240,237,230,0.6)' : 'rgba(26,26,24,0.45)';
|
|
144
|
+
function EmptyState() {
|
|
170
145
|
return (
|
|
171
|
-
<
|
|
172
|
-
<
|
|
173
|
-
|
|
174
|
-
<div key={i} style={{ width: 8, height: 8, borderRadius: '50%', background: dot, animation: `vr-pulse 1.2s ease-in-out ${i * 0.2}s infinite` }} />
|
|
175
|
-
))}
|
|
176
|
-
</div>
|
|
177
|
-
<style>{`@keyframes vr-pulse{0%,80%,100%{opacity:.2;transform:scale(.9)}40%{opacity:1;transform:scale(1.1)}}`}</style>
|
|
178
|
-
<p style={{ margin: 0, fontSize: 14 }}>Loading .visualli file…</p>
|
|
179
|
-
</div>
|
|
146
|
+
<StateNote topic="stone" title="Nothing to show yet">
|
|
147
|
+
Pass a <code style={{ fontFamily: 'var(--font-mono)' }}>visualliFile</code> or <code style={{ fontFamily: 'var(--font-mono)' }}>visualliString</code>.
|
|
148
|
+
</StateNote>
|
|
180
149
|
);
|
|
181
150
|
}
|
|
182
151
|
|
|
183
|
-
function
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
const body = isDark ? 'rgba(248,113,113,0.8)' : 'rgba(180,40,40,0.8)';
|
|
152
|
+
function LoadingState() {
|
|
153
|
+
return <StateNote topic="teal" title="Opening the map…" role="status" />;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function ErrorState({ message }: { message: string }) {
|
|
189
157
|
return (
|
|
190
|
-
<
|
|
191
|
-
<
|
|
192
|
-
|
|
193
|
-
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke={head} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
194
|
-
<circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/>
|
|
195
|
-
</svg>
|
|
196
|
-
<span style={{ fontSize: 15, fontWeight: 700, color: head }}>Failed to parse .visualli file</span>
|
|
197
|
-
</div>
|
|
198
|
-
<p style={{ margin: 0, fontSize: 13, color: body, lineHeight: 1.6, wordBreak: 'break-word', fontFamily: 'monospace' }}>{message}</p>
|
|
199
|
-
</div>
|
|
200
|
-
</div>
|
|
158
|
+
<StateNote topic="berry" title="This map couldn't be opened" role="alert">
|
|
159
|
+
<code style={{ fontFamily: 'var(--font-mono)' }}>{message}</code>
|
|
160
|
+
</StateNote>
|
|
201
161
|
);
|
|
202
162
|
}
|
|
203
163
|
|
|
@@ -215,6 +175,7 @@ self.onmessage = function(e) {
|
|
|
215
175
|
layers: new Map(),
|
|
216
176
|
layersByLevel: new Map(),
|
|
217
177
|
rootLayer: null,
|
|
178
|
+
extensions: {},
|
|
218
179
|
};
|
|
219
180
|
for (var i = 0; i < lines.length; i++) {
|
|
220
181
|
var line = lines[i].trim();
|
|
@@ -230,6 +191,8 @@ self.onmessage = function(e) {
|
|
|
230
191
|
if (!doc.layersByLevel.has(obj.level)) doc.layersByLevel.set(obj.level, []);
|
|
231
192
|
doc.layersByLevel.get(obj.level).push(obj);
|
|
232
193
|
if (obj.level === 0) doc.rootLayer = obj;
|
|
194
|
+
} else if (obj.type === 'extension' && typeof obj.id === 'string') {
|
|
195
|
+
doc.extensions[obj.id] = Array.isArray(obj.data) ? obj.data : [];
|
|
233
196
|
}
|
|
234
197
|
}
|
|
235
198
|
if (!doc.meta) throw new Error('Missing required meta section');
|
|
@@ -368,16 +331,23 @@ export default function VisualliRenderer({
|
|
|
368
331
|
visualliString,
|
|
369
332
|
visualliFile,
|
|
370
333
|
theme = 'light',
|
|
334
|
+
comfort,
|
|
335
|
+
respectForcedColors,
|
|
336
|
+
layout,
|
|
337
|
+
controlsPosition,
|
|
338
|
+
app,
|
|
371
339
|
width = '100%',
|
|
372
340
|
height = '100%',
|
|
373
341
|
useWorker = true,
|
|
374
342
|
chromaticImmersion = false,
|
|
343
|
+
fontBaseUrl,
|
|
344
|
+
loadWebFonts,
|
|
375
345
|
className,
|
|
376
346
|
style,
|
|
377
347
|
}: VisualliRendererProps) {
|
|
378
|
-
|
|
379
|
-
const
|
|
380
|
-
|
|
348
|
+
// Resolve once here so the empty / loading / error states carry the same theme attributes as the map.
|
|
349
|
+
const design = useDesign({ theme, comfort, respectForcedColors });
|
|
350
|
+
useState(() => { ensureDesignSystemStyles({ fontBaseUrl, loadWebFonts }); return true; });
|
|
381
351
|
|
|
382
352
|
const cssWidth = typeof width === 'number' ? `${width}px` : width;
|
|
383
353
|
const cssHeight = typeof height === 'number' ? `${height}px` : height;
|
|
@@ -396,21 +366,22 @@ export default function VisualliRenderer({
|
|
|
396
366
|
}, [parseState]);
|
|
397
367
|
|
|
398
368
|
const wrapperStyle: React.CSSProperties = { width: cssWidth, height: cssHeight, overflow: 'hidden', ...style };
|
|
369
|
+
const attrs = design.attrs;
|
|
399
370
|
|
|
400
371
|
// ── Empty state ─────────────────────────────────────────────────────────────
|
|
401
372
|
if (!visualliString && !visualliFile) {
|
|
402
|
-
return <div className={className} style={wrapperStyle}><EmptyState
|
|
373
|
+
return <div className={className} {...attrs} style={wrapperStyle}><EmptyState /></div>;
|
|
403
374
|
}
|
|
404
375
|
|
|
405
376
|
// ── Loading state ───────────────────────────────────────────────────────────
|
|
406
377
|
if (fileReading || parseState.status === 'loading') {
|
|
407
|
-
return <div className={className} style={wrapperStyle}><LoadingState
|
|
378
|
+
return <div className={className} {...attrs} style={wrapperStyle}><LoadingState /></div>;
|
|
408
379
|
}
|
|
409
380
|
|
|
410
381
|
// ── Error state ─────────────────────────────────────────────────────────────
|
|
411
382
|
if (fileReadError || parseState.status === 'error') {
|
|
412
383
|
const msg = fileReadError ?? (parseState.status === 'error' ? parseState.message : 'Unknown error');
|
|
413
|
-
return <div className={className} style={wrapperStyle}><ErrorState message={msg}
|
|
384
|
+
return <div className={className} {...attrs} style={wrapperStyle}><ErrorState message={msg} /></div>;
|
|
414
385
|
}
|
|
415
386
|
|
|
416
387
|
// ── Canvas ──────────────────────────────────────────────────────────────────
|
|
@@ -418,7 +389,14 @@ export default function VisualliRenderer({
|
|
|
418
389
|
<div className={className} style={wrapperStyle}>
|
|
419
390
|
<VisualliCanvas
|
|
420
391
|
preParsedVisualli={resolvedDoc ?? undefined}
|
|
421
|
-
|
|
392
|
+
theme={theme}
|
|
393
|
+
comfort={comfort}
|
|
394
|
+
respectForcedColors={respectForcedColors}
|
|
395
|
+
layout={layout}
|
|
396
|
+
controlsPosition={controlsPosition}
|
|
397
|
+
app={app}
|
|
398
|
+
fontBaseUrl={fontBaseUrl}
|
|
399
|
+
loadWebFonts={loadWebFonts}
|
|
422
400
|
chromaticImmersion={chromaticImmersion}
|
|
423
401
|
style={{ width: '100%', height: '100%' }}
|
|
424
402
|
/>
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// ─── Accessible layer ─────────────────────────────────────────────────────────
|
|
2
|
+
//
|
|
3
|
+
// The canvas is a picture, so ideas are mirrored as a visually hidden DOM list:
|
|
4
|
+
// one button per visible idea (label, depth, "has more inside"), reachable with
|
|
5
|
+
// Tab and activated with Enter/Space exactly like a click. Focus is drawn on
|
|
6
|
+
// the canvas (the idea gets the spec's focus outline).
|
|
7
|
+
//
|
|
8
|
+
// Only ideas in view are mirrored (capped), so a 10k-idea map never creates a
|
|
9
|
+
// 10k-element DOM.
|
|
10
|
+
|
|
11
|
+
import React, { memo, useEffect, useMemo, useState } from 'react';
|
|
12
|
+
import { intersectsViewport, nodeBounds, type FlatNode } from '@visualli/core';
|
|
13
|
+
import { useViewportStore } from '../stores/useViewportStore';
|
|
14
|
+
|
|
15
|
+
const MAX_MIRRORED = 150;
|
|
16
|
+
|
|
17
|
+
export interface A11yLayerProps {
|
|
18
|
+
nodes: FlatNode[];
|
|
19
|
+
/** Accessible name of the list, e.g. the layer title. */
|
|
20
|
+
label: string;
|
|
21
|
+
hasChildLayer: (nodeId: string) => boolean;
|
|
22
|
+
onActivate: (node: FlatNode) => void;
|
|
23
|
+
onFocusNode: (nodeId: string | null) => void;
|
|
24
|
+
/** Spoken when the layer changes. */
|
|
25
|
+
announcement?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const A11yLayer = memo(function A11yLayer({ nodes, label, hasChildLayer, onActivate, onFocusNode, announcement }: A11yLayerProps) {
|
|
29
|
+
const cx = useViewportStore((s) => s.centerX);
|
|
30
|
+
const cy = useViewportStore((s) => s.centerY);
|
|
31
|
+
const zoom = useViewportStore((s) => s.zoomLevel);
|
|
32
|
+
const w = useViewportStore((s) => s.canvasWidth);
|
|
33
|
+
const h = useViewportStore((s) => s.canvasHeight);
|
|
34
|
+
|
|
35
|
+
// Debounce: wheel-zoom updates the store on every event.
|
|
36
|
+
const [view, setView] = useState({ cx, cy, zoom, w, h });
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
const t = setTimeout(() => setView({ cx, cy, zoom, w, h }), 120);
|
|
39
|
+
return () => clearTimeout(t);
|
|
40
|
+
}, [cx, cy, zoom, w, h]);
|
|
41
|
+
|
|
42
|
+
const items = useMemo(() => {
|
|
43
|
+
const halfW = view.w / 2 / view.zoom, halfH = view.h / 2 / view.zoom;
|
|
44
|
+
const bounds = { minX: view.cx - halfW, maxX: view.cx + halfW, minY: view.cy - halfH, maxY: view.cy + halfH };
|
|
45
|
+
const visible = nodes.filter((n) => intersectsViewport(n, bounds));
|
|
46
|
+
// Keep the nearest ones when there are too many, then restore reading order (rows, then left to right).
|
|
47
|
+
const picked = visible.length > MAX_MIRRORED
|
|
48
|
+
? visible.map((n) => ({ n, d: (n.x - view.cx) ** 2 + (n.y - view.cy) ** 2 })).sort((a, b) => a.d - b.d).slice(0, MAX_MIRRORED).map((e) => e.n)
|
|
49
|
+
: visible;
|
|
50
|
+
const row = (n: FlatNode) => Math.round(nodeBounds(n).minY / 150);
|
|
51
|
+
return [...picked].sort((a, b) => row(a) - row(b) || a.x - b.x);
|
|
52
|
+
}, [nodes, view]);
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<>
|
|
56
|
+
<div className="vi-sr" role="list" aria-label={label}>
|
|
57
|
+
{items.map((n) => (
|
|
58
|
+
<div role="listitem" key={n.id}>
|
|
59
|
+
<button
|
|
60
|
+
type="button"
|
|
61
|
+
data-node-id={n.id}
|
|
62
|
+
aria-label={`${n.title}. Depth ${n.level + 1}.${hasChildLayer(n.id) ? ' Has more inside; press Enter to step inside.' : ''}`}
|
|
63
|
+
onFocus={() => onFocusNode(n.id)}
|
|
64
|
+
onBlur={() => onFocusNode(null)}
|
|
65
|
+
onClick={() => onActivate(n)}
|
|
66
|
+
/>
|
|
67
|
+
</div>
|
|
68
|
+
))}
|
|
69
|
+
</div>
|
|
70
|
+
<div className="vi-sr" role="status" aria-live="polite">{announcement}</div>
|
|
71
|
+
</>
|
|
72
|
+
);
|
|
73
|
+
});
|