@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.
Files changed (49) hide show
  1. package/README.md +13 -5
  2. package/dist/index.cjs +1900 -2033
  3. package/dist/index.cjs.map +1 -1
  4. package/dist/index.d.cts +354 -97
  5. package/dist/index.d.ts +354 -97
  6. package/dist/index.js +2005 -2095
  7. package/dist/index.js.map +1 -1
  8. package/fonts/Caveat-Variable.ttf +0 -0
  9. package/licenses/visualli-design-system/LICENSE +9 -0
  10. package/licenses/visualli-design-system/NOTICE.md +6 -0
  11. package/package.json +9 -4
  12. package/src/VisualliCanvas.tsx +452 -409
  13. package/src/VisualliRenderer.tsx +90 -112
  14. package/src/components/A11yLayer.tsx +73 -0
  15. package/src/components/KonvaContainer.tsx +28 -237
  16. package/src/components/KonvaContainerLabelLayer.tsx +41 -0
  17. package/src/components/KonvaContainerLayer.tsx +34 -33
  18. package/src/components/KonvaEdge.tsx +25 -240
  19. package/src/components/KonvaEdgeLayer.tsx +69 -49
  20. package/src/components/KonvaNode.tsx +21 -156
  21. package/src/components/KonvaNodeLayer.tsx +102 -34
  22. package/src/components/KonvaStage.tsx +17 -3
  23. package/src/components/NavigationStack.tsx +16 -159
  24. package/src/components/Overlays.tsx +327 -0
  25. package/src/components/ZoomControls.tsx +3 -107
  26. package/src/components/useFrames.ts +30 -0
  27. package/src/context/VisualliContext.tsx +3 -3
  28. package/src/design/choreography.ts +157 -0
  29. package/src/design/containers.ts +150 -0
  30. package/src/design/design.ts +115 -0
  31. package/src/design/drawing.ts +303 -0
  32. package/src/design/measure.ts +34 -0
  33. package/src/design/runtime.ts +154 -0
  34. package/src/design/useDesign.ts +38 -0
  35. package/src/generated/specCss.ts +15 -0
  36. package/src/hooks/index.ts +0 -2
  37. package/src/hooks/useKonvaRenderer.ts +13 -18
  38. package/src/hooks/useLayerChoreography.ts +159 -0
  39. package/src/hooks/useViewportNodes.ts +0 -1
  40. package/src/index.ts +22 -22
  41. package/src/stores/stores.ts +2 -2
  42. package/src/stores/useViewportStore.ts +12 -2
  43. package/src/animations/index.ts +0 -2
  44. package/src/animations/konvaLayerTransition.ts +0 -223
  45. package/src/components/SketchyBoxKonva.tsx +0 -121
  46. package/src/config/blobShapes.ts +0 -204
  47. package/src/config/index.ts +0 -21
  48. package/src/config/textScaling.ts +0 -69
  49. package/src/hooks/useKonvaLayerTransition.ts +0 -221
@@ -0,0 +1,327 @@
1
+ // ─── Overlay chrome (DOM) ─────────────────────────────────────────────────────
2
+ //
3
+ // Peek, term cards, depth trail and canvas controls are plain DOM styled by the
4
+ // design system's own CSS (.vi-fact, .vi-anchor-card, .vi-trail, .vi-ctrls,
5
+ // .vi-iconbtn …). No colours or fonts are set here: topic colours reach the CSS
6
+ // as the --topic-* custom properties of the active theme; a custom colour is used
7
+ // as given (the design system's colour rule).
8
+
9
+ import React, { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
10
+ import { SHEET, TERM, TRAIL, VIEW, blobPath, hoverOpensTerm, shapeOfLevel, type FlatNode, type SemanticAnchor } from '@visualli/core';
11
+ import { useViewportStore } from '../stores/useViewportStore';
12
+
13
+ // ── Icons (stroke = currentColor) ─────────────────────────────────────────────
14
+
15
+ const Icon = ({ children, size = 20 }: { children: React.ReactNode; size?: number }) => (
16
+ <svg className="vi-icon" width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
17
+ {children}
18
+ </svg>
19
+ );
20
+ const MinusIcon = () => <Icon><line x1="5" y1="12" x2="19" y2="12" /></Icon>;
21
+ const PlusIcon = () => <Icon><line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" /></Icon>;
22
+ const FitIcon = () => <Icon><path d="M8 3H5a2 2 0 0 0-2 2v3" /><path d="M21 8V5a2 2 0 0 0-2-2h-3" /><path d="M3 16v3a2 2 0 0 0 2 2h3" /><path d="M16 21h3a2 2 0 0 0 2-2v-3" /></Icon>;
23
+ const ChevronLeftIcon = () => <Icon size={14}><polyline points="15 6 9 12 15 18" /></Icon>;
24
+ const ChevronIcon = () => <Icon size={14}><polyline points="9 6 15 12 9 18" /></Icon>;
25
+ const ExternalIcon = () => <Icon size={14}><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" /><polyline points="15 3 21 3 21 9" /><line x1="10" y1="14" x2="21" y2="3" /></Icon>;
26
+ const HomeIcon = () => <Icon size={TRAIL.homeIconSize}><path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" /><polyline points="9 22 9 12 15 12 15 22" /></Icon>;
27
+ const CloseIcon = () => <Icon size={18}><line x1="6" y1="6" x2="18" y2="18" /><line x1="18" y1="6" x2="6" y2="18" /></Icon>;
28
+
29
+ // ── Canvas controls ───────────────────────────────────────────────────────────
30
+
31
+ export interface ZoomControlsProps {
32
+ /** Re-fit the current layer into view. */
33
+ onFit?: () => void;
34
+ /** Touch layout: larger targets (the design system's `md` icon buttons). */
35
+ touch?: boolean;
36
+ /** @deprecated theme comes from the surrounding data-theme; kept so existing callers compile. */
37
+ isDark?: boolean;
38
+ }
39
+
40
+ export const ZoomControls = memo(function ZoomControls({ onFit, touch = false }: ZoomControlsProps) {
41
+ const size = touch ? 'vi-iconbtn--md' : 'vi-iconbtn--sm';
42
+ const zoom = useViewportStore((s) => s.zoomLevel);
43
+ const setZoom = useViewportStore((s) => s.setZoom);
44
+ // As the design system's canvas controls: zoom is shown relative to the layer's fit (fit = 100%), the store keeps
45
+ // it within VIEW's limits, and Fit is disabled while the whole layer is already in view.
46
+ const fitted = useViewportStore((s) => Math.abs(s.zoomLevel - s.fitScale) < 1e-6 && Math.abs(s.centerX - s.fitCenterX) < 0.5 && Math.abs(s.centerY - s.fitCenterY) < 0.5);
47
+ const fit = useViewportStore((s) => s.fitScale);
48
+ const zoomIn = useCallback(() => setZoom(zoom * VIEW.zoomStep), [zoom, setZoom]);
49
+ const zoomOut = useCallback(() => setZoom(zoom / VIEW.zoomStep), [zoom, setZoom]);
50
+ return (
51
+ <div className="vi-ctrls" role="toolbar" aria-label="Canvas" data-help="zoom-controls">
52
+ <button type="button" className={`vi-iconbtn ${size}`} aria-label="Zoom out" onClick={zoomOut}><MinusIcon /></button>
53
+ <span className="vi-ctrls__pct" aria-live="polite">{Math.round((zoom / fit) * 100)}%</span>
54
+ <button type="button" className={`vi-iconbtn ${size}`} aria-label="Zoom in" onClick={zoomIn}><PlusIcon /></button>
55
+ {onFit && (<>
56
+ <span className="vi-ctrls__div" aria-hidden="true" />
57
+ <button type="button" className={`vi-iconbtn ${size}`} aria-label="Fit map to view" disabled={fitted} onClick={onFit}><FitIcon /></button>
58
+ </>)}
59
+ </div>
60
+ );
61
+ });
62
+
63
+ // ── Depth trail ───────────────────────────────────────────────────────────────
64
+
65
+ /** An idea's colour: its topic, or a custom colour drawn as given (FlatNode.topic / FlatNode.custom). */
66
+ export type IdeaPaint = Pick<FlatNode, 'topic' | 'custom'>;
67
+
68
+ /** CSS fill + ring for an idea's colour, as the design system's topicColors: the active theme's topic properties, or
69
+ * the custom colour with its darker ring (the `edge` token when it has none). */
70
+ export function paintVars(p: IdeaPaint): { fill: string; ring: string } {
71
+ if (p.custom) return { fill: p.custom.fill, ring: p.custom.ring ?? 'var(--edge)' };
72
+ const t = p.topic ?? TRAIL.rootTopic;
73
+ return { fill: `var(--topic-${t})`, ring: `var(--topic-${t}-ring)` };
74
+ }
75
+
76
+ /** A depth trail entry: the idea stepped into (its title and colour); the first is the map (home glyph, its title as the accessible name). */
77
+ export interface TrailEntry { layerId: string; label: string; level: number; paint: IdeaPaint }
78
+
79
+ /** The trail dot: the level's blob shape in the idea's colour (the design system's TRAIL). */
80
+ const TrailDot = ({ level, paint, current }: { level: number; paint: IdeaPaint; current: boolean }) => {
81
+ const { fill, ring } = paintVars(paint);
82
+ return (
83
+ <svg width={TRAIL.dotBox} height={TRAIL.dotBox} viewBox={`${-TRAIL.dotBox / 2} ${-TRAIL.dotBox / 2} ${TRAIL.dotBox} ${TRAIL.dotBox}`} aria-hidden="true">
84
+ <path d={blobPath(shapeOfLevel(level), TRAIL.dotRadius, TRAIL.dotRadius)} fill={fill} stroke={ring} strokeWidth={current ? TRAIL.currentStroke : TRAIL.stroke} />
85
+ </svg>
86
+ );
87
+ };
88
+
89
+ export interface DepthTrailProps { stack: TrailEntry[]; onNavigateBack: (index: number) => void }
90
+
91
+ export const DepthTrail = memo(function DepthTrail({ stack, onNavigateBack }: DepthTrailProps) {
92
+ if (stack.length === 0) return null;
93
+ const current = stack.length - 1;
94
+ // The map's own entry: the home glyph only, its title as the accessible name and tooltip (the design system's TRAIL.rootLabel).
95
+ const iconOnly = TRAIL.rootLabel === 'icon';
96
+ return (
97
+ <nav className="vi-trail" aria-label="Depth trail" data-help="navigation-stack">
98
+ <ol>
99
+ {stack.map((e, i) => (
100
+ <li key={`${e.layerId}-${i}`} className={i === current ? 'is-current' : undefined}>
101
+ <button type="button" disabled={i === current} aria-current={i === current ? 'location' : undefined} onClick={() => onNavigateBack(i)}
102
+ {...(i === 0 && iconOnly ? { 'aria-label': e.label, title: e.label } : {})}>
103
+ <TrailDot level={e.level} paint={e.paint} current={i === current} />
104
+ <span>{i === 0 ? (iconOnly ? <HomeIcon /> : <><HomeIcon /> {e.label}</>) : e.label}</span>
105
+ </button>
106
+ </li>
107
+ ))}
108
+ </ol>
109
+ </nav>
110
+ );
111
+ });
112
+
113
+ // ── Terms (semantic anchors) ──────────────────────────────────────────────────
114
+
115
+ const escapeRe = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
116
+
117
+ /** Split `text` into plain runs and runs that match an anchor word (longest word wins, whole words only). */
118
+ export function splitTerms(text: string, anchors: ReadonlyArray<SemanticAnchor>): Array<string | SemanticAnchor> {
119
+ const words = anchors.filter((a) => a.word).sort((a, b) => b.word.length - a.word.length);
120
+ if (!words.length) return [text];
121
+ const re = new RegExp(`(${words.map((a) => escapeRe(a.word)).join('|')})`, 'giu');
122
+ const byWord = new Map(words.map((a) => [a.word.toLowerCase(), a]));
123
+ const isWordChar = (ch: string | undefined) => !!ch && /[\p{L}\p{N}]/u.test(ch);
124
+ const out: Array<string | SemanticAnchor> = [];
125
+ let last = 0;
126
+ for (const m of text.matchAll(re)) {
127
+ const start = m.index!, end = start + m[0].length;
128
+ // Whole words only (no lookbehind: it is not available in older Safari).
129
+ if (isWordChar(text[start - 1]) || isWordChar(text[end])) continue;
130
+ if (start > last) out.push(text.slice(last, start));
131
+ out.push({ ...byWord.get(m[0].toLowerCase())!, word: m[0] });
132
+ last = end;
133
+ }
134
+ if (last < text.length) out.push(text.slice(last));
135
+ return out;
136
+ }
137
+
138
+ export const TermCard = ({ anchor, inSheet = false }: { anchor: SemanticAnchor; inSheet?: boolean }) => (
139
+ <span className={`vi-anchor-card${inSheet ? ' is-in-sheet' : ''}`} role="note">
140
+ <span className="vi-anchor-card__eyebrow">Term</span>
141
+ <span className="vi-anchor-card__word" style={{ display: 'block' }}>{anchor.word}</span>
142
+ <p className="vi-anchor-card__desc">{anchor.description}</p>
143
+ {anchor.knowMoreUrl && (
144
+ <span className="vi-anchor-card__acts">
145
+ <a className="vi-fact__link" href={anchor.knowMoreUrl} target="_blank" rel="noopener noreferrer">{TERM.learnMoreLabel} <ExternalIcon /></a>
146
+ </span>
147
+ )}
148
+ </span>
149
+ );
150
+
151
+ // ── Term (semantic anchor) in a peek ─────────────────────────────────────────
152
+
153
+ /**
154
+ * An underlined term and its definition card, behaving as the design system's
155
+ * `SemanticAnchor` (rules from geometry/interaction.ts → TERM): a mouse opens
156
+ * it after TERM.hoverOpen and it closes TERM.hoverClose after the pointer leaves
157
+ * the term and its card; a click, Enter or Space pins it (pressing again closes);
158
+ * Escape closes it (without stepping out of the layer) and returns focus to the
159
+ * term; a press outside closes it; opening by click / keyboard moves focus into
160
+ * the card; the card stays TERM.viewportMargin inside the viewport.
161
+ */
162
+ export function TermAnchor({ anchor }: { anchor: SemanticAnchor }) {
163
+ const [open, setOpen] = useState(false);
164
+ const [shift, setShift] = useState(0);
165
+ const pinned = useRef(false);
166
+ const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
167
+ const wrap = useRef<HTMLSpanElement>(null), btn = useRef<HTMLButtonElement>(null), card = useRef<HTMLSpanElement>(null);
168
+ const show = (pin: boolean) => { clearTimeout(timer.current); if (pin) pinned.current = true; setOpen(true); };
169
+ const hide = () => { clearTimeout(timer.current); pinned.current = false; setOpen(false); };
170
+ useEffect(() => () => clearTimeout(timer.current), []);
171
+ useLayoutEffect(() => { // keep the card inside the viewport
172
+ if (!open || !card.current) { setShift(0); return; }
173
+ const r = card.current.getBoundingClientRect(), vw = document.documentElement.clientWidth, m = TERM.viewportMargin;
174
+ setShift(r.right > vw - m ? vw - m - r.right : r.left < m ? m - r.left : 0);
175
+ }, [open]);
176
+ useEffect(() => {
177
+ if (!open) return;
178
+ const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') { e.stopPropagation(); hide(); btn.current?.focus(); } };
179
+ const onPress = (e: PointerEvent) => { if (!wrap.current?.contains(e.target as Node)) hide(); };
180
+ document.addEventListener('keydown', onKey, true);
181
+ document.addEventListener('pointerdown', onPress, true);
182
+ return () => { document.removeEventListener('keydown', onKey, true); document.removeEventListener('pointerdown', onPress, true); };
183
+ }, [open]);
184
+ return (
185
+ <span
186
+ ref={wrap}
187
+ className={`vi-anchor${open ? ' is-open' : ''}`}
188
+ data-semantic-tooltip="true"
189
+ onPointerEnter={(e) => { if (!hoverOpensTerm(e.pointerType)) return; clearTimeout(timer.current); timer.current = setTimeout(() => setOpen(true), TERM.hoverOpen); }}
190
+ onPointerLeave={(e) => { if (!hoverOpensTerm(e.pointerType) || pinned.current) return; clearTimeout(timer.current); timer.current = setTimeout(() => setOpen(false), TERM.hoverClose); }}
191
+ onBlur={(e) => { if (!wrap.current?.contains(e.relatedTarget as Node | null)) hide(); }}
192
+ >
193
+ <button
194
+ ref={btn}
195
+ type="button"
196
+ className="vi-term"
197
+ aria-expanded={open}
198
+ onClick={() => {
199
+ if (open && pinned.current) { hide(); return; }
200
+ show(true);
201
+ setTimeout(() => card.current?.querySelector<HTMLElement>('a, button')?.focus(), 0);
202
+ }}
203
+ >
204
+ {anchor.word}
205
+ </button>
206
+ {open && <span ref={card} className="vi-anchor__pop" style={shift ? { transform: `translateX(${shift}px)` } : undefined}><TermCard anchor={anchor} /></span>}
207
+ </span>
208
+ );
209
+ }
210
+
211
+ // ── Peek ─────────────────────────────────────────────────────────────────────
212
+
213
+ export interface PeekCardProps {
214
+ node: FlatNode;
215
+ anchors: ReadonlyArray<SemanticAnchor>;
216
+ /** Present when the idea has a layer inside it. */
217
+ onStepInside?: () => void;
218
+ onClose?: () => void;
219
+ /** Replaces the plain summary text (extension point kept from earlier versions). */
220
+ renderContent?: (summary: string) => React.ReactNode;
221
+ }
222
+
223
+ export const PeekCard = memo(function PeekCard({ node, anchors, onStepInside, onClose, renderContent }: PeekCardProps) {
224
+ const parts = useMemo(() => splitTerms(node.description ?? '', anchors), [node.description, anchors]);
225
+ const { fill, ring } = paintVars(node);
226
+ const style = { ['--vi-fact-fill' as string]: fill, ['--vi-fact-ring' as string]: ring } as React.CSSProperties;
227
+ return (
228
+ <div className="vi-fact vi-peek" style={style} role="dialog" aria-label={node.title} data-node-tooltip="true">
229
+ <div className="vi-fact__head">
230
+ <div className="vi-fact__title">{node.title}</div>
231
+ {onClose && <button type="button" className="vi-iconbtn vi-iconbtn--sm vi-fact__close" aria-label="Close" onClick={onClose}><CloseIcon /></button>}
232
+ </div>
233
+ <div className="vi-fact__body">
234
+ {renderContent ? renderContent(node.description ?? '') : parts.map((p, i) =>
235
+ typeof p === 'string' ? <React.Fragment key={i}>{p}</React.Fragment> : (
236
+ <TermAnchor key={i} anchor={p} />
237
+ ))}
238
+ </div>
239
+ {onStepInside && (
240
+ <div className="vi-fact__foot">
241
+ <span />
242
+ <div className="vi-fact__acts">
243
+ <button type="button" className="vi-fact__step" onClick={onStepInside}>Step inside <ChevronIcon /></button>
244
+ </div>
245
+ </div>
246
+ )}
247
+ </div>
248
+ );
249
+ });
250
+
251
+ // ── Peek as a bottom sheet (touch) ────────────────────────────────────────────
252
+
253
+ export interface PeekSheetProps extends Omit<PeekCardProps, 'onClose'> {
254
+ onClose: () => void;
255
+ expanded: boolean;
256
+ onToggleExpanded: () => void;
257
+ /** Collapsed height in px (`--vi-sheet-h`). */
258
+ height: number;
259
+ }
260
+
261
+ // Swipe thresholds on the sheet's grip: the design system's SHEET (geometry/interaction.ts).
262
+
263
+ /**
264
+ * The peek on touch devices: the design system's bottom sheet (`.vi-fact--sheet`).
265
+ * Stays open until dismissed (close button, swipe down, tap on empty canvas, Esc).
266
+ * A tapped term opens its definition inside the sheet, with a Back button.
267
+ */
268
+ export const PeekSheet = memo(function PeekSheet({ node, anchors, onStepInside, onClose, renderContent, expanded, onToggleExpanded, height }: PeekSheetProps) {
269
+ const [term, setTerm] = useState<SemanticAnchor | null>(null);
270
+ const parts = useMemo(() => splitTerms(node.description ?? '', anchors), [node.description, anchors]);
271
+ const startY = useRef<number | null>(null);
272
+ const swiped = useRef(false);
273
+ const { fill, ring } = paintVars(node);
274
+ const style = {
275
+ ['--vi-fact-fill' as string]: fill,
276
+ ['--vi-fact-ring' as string]: ring,
277
+ ['--vi-sheet-h' as string]: `${height}px`,
278
+ zIndex: 'var(--z-card)',
279
+ } as React.CSSProperties;
280
+
281
+ return (
282
+ <div className={`vi-fact vi-fact--sheet vi-peek${expanded ? ' is-expanded' : ''}`} style={style} role="dialog" aria-label={node.title} data-node-tooltip="true">
283
+ <div
284
+ className="vi-fact__grip"
285
+ onPointerDown={(e) => { startY.current = e.clientY; swiped.current = false; }}
286
+ onPointerUp={(e) => {
287
+ if (startY.current === null) return;
288
+ const dy = e.clientY - startY.current;
289
+ startY.current = null;
290
+ if (dy < -SHEET.expandDrag) { swiped.current = true; if (!expanded) onToggleExpanded(); }
291
+ else if (dy > SHEET.dismissDrag) { swiped.current = true; if (expanded) onToggleExpanded(); else onClose(); }
292
+ }}
293
+ >
294
+ <button type="button" className="vi-fact__handle" aria-label={expanded ? 'Collapse' : 'Expand'} aria-expanded={expanded} onClick={() => { if (!swiped.current) onToggleExpanded(); swiped.current = false; }} />
295
+ </div>
296
+ {term ? (
297
+ <>
298
+ <button type="button" className="vi-fact__back" onClick={() => setTerm(null)}><ChevronLeftIcon /> Back</button>
299
+ <TermCard anchor={term} inSheet />
300
+ </>
301
+ ) : (
302
+ <>
303
+ <div className="vi-fact__head">
304
+ <div className="vi-fact__title">{node.title}</div>
305
+ <button type="button" className="vi-iconbtn vi-iconbtn--md vi-fact__close" aria-label="Close" onClick={onClose}><CloseIcon /></button>
306
+ </div>
307
+ <div className="vi-fact__body">
308
+ {renderContent ? renderContent(node.description ?? '') : parts.map((p, i) =>
309
+ typeof p === 'string' ? <React.Fragment key={i}>{p}</React.Fragment> : (
310
+ <span key={i} className="vi-anchor" data-semantic-tooltip="true">
311
+ <button type="button" className="vi-term" onClick={() => setTerm(p)}>{p.word}</button>
312
+ </span>
313
+ ))}
314
+ </div>
315
+ {onStepInside && (
316
+ <div className="vi-fact__foot">
317
+ <span />
318
+ <div className="vi-fact__acts">
319
+ <button type="button" className="vi-fact__step" onClick={onStepInside}>Step inside <ChevronIcon /></button>
320
+ </div>
321
+ </div>
322
+ )}
323
+ </>
324
+ )}
325
+ </div>
326
+ );
327
+ });
@@ -1,110 +1,6 @@
1
1
  // ─── ZoomControls ─────────────────────────────────────────────────────────────
2
2
  //
3
- // Floating zoom control matching the visualli.ai design:
4
- // • Side-by-side ZoomIn / ZoomOut buttons (65×32px)
5
- // • SVG icons (no external dependency)
6
- // • Inline styles (no Tailwind required in SDK)
3
+ // Zoom out / percentage / zoom in / fit, in the design system's controls style.
4
+ // The implementation lives with the other overlay chrome (Overlays.tsx).
7
5
 
8
- import React, { useCallback, memo } from 'react';
9
- import { ZOOM_MIN, ZOOM_MAX } from '@visualli/core';
10
- import { useViewportStore } from '../stores/useViewportStore';
11
-
12
- export interface ZoomControlsProps {
13
- isDark?: boolean;
14
- }
15
-
16
- // ── ZoomIn SVG (Lucide-compatible path) ───────────────────────────────────────
17
- const ZoomInIcon = () => (
18
- <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
19
- <circle cx="11" cy="11" r="8" />
20
- <line x1="21" y1="21" x2="16.65" y2="16.65" />
21
- <line x1="11" y1="8" x2="11" y2="14" />
22
- <line x1="8" y1="11" x2="14" y2="11" />
23
- </svg>
24
- );
25
-
26
- // ── ZoomOut SVG ───────────────────────────────────────────────────────────────
27
- const ZoomOutIcon = () => (
28
- <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
29
- <circle cx="11" cy="11" r="8" />
30
- <line x1="21" y1="21" x2="16.65" y2="16.65" />
31
- <line x1="8" y1="11" x2="14" y2="11" />
32
- </svg>
33
- );
34
-
35
- const ZOOM_STEP = 0.2;
36
-
37
- const ZoomControls = memo(function ZoomControls({ isDark = false }: ZoomControlsProps) {
38
- const zoomLevel = useViewportStore(s => s.zoomLevel);
39
- const setZoom = useViewportStore(s => s.setZoom);
40
-
41
- const handleZoomIn = useCallback(() => setZoom(Math.min(ZOOM_MAX, zoomLevel * 1.2)), [zoomLevel, setZoom]);
42
- const handleZoomOut = useCallback(() => setZoom(Math.max(ZOOM_MIN, zoomLevel / 1.2)), [zoomLevel, setZoom]);
43
-
44
- const surfaceBg = isDark ? 'var(--bg-surface, #1C1C1A)' : 'var(--bg-surface, #FAF8F4)';
45
- const border = isDark ? 'var(--border-color, #333330)' : 'var(--border-color, #DDD9D0)';
46
- const primary = isDark ? 'var(--text-primary, #F0EDE6)' : 'var(--text-primary, #1A1A18)';
47
- const muted = isDark ? 'var(--text-muted, #8E8C88)' : 'var(--text-muted, #747270)';
48
-
49
- return (
50
- <div
51
- style={{
52
- position: 'relative',
53
- background: surfaceBg,
54
- border: `0.5px solid ${border}`,
55
- borderRadius: '8px',
56
- overflow: 'hidden',
57
- width: '65px',
58
- height: '32px',
59
- flexShrink: 0,
60
- }}
61
- role="group"
62
- aria-label="Zoom controls"
63
- >
64
- {/* Zoom In — left half */}
65
- <button
66
- onClick={handleZoomIn}
67
- disabled={zoomLevel >= ZOOM_MAX}
68
- style={{
69
- position: 'absolute',
70
- top: 0, left: 0,
71
- width: '32px', height: '100%',
72
- display: 'flex', alignItems: 'center', justifyContent: 'center',
73
- border: 'none', background: 'transparent', cursor: zoomLevel >= ZOOM_MAX ? 'not-allowed' : 'pointer',
74
- color: zoomLevel >= ZOOM_MAX ? muted : primary,
75
- transition: 'background 0.15s',
76
- }}
77
- onMouseEnter={e => { if (zoomLevel < ZOOM_MAX) (e.currentTarget as HTMLButtonElement).style.background = isDark ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.05)'; }}
78
- onMouseLeave={e => { (e.currentTarget as HTMLButtonElement).style.background = 'transparent'; }}
79
- aria-label="Zoom in"
80
- >
81
- <ZoomInIcon />
82
- </button>
83
-
84
- {/* Divider */}
85
- <div style={{ position: 'absolute', top: 0, bottom: 0, left: '32px', width: '0.5px', background: border }} />
86
-
87
- {/* Zoom Out — right half */}
88
- <button
89
- onClick={handleZoomOut}
90
- disabled={zoomLevel <= ZOOM_MIN}
91
- style={{
92
- position: 'absolute',
93
- top: 0, right: 0,
94
- width: '33px', height: '100%',
95
- display: 'flex', alignItems: 'center', justifyContent: 'center',
96
- border: 'none', background: 'transparent', cursor: zoomLevel <= ZOOM_MIN ? 'not-allowed' : 'pointer',
97
- color: zoomLevel <= ZOOM_MIN ? muted : primary,
98
- transition: 'background 0.15s',
99
- }}
100
- onMouseEnter={e => { if (zoomLevel > ZOOM_MIN) (e.currentTarget as HTMLButtonElement).style.background = isDark ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.05)'; }}
101
- onMouseLeave={e => { (e.currentTarget as HTMLButtonElement).style.background = 'transparent'; }}
102
- aria-label="Zoom out"
103
- >
104
- <ZoomOutIcon />
105
- </button>
106
- </div>
107
- );
108
- });
109
-
110
- export default ZoomControls;
6
+ export { ZoomControls as default, type ZoomControlsProps } from './Overlays';
@@ -0,0 +1,30 @@
1
+ // ─── useFrames ───────────────────────────────────────────────────────────────
2
+ //
3
+ // Redraws a Konva layer every animation frame for as long as `busy()` says an
4
+ // animation is running. Nothing runs while the map is at rest. `run()` draws at
5
+ // once (sharing the frame with any draw already queued, so a state change plus an
6
+ // animation start still paints once) and keeps drawing each frame while busy.
7
+
8
+ import { useEffect, useMemo, useRef, type RefObject } from 'react';
9
+ import type Konva from 'konva';
10
+
11
+ export function useFrames(layerRef: RefObject<Konva.Layer | null>, busy: () => boolean): { run: () => void } {
12
+ const raf = useRef<number | null>(null);
13
+ const busyRef = useRef(busy);
14
+ busyRef.current = busy;
15
+ useEffect(() => () => { if (raf.current !== null) cancelAnimationFrame(raf.current); raf.current = null; }, []);
16
+ return useMemo(() => {
17
+ const tick = () => {
18
+ raf.current = null;
19
+ if (!busyRef.current()) return;
20
+ layerRef.current?.batchDraw();
21
+ raf.current = requestAnimationFrame(tick);
22
+ };
23
+ return {
24
+ run: () => {
25
+ layerRef.current?.batchDraw();
26
+ if (raf.current === null) raf.current = requestAnimationFrame(tick);
27
+ },
28
+ };
29
+ }, [layerRef]);
30
+ }
@@ -4,9 +4,9 @@
4
4
 
5
5
  import React, { createContext, useContext } from 'react';
6
6
 
7
- export interface VisualliContextValue {
8
- // Reserved for future use
9
- }
7
+ // Reserved for future use (public type, kept as an open interface).
8
+ // eslint-disable-next-line @typescript-eslint/no-empty-object-type
9
+ export interface VisualliContextValue {}
10
10
 
11
11
  const VisualliContext = createContext<VisualliContextValue | null>(null);
12
12
 
@@ -0,0 +1,157 @@
1
+ // ─── Choreography ────────────────────────────────────────────────────────────
2
+ //
3
+ // Runs the design system's motion timeline (core's MOTION / revealDelay /
4
+ // connectorDelay / bloomOffset / easeBezier, copied verbatim from
5
+ // design-system/geometry/motion.ts) on the canvas: the same arrival, hover and
6
+ // reduced-motion behaviour VisualMap gets from its CSS animations.
7
+ //
8
+ // No React and no Konva in here; the layers ask "what does idea / connector i
9
+ // look like right now?" while they paint, and redraw each frame while a
10
+ // timeline is running.
11
+
12
+ import { MOTION, easeBezier, bloomOffset, layerReveal, type FlatNode } from '@visualli/core';
13
+
14
+ const clamp01 = (v: number) => (v < 0 ? 0 : v > 1 ? 1 : v);
15
+
16
+ /** How an idea looks mid-arrival (multiplies its resting look). */
17
+ export interface Arrival { alpha: number; scale: number; dx: number; dy: number }
18
+ /** How a connector looks mid-arrival: `draw` is the fraction of the line drawn, `alpha` its group's fade. */
19
+ export interface EdgeArrival { alpha: number; draw: number }
20
+
21
+ /** The timeline of one layer's arrival. Shared by the idea layer and the connector layer. */
22
+ export class RevealClock {
23
+ private t0 = -Infinity;
24
+ private end = 0;
25
+ private plan = layerReveal(0, 0);
26
+ private reduced = false;
27
+ private center = { x: 0, y: 0 };
28
+ private order = new Map<string, number>();
29
+ private listeners = new Set<(running: boolean) => void>();
30
+ private timer: ReturnType<typeof setTimeout> | null = null;
31
+
32
+ /** Begin a layer's arrival. `reduced` (reduced motion or instant reveal): everything fades in together. */
33
+ start(nodes: ReadonlyArray<FlatNode>, edgeCount: number, reduced: boolean, now = performance.now()): void {
34
+ this.order.clear();
35
+ nodes.forEach((n, i) => this.order.set(n.id, i));
36
+ // The design system's arrival, large layers included (geometry/motion.ts → layerReveal): shared stagger slots
37
+ // above largeLayer.staggerSlots, a fade-in together above largeLayer.bloomMax.
38
+ this.plan = layerReveal(nodes.length, edgeCount);
39
+ this.reduced = reduced || this.plan.instant;
40
+ reduced = this.reduced;
41
+ let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
42
+ for (const n of nodes) { minX = Math.min(minX, n.x); maxX = Math.max(maxX, n.x); minY = Math.min(minY, n.y); maxY = Math.max(maxY, n.y); }
43
+ this.center = nodes.length ? { x: (minX + maxX) / 2, y: (minY + maxY) / 2 } : { x: 0, y: 0 };
44
+ this.t0 = now;
45
+ this.end = reduced ? MOTION.reduced.fade : this.plan.total;
46
+ this.emit(true);
47
+ if (this.timer) clearTimeout(this.timer);
48
+ this.timer = setTimeout(() => { this.timer = null; this.emit(false); }, this.end + 50);
49
+ }
50
+
51
+ /** Jump to the finished state (e.g. on unmount). */
52
+ stop(): void {
53
+ this.t0 = -Infinity;
54
+ if (this.timer) { clearTimeout(this.timer); this.timer = null; }
55
+ this.emit(false);
56
+ }
57
+
58
+ /** True while the layer is still arriving. */
59
+ active(now = performance.now()): boolean { return now - this.t0 < this.end; }
60
+
61
+ /** Called with true when an arrival starts and false when it has finished. Returns the unsubscribe function. */
62
+ subscribe(fn: (running: boolean) => void): () => void {
63
+ this.listeners.add(fn);
64
+ return () => { this.listeners.delete(fn); };
65
+ }
66
+
67
+ private emit(running: boolean) { this.listeners.forEach((fn) => fn(running)); }
68
+
69
+ /** Fills `out` with idea `id`'s arrival look. Returns false when it has arrived (nothing to apply). */
70
+ node(id: string, x: number, y: number, now: number, out: Arrival): boolean {
71
+ const el = now - this.t0;
72
+ if (el >= this.end) return false;
73
+ const i = this.order.get(id);
74
+ if (i === undefined) return false;
75
+ if (this.reduced) {
76
+ out.alpha = clamp01(el / MOTION.reduced.fade); out.scale = 1; out.dx = 0; out.dy = 0;
77
+ return true;
78
+ }
79
+ const r = MOTION.reveal;
80
+ const t = el - this.plan.ideaDelay(i);
81
+ if (t >= r.duration) return false;
82
+ const e = t <= 0 ? 0 : easeBezier(r.ease, t / r.duration); // `backwards` fill: waiting ideas sit at the first keyframe
83
+ const from = bloomOffset({ x, y }, this.center);
84
+ out.alpha = clamp01(e);
85
+ out.scale = r.fromScale + (1 - r.fromScale) * e;
86
+ out.dx = from.x * (1 - e);
87
+ out.dy = from.y * (1 - e);
88
+ return true;
89
+ }
90
+
91
+ /** Fills `out` with connector `i`'s arrival look. Returns false when it has arrived. */
92
+ edge(i: number, dashed: boolean, now: number, out: EdgeArrival): boolean {
93
+ const el = now - this.t0;
94
+ if (el >= this.end) return false;
95
+ if (this.reduced) { out.alpha = clamp01(el / MOTION.reduced.fade); out.draw = 1; return true; }
96
+ const c = MOTION.connector;
97
+ const t = el - this.plan.connectorDelay(i);
98
+ if (t >= c.draw && t >= c.fade) return false;
99
+ const group = t <= 0 ? 0 : easeBezier(c.ease, clamp01(t / c.fade));
100
+ const line = t <= 0 ? 0 : easeBezier(c.ease, clamp01(t / c.draw));
101
+ // A solid connector draws from source to target; a dashed one fades in instead.
102
+ out.alpha = dashed ? group * line : group;
103
+ out.draw = dashed ? 1 : line;
104
+ return true;
105
+ }
106
+ }
107
+
108
+ // ── Hover ─────────────────────────────────────────────────────────────────────
109
+
110
+ interface HoverTween { fromLift: number; fromRing: number; to: number; t0: number }
111
+
112
+ /**
113
+ * Hover / select / press: an idea lifts (ease-bloom, so it settles with a little
114
+ * overshoot) while its rings turn and grow (ease-standard), both over
115
+ * `MOTION.hover.duration`. Tracks each raised idea's progress so reversing
116
+ * mid-way continues from where it was.
117
+ */
118
+ export class HoverTweens {
119
+ private tweens = new Map<string, HoverTween>();
120
+ private raised = new Set<string>();
121
+
122
+ /** Set which ideas are raised now. Returns true when anything started moving. */
123
+ set(next: ReadonlySet<string>, reduced: boolean, now = performance.now()): boolean {
124
+ let changed = false;
125
+ const ids = new Set<string>([...this.raised, ...next]);
126
+ for (const id of ids) {
127
+ const was = this.raised.has(id), is = next.has(id);
128
+ if (was === is) continue;
129
+ const cur = this.progress(id, now);
130
+ this.tweens.set(id, { fromLift: cur.lift, fromRing: cur.ring, to: is ? 1 : 0, t0: reduced ? -Infinity : now });
131
+ changed = true;
132
+ }
133
+ this.raised = new Set(next);
134
+ return changed;
135
+ }
136
+
137
+ private cache = { lift: 0, ring: 0 };
138
+ /** Lift (bloom ease) and ring (standard ease) progress of an idea, 0 at rest and 1 fully raised. */
139
+ progress(id: string, now: number): { lift: number; ring: number } {
140
+ const tw = this.tweens.get(id);
141
+ const out = this.cache;
142
+ if (!tw) { out.lift = out.ring = this.raised.has(id) ? 1 : 0; return out; }
143
+ const u = clamp01((now - tw.t0) / MOTION.hover.duration);
144
+ out.lift = tw.fromLift + (tw.to - tw.fromLift) * easeBezier(MOTION.hover.liftEase, u);
145
+ out.ring = tw.fromRing + (tw.to - tw.fromRing) * easeBezier(MOTION.hover.ringEase, u);
146
+ if (u >= 1) { this.tweens.delete(id); out.lift = out.ring = tw.to; }
147
+ return out;
148
+ }
149
+
150
+ /** True while any idea is still moving. */
151
+ active(now = performance.now()): boolean {
152
+ for (const tw of this.tweens.values()) if (now - tw.t0 < MOTION.hover.duration) return true;
153
+ return false;
154
+ }
155
+
156
+ has(id: string): boolean { return this.tweens.has(id) || this.raised.has(id); }
157
+ }