@visualli/react 0.1.7 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/README.md +13 -5
  2. package/dist/index.cjs +1900 -2033
  3. package/dist/index.cjs.map +1 -1
  4. package/dist/index.d.cts +354 -97
  5. package/dist/index.d.ts +354 -97
  6. package/dist/index.js +2005 -2095
  7. package/dist/index.js.map +1 -1
  8. package/fonts/Caveat-Variable.ttf +0 -0
  9. package/licenses/visualli-design-system/LICENSE +9 -0
  10. package/licenses/visualli-design-system/NOTICE.md +6 -0
  11. package/package.json +9 -4
  12. package/src/VisualliCanvas.tsx +452 -409
  13. package/src/VisualliRenderer.tsx +90 -112
  14. package/src/components/A11yLayer.tsx +73 -0
  15. package/src/components/KonvaContainer.tsx +28 -237
  16. package/src/components/KonvaContainerLabelLayer.tsx +41 -0
  17. package/src/components/KonvaContainerLayer.tsx +34 -33
  18. package/src/components/KonvaEdge.tsx +25 -240
  19. package/src/components/KonvaEdgeLayer.tsx +69 -49
  20. package/src/components/KonvaNode.tsx +21 -156
  21. package/src/components/KonvaNodeLayer.tsx +102 -34
  22. package/src/components/KonvaStage.tsx +17 -3
  23. package/src/components/NavigationStack.tsx +16 -159
  24. package/src/components/Overlays.tsx +327 -0
  25. package/src/components/ZoomControls.tsx +3 -107
  26. package/src/components/useFrames.ts +30 -0
  27. package/src/context/VisualliContext.tsx +3 -3
  28. package/src/design/choreography.ts +157 -0
  29. package/src/design/containers.ts +150 -0
  30. package/src/design/design.ts +115 -0
  31. package/src/design/drawing.ts +303 -0
  32. package/src/design/measure.ts +34 -0
  33. package/src/design/runtime.ts +154 -0
  34. package/src/design/useDesign.ts +38 -0
  35. package/src/generated/specCss.ts +15 -0
  36. package/src/hooks/index.ts +0 -2
  37. package/src/hooks/useKonvaRenderer.ts +13 -18
  38. package/src/hooks/useLayerChoreography.ts +159 -0
  39. package/src/hooks/useViewportNodes.ts +0 -1
  40. package/src/index.ts +22 -22
  41. package/src/stores/stores.ts +2 -2
  42. package/src/stores/useViewportStore.ts +12 -2
  43. package/src/animations/index.ts +0 -2
  44. package/src/animations/konvaLayerTransition.ts +0 -223
  45. package/src/components/SketchyBoxKonva.tsx +0 -121
  46. package/src/config/blobShapes.ts +0 -204
  47. package/src/config/index.ts +0 -21
  48. package/src/config/textScaling.ts +0 -69
  49. package/src/hooks/useKonvaLayerTransition.ts +0 -221
@@ -1,61 +1,129 @@
1
1
  // ─── KonvaNodeLayer ───────────────────────────────────────────────────────────
2
2
  //
3
- // Konva <Layer> that renders all visible FlatNodes.
4
- // Uses viewport culling and passes external hover/pressed state to each node.
3
+ // One Konva <Shape> paints every visible idea. Culling uses the stage's live
4
+ // transform at draw time (so panning, which moves the stage directly without
5
+ // React state, never shows blank areas) and the idea's centred visual bounds
6
+ // (RBush above 200 ideas). Hit testing stays outside Konva (stage-level RBush).
5
7
 
6
- import React, { useRef, useLayoutEffect, useEffect, useMemo } from 'react';
7
- import { KLayer as Layer } from '../konvaCompat';
8
+ import React, { useRef, useLayoutEffect, useMemo } from 'react';
9
+ import { KLayer as Layer, KShape as Shape } from '../konvaCompat';
8
10
  import type Konva from 'konva';
9
- import type { FlatNode } from '@visualli/core';
10
- import { useViewportStore } from '../stores/useViewportStore';
11
+ import { CANVAS_STYLE, RBushSpatialIndex, layerBlooms, shadowsShown, intersectsViewport, nodeBounds, type FlatNode } from '@visualli/core';
11
12
  import { useNodeStore } from '../stores/useNodeStore';
12
- import { useViewportNodes } from '../hooks/useViewportNodes';
13
- import KonvaNode from './KonvaNode';
13
+ import { DEFAULT_DESIGN, type Design } from '../design/design';
14
+ import { useFontsEpoch } from '../design/runtime';
15
+ import { IDLE, drawIdea, type IdeaState } from '../design/drawing';
16
+ import { HoverTweens, type RevealClock, type Arrival } from '../design/choreography';
17
+ import { useFrames } from './useFrames';
14
18
 
19
+ const SPATIAL_INDEX_THRESHOLD = 200;
15
20
  export interface KonvaNodeLayerProps {
16
21
  nodes?: FlatNode[];
17
22
  isTransitioning?: boolean;
18
23
  isDragging?: boolean;
19
24
  hoveredNodeId?: string | null;
20
25
  pressedNodeId?: string | null;
26
+ selectedNodeId?: string | null;
27
+ /** Idea with keyboard focus (accessible layer). */
28
+ focusedNodeId?: string | null;
29
+ design?: Design;
30
+ /** The layer's arrival timeline (bloom). Omit for no arrival animation. */
31
+ clock?: RevealClock;
32
+ /** Touch layout (`.vi-map.is-touch`): the other ideas fade while one is peeked. */
33
+ touch?: boolean;
34
+ /** Idea whose peek (sheet) is open on touch. */
35
+ peekedNodeId?: string | null;
36
+ }
37
+
38
+ /** World-space rectangle currently visible in the stage. */
39
+ export function stageViewRect(stage: Konva.Stage) {
40
+ const s = stage.scaleX() || 1;
41
+ return { minX: -stage.x() / s, minY: -stage.y() / s, maxX: (stage.width() - stage.x()) / s, maxY: (stage.height() - stage.y()) / s };
21
42
  }
22
43
 
23
44
  export default function KonvaNodeLayer({
24
45
  nodes: propNodes,
25
- isTransitioning = false,
26
46
  isDragging = false,
27
47
  hoveredNodeId = null,
28
48
  pressedNodeId = null,
49
+ selectedNodeId = null,
50
+ focusedNodeId = null,
51
+ design = DEFAULT_DESIGN,
52
+ clock,
53
+ touch = false,
54
+ peekedNodeId = null,
29
55
  }: KonvaNodeLayerProps) {
30
- const layerRef = useRef<Konva.Layer | null>(null);
31
- const zoomLevel = useViewportStore(s => s.zoomLevel);
32
- const storeNodes = useNodeStore(s => s.nodes);
33
- const allNodes: FlatNode[] = useMemo(
34
- () => propNodes ?? Array.from(storeNodes.values()),
35
- [propNodes, storeNodes],
36
- );
37
- const visibleNodes = useViewportNodes(allNodes, undefined, isTransitioning, isDragging);
56
+ const layerRef = useRef<Konva.Layer | null>(null);
57
+ const fonts = useFontsEpoch();
58
+ const storeNodes = useNodeStore((s) => s.nodes);
59
+ const nodes: FlatNode[] = useMemo(() => propNodes ?? Array.from(storeNodes.values()), [propNodes, storeNodes]);
60
+
61
+ // Spatial index over visual bounds. Rebuilt only when the node set changes; while dragging we draw everything.
62
+ const index = useMemo(() => {
63
+ if (nodes.length < SPATIAL_INDEX_THRESHOLD || isDragging) return null;
64
+ const idx = new RBushSpatialIndex();
65
+ idx.bulkLoad(nodes.map((n) => ({ nodeId: n.id, bounds: nodeBounds(n) })));
66
+ return idx;
67
+ }, [nodes, isDragging]);
68
+ const byId = useMemo(() => (index ? new Map(nodes.map((n) => [n.id, n])) : null), [index, nodes]);
69
+
70
+ // While an idea is peeked the others are dimmed (`.vi-node.is-dimmed`): unchanged by default, faded in the focus
71
+ // themes and on touch — the design system's opacities (CANVAS_STYLE from css/spec.css).
72
+ const highlighted = hoveredNodeId ?? peekedNodeId ?? selectedNodeId;
73
+ const N = CANVAS_STYLE.node;
74
+ const focusTheme = design.theme.startsWith('focus');
75
+ const dimOpacity = focusTheme ? (touch ? N.dimmedOpacityFocusTouch : N.dimmedOpacityFocus) : touch ? N.dimmedOpacityTouch : N.dimmedOpacity;
76
+ const dimOthers = highlighted !== null && dimOpacity !== 1;
77
+
78
+ // Hover / press / select: the design system's lift and ring turn, tweened rather than snapped.
79
+ const tweens = useRef(new HoverTweens()).current;
80
+ const frames = useFrames(layerRef, () => clock?.active() || tweens.active());
81
+ useLayoutEffect(() => {
82
+ const raised = new Set<string>();
83
+ if (hoveredNodeId) raised.add(hoveredNodeId);
84
+ if (pressedNodeId) raised.add(pressedNodeId);
85
+ if (selectedNodeId) raised.add(selectedNodeId);
86
+ if (tweens.set(raised, design.comfort.reducedMotion || !layerBlooms(nodes.length))) frames.run();
87
+ }, [hoveredNodeId, pressedNodeId, selectedNodeId, design.comfort.reducedMotion, layerBlooms(nodes.length), tweens, frames]); // eslint-disable-line react-hooks/exhaustive-deps
88
+ useLayoutEffect(() => clock?.subscribe(() => frames.run()), [clock, frames]);
89
+
90
+ useLayoutEffect(() => { layerRef.current?.batchDraw(); }, [nodes, index, design, fonts, hoveredNodeId, pressedNodeId, selectedNodeId, focusedNodeId, isDragging, touch, peekedNodeId]);
38
91
 
39
- // Redraw synchronously before paint whenever visible content changes.
40
- // Tied to data deps so it does not fire on every parent re-render.
41
- useLayoutEffect(() => { layerRef.current?.batchDraw(); }, [visibleNodes, hoveredNodeId, pressedNodeId, isDragging, zoomLevel]);
92
+ const sceneFunc = (ctx: { _context: CanvasRenderingContext2D }, shape: Konva.Shape) => {
93
+ const stage = shape.getStage();
94
+ if (!stage) return;
95
+ const zoom = stage.scaleX() || 1;
96
+ const view = stageViewRect(stage);
97
+ let visible: FlatNode[];
98
+ if (isDragging) visible = nodes;
99
+ else if (index && byId) visible = index.query(view).map((id) => byId.get(id)!).filter(Boolean);
100
+ else visible = nodes.filter((n) => intersectsViewport(n, view));
42
101
 
43
- // Redraw when transition state toggles (opacity wrapper controls visibility,
44
- // but the canvas still needs to be current when it fades back in).
45
- useEffect(() => { layerRef.current?.batchDraw(); }, [isTransitioning]);
102
+ const c = ctx._context;
103
+ const m = c.getTransform();
104
+ const opt = { shadows: !isDragging && shadowsShown(visible.length), k: Math.hypot(m.a, m.b), base: m, dimOpacity };
105
+ const st: IdeaState = { ...IDLE };
106
+ const now = performance.now();
107
+ const arrival: Arrival = { alpha: 1, scale: 1, dx: 0, dy: 0 };
108
+ const arriving = clock?.active(now) ?? false;
109
+ for (let i = 0; i < visible.length; i++) {
110
+ const n = visible[i]!;
111
+ st.hovered = n.id === hoveredNodeId;
112
+ st.pressed = n.id === pressedNodeId;
113
+ st.selected = n.id === selectedNodeId;
114
+ st.focused = n.id === focusedNodeId;
115
+ st.dimmed = dimOthers && n.id !== highlighted;
116
+ if (tweens.has(n.id)) { const p = tweens.progress(n.id, now); st.lift = p.lift; st.ring = p.ring; } else { st.lift = undefined; st.ring = undefined; }
117
+ st.arrival = arriving && clock!.node(n.id, n.x, n.y, now, arrival) ? arrival : null;
118
+ drawIdea(c, n, design, zoom, st, opt);
119
+ }
120
+ c.setTransform(m);
121
+ c.globalAlpha = 1;
122
+ };
46
123
 
47
124
  return (
48
- <Layer ref={layerRef} name="nodes">
49
- {visibleNodes.map(n => (
50
- <KonvaNode
51
- key={n.id}
52
- node={n}
53
- zoomLevel={zoomLevel}
54
- isExternallyHovered={hoveredNodeId === n.id}
55
- isExternallyPressed={pressedNodeId === n.id}
56
- isDragging={isDragging}
57
- />
58
- ))}
125
+ <Layer ref={layerRef} name="nodes" listening={false}>
126
+ <Shape sceneFunc={sceneFunc} listening={false} perfectDrawEnabled={false} />
59
127
  </Layer>
60
128
  );
61
129
  }
@@ -4,11 +4,14 @@
4
4
  // • Binds canvas position/scale from the viewport store
5
5
  // • Accepts optional event handlers (wheel, mouse, touch, context-menu)
6
6
  // • Calls stage.destroy() on unmount (memory hygiene)
7
+ // • Renders at devicePixelRatio and follows it when it changes (browser zoom,
8
+ // moving the window to another display)
7
9
 
8
10
  import React, { useRef, useEffect, forwardRef } from 'react';
9
11
  import { KStage as Stage } from '../konvaCompat';
10
- import type Konva from 'konva';
12
+ import Konva from 'konva';
11
13
  import { useViewportStore } from '../stores/useViewportStore';
14
+ import { currentPixelRatio, watchPixelRatio } from '../design/runtime';
12
15
 
13
16
  export interface KonvaStageProps {
14
17
  children: React.ReactNode;
@@ -44,9 +47,21 @@ const KonvaStage = forwardRef<Konva.Stage, KonvaStageProps>(function KonvaStage(
44
47
  }, []); // eslint-disable-line react-hooks/exhaustive-deps
45
48
 
46
49
  useEffect(() => {
47
- return () => { internalRef.current?.destroy(); };
50
+ const stage = internalRef.current; // the stage this mount created
51
+ return () => { stage?.destroy(); };
48
52
  }, []);
49
53
 
54
+ // Konva reads the global pixelRatio when a layer creates its canvases, so set it
55
+ // before the first draw and re-size every canvas when the ratio changes later.
56
+ Konva.pixelRatio = currentPixelRatio();
57
+ useEffect(() => watchPixelRatio((ratio) => {
58
+ Konva.pixelRatio = ratio;
59
+ const stage = internalRef.current;
60
+ if (!stage) return;
61
+ stage.getLayers().forEach((l) => { l.getCanvas().setPixelRatio(ratio); l.getHitCanvas().setPixelRatio(ratio); });
62
+ stage.batchDraw();
63
+ }), []);
64
+
50
65
  const w = canvasWidth || 800;
51
66
  const h = canvasHeight || 600;
52
67
  const stageX = w / 2 - centerX * zoomLevel;
@@ -70,7 +85,6 @@ const KonvaStage = forwardRef<Konva.Stage, KonvaStageProps>(function KonvaStage(
70
85
  onTouchMove={onTouchMove}
71
86
  onTouchEnd={onTouchEnd}
72
87
  draggable={false}
73
- pixelRatio={1}
74
88
  >
75
89
  {children}
76
90
  </Stage>
@@ -1,182 +1,39 @@
1
- // ─── NavigationStack ─────────────────────────────────────────────────────────
1
+ // ─── NavigationStack ──────────────────────────────────────────────────────────
2
2
  //
3
- // Vertical navigation stack rendered as a fixed left-side overlay.
4
- // Matches the visualli.ai VisualliNavigationStack design:
5
- // • Organic hand-drawn circle SVGs per level
6
- // • Vertical connector lines between levels
7
- // • Active level highlighted, previous levels clickable to navigate back
8
- // • Font: Nunito
3
+ // The depth trail. Kept as a named component for existing callers; it renders
4
+ // the design system's trail (DepthTrail) from layer entries.
9
5
 
10
- import React, { memo, useState } from 'react';
6
+ import React, { memo, useMemo } from 'react';
11
7
  import type { VisualliLayer } from '@visualli/core';
8
+ import { DepthTrail, type IdeaPaint } from './Overlays';
12
9
 
13
10
  export interface NavStackEntry {
14
11
  layerId: string;
15
12
  layer: VisualliLayer;
16
13
  label: string;
14
+ /** Colour of the idea this layer opens from (the design system's TRAIL: the clicked idea's own colour); the first
15
+ * entry, the map's title, is TRAIL.rootTopic. */
16
+ paint?: IdeaPaint;
17
17
  }
18
18
 
19
19
  export interface NavigationStackProps {
20
20
  stack: NavStackEntry[];
21
21
  onNavigateBack: (index: number) => void;
22
+ /** @deprecated theme comes from the surrounding data-theme. */
22
23
  isDark?: boolean;
24
+ /** Override the trail's default offset from the top-left corner. */
23
25
  top?: string;
24
26
  left?: string;
25
27
  }
26
28
 
27
- // Pastel fills cycled across levels
28
- const PASTEL_FILLS = [
29
- '#b6eea7', '#d5bcfe', '#a1c4fc', '#a6f5d8',
30
- '#ffc9de', '#c4e8ff', '#f0c4ff', '#ffe0a3',
31
- ];
32
-
33
- // Organic circle SVG paths (small / active variants matching visualli.ai)
34
- const ORGANIC_PATHS_SMALL = [
35
- 'M13.72,6.31 C15.12,6.29 17.96,8.57 17.99,10.60 C18.07,14.62 16.72,17.90 11.12,18.00 C7.26,18.07 4.07,14.86 4.00,10.85 C3.93,6.83 9.44,2.02 13.69,4.85',
36
- 'M13.88,6.41 C15.28,6.43 18.04,8.82 18.00,10.85 C17.93,14.86 16.48,18.10 10.88,18.00 C7.02,17.93 3.94,14.62 4.01,10.60 C4.08,6.59 9.76,1.97 13.91,4.95',
37
- ];
38
- const ORGANIC_PATH_ACTIVE =
39
- 'M16.55,9.22 C17.95,9.15 20.87,11.33 20.97,13.36 C21.19,17.37 19.96,20.70 14.37,20.99 C10.51,21.19 7.20,18.10 7.00,14.09 C6.78,10.08 12.13,5.08 16.48,7.77';
40
-
41
- const CIRCLE_SIZE = 14;
42
-
43
- function hexToRgba(hex: string, opacity: number): string {
44
- const h = hex.replace('#', '');
45
- const r = parseInt(h.substring(0, 2), 16);
46
- const g = parseInt(h.substring(2, 4), 16);
47
- const b = parseInt(h.substring(4, 6), 16);
48
- return `rgba(${r},${g},${b},${opacity})`;
49
- }
50
-
51
- const OrganicCircle = memo(function OrganicCircle({
52
- size, fill, stroke, strokeWidth, variant, isActive,
53
- }: {
54
- size: number; fill: string; stroke: string; strokeWidth: number; variant: number; isActive?: boolean;
55
- }) {
56
- const path = isActive ? ORGANIC_PATH_ACTIVE : ORGANIC_PATHS_SMALL[variant % ORGANIC_PATHS_SMALL.length];
57
- const vb = isActive ? '4 3 20 20' : '2 1 18 18';
58
- const vbSz = isActive ? 20 : 18;
59
- const sw = strokeWidth * vbSz / size;
60
- return (
61
- <svg width={size} height={size} viewBox={vb} fill="none" style={{ display: 'block' }}>
62
- <path d={path} fill={fill} fillOpacity={1} />
63
- <path d={path} fill="none" stroke={stroke} strokeWidth={sw} strokeLinecap="round" />
64
- </svg>
29
+ const NavigationStack = memo(function NavigationStack({ stack, onNavigateBack, top, left }: NavigationStackProps) {
30
+ const entries = useMemo(
31
+ () => stack.map((e) => ({ layerId: e.layerId, label: e.label, level: e.layer.level, paint: e.paint ?? {} })),
32
+ [stack],
65
33
  );
66
- });
67
-
68
- const NavigationStack = memo(function NavigationStack({
69
- stack,
70
- onNavigateBack,
71
- isDark = false,
72
- top = '16px',
73
- left = '16px',
74
- }: NavigationStackProps) {
75
- const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
76
-
77
- // Always render at least the root entry so the user knows where they are.
78
- if (stack.length === 0) return null;
79
-
80
- const visitedStroke = isDark ? '#908e8c' : '#747270';
81
- const visitedText = isDark ? '#908e8c' : '#747270';
82
- const activeStroke = isDark ? '#e8e6e4' : '#1a1a18';
83
- const activeText = isDark ? '#e8e6e4' : '#1a1a18';
84
- const connectorColor = isDark ? '#908e8c' : '#747270';
85
- const connectorMarginLeft = 10.5; // padding(4) + circleHalf(7) - connectorHalf(0.5)
86
- const currentIndex = stack.length - 1;
87
-
88
34
  return (
89
- <div
90
- data-help="navigation-stack"
91
- style={{
92
- position: 'absolute',
93
- top,
94
- left,
95
- zIndex: 30,
96
- pointerEvents: 'auto',
97
- }}
98
- >
99
- <div style={{ display: 'flex', flexDirection: 'column', padding: '8px' }}>
100
- {stack.flatMap((entry, index) => {
101
- const isCurrent = index === currentIndex;
102
- const isClickable = index < currentIndex;
103
- const isHovered = hoveredIndex === index;
104
- const pastelFill = PASTEL_FILLS[index % PASTEL_FILLS.length];
105
- const rowBg = isCurrent
106
- ? hexToRgba(pastelFill, 0.6)
107
- : isHovered && isClickable
108
- ? hexToRgba(pastelFill, 0.3)
109
- : 'transparent';
110
-
111
- const items: React.ReactNode[] = [];
112
-
113
- if (index > 0) {
114
- items.push(
115
- <div
116
- key={`conn-${index}`}
117
- style={{
118
- width: '1px',
119
- height: '20px',
120
- marginLeft: `${connectorMarginLeft}px`,
121
- marginTop: '-6px',
122
- marginBottom: '-6px',
123
- background: connectorColor,
124
- alignSelf: 'flex-start',
125
- flexShrink: 0,
126
- position: 'relative',
127
- zIndex: 1,
128
- }}
129
- />,
130
- );
131
- }
132
-
133
- items.push(
134
- <div
135
- key={`${entry.layerId}-${index}`}
136
- style={{
137
- position: 'relative',
138
- zIndex: 2,
139
- alignSelf: 'flex-start',
140
- display: 'flex',
141
- alignItems: 'center',
142
- gap: '4px',
143
- height: '22px',
144
- padding: '4px',
145
- borderRadius: '8px',
146
- cursor: isClickable ? 'pointer' : 'default',
147
- background: rowBg,
148
- transition: 'background 0.15s',
149
- }}
150
- onMouseEnter={() => setHoveredIndex(index)}
151
- onMouseLeave={() => setHoveredIndex(null)}
152
- onClick={e => { e.stopPropagation(); if (isClickable) onNavigateBack(index); }}
153
- >
154
- <div style={{ width: CIRCLE_SIZE, height: CIRCLE_SIZE, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0, zIndex: 4 }}>
155
- {isCurrent ? (
156
- <OrganicCircle size={CIRCLE_SIZE} fill={pastelFill} stroke={activeStroke} strokeWidth={1.5} variant={index} isActive />
157
- ) : (
158
- <OrganicCircle size={CIRCLE_SIZE} fill={pastelFill} stroke={visitedStroke} strokeWidth={1} variant={index} />
159
- )}
160
- </div>
161
- <span
162
- style={{
163
- fontFamily: "'Nunito', sans-serif",
164
- fontSize: isCurrent || (isHovered && isClickable) ? '11px' : '9px',
165
- fontWeight: isCurrent ? 600 : 400,
166
- color: isCurrent ? activeText : visitedText,
167
- lineHeight: 1,
168
- whiteSpace: 'nowrap',
169
- transition: 'color 0.15s, font-size 0.15s',
170
- }}
171
- >
172
- {entry.label}
173
- </span>
174
- </div>,
175
- );
176
-
177
- return items;
178
- })}
179
- </div>
35
+ <div className="vi-map__trail" style={top || left ? { top, left } : undefined}>
36
+ <DepthTrail stack={entries} onNavigateBack={onNavigateBack} />
180
37
  </div>
181
38
  );
182
39
  });