@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,257 +1,42 @@
1
1
  // ─── KonvaEdge ────────────────────────────────────────────────────────────────
2
2
  //
3
- // Renders a connection arrow between two FlatNodes.
4
- // Matches the visualli.ai KonvaEdge exactly:
5
- // - Cubic bezier multi-point polyline (20 segments) through ellipse boundaries
6
- // - Adaptive arc height (smaller for long connections)
7
- // - Open V arrowhead at target
8
- // - TextPath label following the curve (above the line)
9
- // - Always black stroke for maximum visibility
3
+ // A single connector between two ideas: the design system's cubic curve, open
4
+ // arrowhead and label. Endpoints sit `edge-gap` outside the real outline of each
5
+ // idea (including its outermost ring). The canvas layers batch connectors
6
+ // (KonvaEdgeLayer); this component is for composing custom Konva scenes.
10
7
 
11
- import React, { useMemo, memo } from 'react';
12
- import { KGroup as Group, KLine as Line, KTextPath as TextPath } from '../konvaCompat';
13
- import type { FlatNode, MindMapConnection } from '@visualli/core';
14
- import { NODE_HEIGHT } from '@visualli/core';
15
-
16
- // ── Helpers ───────────────────────────────────────────────────────────────────
17
-
18
- // Returns the outermost visible blob scale for a node.
19
- // Mirrors BLOB_LAYER_CONFIG in KonvaNode: scaleUps are [1.30, 1.20, 1.10] (outermost→inner).
20
- const getOutermostScaleUp = (branchCount: number | undefined): number => {
21
- const layerCount = Math.min(branchCount ?? 0, 3);
22
- if (layerCount === 0) return 1.0;
23
- const scaleUps = [1.30, 1.20, 1.10] as const;
24
- return scaleUps[scaleUps.length - layerCount];
25
- };
26
-
27
- function getEllipseIntersection(
28
- cx: number, cy: number, rx: number, ry: number, angle: number,
29
- ): { x: number; y: number } {
30
- return {
31
- x: cx + rx * Math.cos(angle),
32
- y: cy + ry * Math.sin(angle),
33
- };
34
- }
35
-
36
- // ── Component ─────────────────────────────────────────────────────────────────
8
+ import React, { memo, useMemo } from 'react';
9
+ import { KShape as Shape } from '../konvaCompat';
10
+ import { connectorGeometry, type FlatNode, type MindMapConnection } from '@visualli/core';
11
+ import { DEFAULT_DESIGN, type Design } from '../design/design';
12
+ import { drawConnector, prepareConnector } from '../design/drawing';
37
13
 
38
14
  export interface KonvaEdgeProps {
39
15
  sourceNode: FlatNode;
40
16
  targetNode: FlatNode;
41
17
  connection?: MindMapConnection;
18
+ /** @deprecated pass `design`. */
42
19
  isDark?: boolean;
20
+ /** @deprecated connectors use the design system's edge colour; kept so existing callers compile. */
43
21
  colorOverride?: string;
44
22
  zoomLevel?: number;
23
+ design?: Design;
45
24
  }
46
25
 
47
- const EDGE_SPACING = 25; // Increased spacing to ensure arrow head stays out of organic shapes
48
- const STROKE_COLOR = '#000000';
49
- const STROKE_COLOR_DARK = '#ffffff';
50
- const STROKE_WIDTH = 2.5;
51
-
52
- const KonvaEdge = memo(function KonvaEdge({
53
- sourceNode: from,
54
- targetNode: to,
55
- connection,
56
- isDark = false,
57
- zoomLevel = 1.0,
58
- }: KonvaEdgeProps) {
59
- const label = (connection as { label?: string } | undefined)?.label;
60
-
61
- // ── Build multi-point bezier curve ─────────────────────────────────────────
62
- const points = useMemo(() => {
63
- const fromBaseX = from.width / 2;
64
- const fromBaseY = Math.max((NODE_HEIGHT * 1.6) / 2, fromBaseX * 0.74);
65
- const fromScale = getOutermostScaleUp(from.branchCount);
66
- const fromRadiusX = fromBaseX * fromScale;
67
- const fromRadiusY = fromBaseY * fromScale;
68
-
69
- const toBaseX = to.width / 2;
70
- const toBaseY = Math.max((NODE_HEIGHT * 1.6) / 2, toBaseX * 0.74);
71
- const toScale = getOutermostScaleUp(to.branchCount);
72
- const toRadiusX = toBaseX * toScale;
73
- const toRadiusY = toBaseY * toScale;
74
-
75
- const angle = Math.atan2(to.y - from.y, to.x - from.x);
76
- const fromEdge = getEllipseIntersection(from.x, from.y, fromRadiusX, fromRadiusY, angle);
77
- const toEdge = getEllipseIntersection(to.x, to.y, toRadiusX, toRadiusY, angle + Math.PI);
78
-
79
- // Apply spacing from edges (increased to 25 to avoid leaking into organic shapes)
80
- const fromX = fromEdge.x + EDGE_SPACING * Math.cos(angle);
81
- const fromY = fromEdge.y + EDGE_SPACING * Math.sin(angle);
82
- const toX = toEdge.x - EDGE_SPACING * Math.cos(angle);
83
- const toY = toEdge.y - EDGE_SPACING * Math.sin(angle);
84
-
85
- const midX = (fromX + toX) / 2;
86
- const midY = (fromY + toY) / 2;
87
-
88
- const distance = Math.sqrt((toX - fromX) ** 2 + (toY - fromY) ** 2);
89
- const arcHeight = Math.min(45, 45 / (1 + Math.max(0, (distance - 200) / 400)));
90
-
91
- const perpX = -(toY - fromY) / distance;
92
- const perpY = (toX - fromX) / distance;
93
-
94
- const offsetMidX = midX + perpX * arcHeight;
95
- const offsetMidY = midY + perpY * arcHeight;
96
-
97
- const ctrl1X = fromX + (offsetMidX - fromX) * 0.66;
98
- const ctrl1Y = fromY + (offsetMidY - fromY) * 0.66;
99
- const ctrl2X = toX + (offsetMidX - toX) * 0.66;
100
- const ctrl2Y = toY + (offsetMidY - toY) * 0.66;
101
-
102
- const NUM_SEGMENTS = 20;
103
- const pts: number[] = [];
104
- for (let i = 0; i <= NUM_SEGMENTS; i++) {
105
- const t = i / NUM_SEGMENTS;
106
- const mt = 1 - t;
107
- const mt2 = mt * mt; const mt3 = mt2 * mt;
108
- const t2 = t * t; const t3 = t2 * t;
109
- pts.push(
110
- mt3 * fromX + 3 * mt2 * t * ctrl1X + 3 * mt * t2 * ctrl2X + t3 * toX,
111
- mt3 * fromY + 3 * mt2 * t * ctrl1Y + 3 * mt * t2 * ctrl2Y + t3 * toY,
112
- );
113
- }
114
- return pts;
115
- }, [from.x, from.y, from.width, from.branchCount, to.x, to.y, to.width, to.branchCount]);
116
-
117
- // ── Arrowhead ─────────────────────────────────────────────────────────────
118
- const arrowHeadPoints = useMemo(() => {
119
- const len = points.length;
120
- const tipX = points[len - 2];
121
- const tipY = points[len - 1];
122
- const prevX = points[len - 4];
123
- const prevY = points[len - 3];
124
- const angle = Math.atan2(tipY - prevY, tipX - prevX);
125
- const armLength = 11;
126
- const armAngle = Math.PI / 5.5; // ~33°
127
- return [
128
- tipX - armLength * Math.cos(angle - armAngle),
129
- tipY - armLength * Math.sin(angle - armAngle),
130
- tipX, tipY,
131
- tipX - armLength * Math.cos(angle + armAngle),
132
- tipY - armLength * Math.sin(angle + armAngle),
133
- ];
134
- }, [points]);
135
-
136
- // ── TextPath data (optional label) ────────────────────────────────────────
137
- const { pathData, fontSize } = useMemo(() => {
138
- if (!label) return { pathData: null, fontSize: 12 };
139
-
140
- const fromBaseX = from.width / 2;
141
- const fromBaseY = Math.max((NODE_HEIGHT * 1.6) / 2, fromBaseX * 0.74);
142
- const fromScale = getOutermostScaleUp(from.branchCount);
143
- const fromRadiusX = fromBaseX * fromScale;
144
- const fromRadiusY = fromBaseY * fromScale;
145
-
146
- const toBaseX = to.width / 2;
147
- const toBaseY = Math.max((NODE_HEIGHT * 1.6) / 2, toBaseX * 0.74);
148
- const toScale = getOutermostScaleUp(to.branchCount);
149
- const toRadiusX = toBaseX * toScale;
150
- const toRadiusY = toBaseY * toScale;
151
-
152
- const angle = Math.atan2(to.y - from.y, to.x - from.x);
153
- const fromEdge = getEllipseIntersection(from.x, from.y, fromRadiusX, fromRadiusY, angle);
154
- const toEdge = getEllipseIntersection(to.x, to.y, toRadiusX, toRadiusY, angle + Math.PI);
155
-
156
- const fromX = fromEdge.x + EDGE_SPACING * Math.cos(angle);
157
- const fromY = fromEdge.y + EDGE_SPACING * Math.sin(angle);
158
- const toX = toEdge.x - EDGE_SPACING * Math.cos(angle);
159
- const toY = toEdge.y - EDGE_SPACING * Math.sin(angle);
160
-
161
- const midX = (fromX + toX) / 2;
162
- const midY = (fromY + toY) / 2;
163
- const distance = Math.sqrt((toX - fromX) ** 2 + (toY - fromY) ** 2);
164
- const arcHeight = Math.min(45, 45 / (1 + Math.max(0, (distance - 200) / 400)));
165
-
166
- const perpX = -(toY - fromY) / distance;
167
- const perpY = (toX - fromX) / distance;
168
-
169
- const offsetMidX = midX + perpX * arcHeight;
170
- const offsetMidY = midY + perpY * arcHeight;
171
-
172
- const ctrl1X = fromX + (offsetMidX - fromX) * 0.66;
173
- const ctrl1Y = fromY + (offsetMidY - fromY) * 0.66;
174
- const ctrl2X = toX + (offsetMidX - toX) * 0.66;
175
- const ctrl2Y = toY + (offsetMidY - toY) * 0.66;
176
-
177
- const TEXT_OFFSET = 15;
178
- const fxo = fromX + perpX * TEXT_OFFSET; const fyo = fromY + perpY * TEXT_OFFSET;
179
- const txo = toX + perpX * TEXT_OFFSET; const tyo = toY + perpY * TEXT_OFFSET;
180
- const c1xo = ctrl1X + perpX * TEXT_OFFSET; const c1yo = ctrl1Y + perpY * TEXT_OFFSET;
181
- const c2xo = ctrl2X + perpX * TEXT_OFFSET; const c2yo = ctrl2Y + perpY * TEXT_OFFSET;
182
-
183
- // Variable font size: scales with zoom level to remain visible when zoomed out
184
- // and also scales with node width.
185
- const baseFontSize = 14;
186
- const zoomFactor = Math.max(1, 1 / zoomLevel); // Increase font size as we zoom out
187
- const scaledFontSize = Math.max(12, Math.min(24, baseFontSize * (from.width / 180) * zoomFactor));
188
-
189
- let d = '';
190
- if (fromX > toX) {
191
- d = `M ${txo},${tyo} C ${c2xo},${c2yo} ${c1xo},${c1yo} ${fxo},${fyo}`;
192
- } else {
193
- d = `M ${fxo},${fyo} C ${c1xo},${c1yo} ${c2xo},${c2yo} ${txo},${tyo}`;
194
- }
195
- return { pathData: d, fontSize: scaledFontSize };
196
- }, [from.x, from.y, from.width, from.branchCount, to.x, to.y, to.width, to.branchCount, label, zoomLevel]);
197
-
198
- // ── Render ────────────────────────────────────────────────────────────────
199
- const strokeColor = isDark ? STROKE_COLOR_DARK : STROKE_COLOR;
200
-
26
+ const KonvaEdge = memo(function KonvaEdge({ sourceNode: from, targetNode: to, connection, zoomLevel = 1, design = DEFAULT_DESIGN }: KonvaEdgeProps) {
27
+ const g = useMemo(
28
+ () => prepareConnector(connectorGeometry(from, to, design.metrics.edgeGap)),
29
+ // eslint-disable-next-line react-hooks/exhaustive-deps
30
+ [from.x, from.y, from.width, from.level, from.branchCount, to.x, to.y, to.width, to.level, to.branchCount, design.metrics.edgeGap],
31
+ );
201
32
  return (
202
- <Group>
203
- <Line
204
- points={points}
205
- stroke={strokeColor}
206
- strokeWidth={STROKE_WIDTH}
207
- tension={0}
208
- opacity={0.9}
209
- lineCap="round"
210
- lineJoin="round"
211
- listening={false}
212
- perfectDrawEnabled={false}
213
- />
214
- <Line
215
- points={arrowHeadPoints}
216
- stroke={strokeColor}
217
- strokeWidth={STROKE_WIDTH}
218
- lineCap="round"
219
- lineJoin="round"
220
- opacity={0.9}
221
- listening={false}
222
- perfectDrawEnabled={false}
223
- />
224
- {label && pathData && (
225
- <>
226
- {/* Background text for contrast */}
227
- <TextPath
228
- data={pathData}
229
- text={label}
230
- fontSize={fontSize}
231
- fontFamily="'Playpen Sans', cursive"
232
- fontStyle="normal"
233
- fill="#ffffff"
234
- align="center"
235
- letterSpacing={0.5}
236
- listening={false}
237
- opacity={0.9}
238
- />
239
- {/* Foreground label text */}
240
- <TextPath
241
- data={pathData}
242
- text={label}
243
- fontSize={fontSize}
244
- fontFamily="'Playpen Sans', cursive"
245
- fontStyle="normal"
246
- fill="#6b6860"
247
- align="center"
248
- letterSpacing={0.5}
249
- listening={false}
250
- opacity={0.9}
251
- />
252
- </>
253
- )}
254
- </Group>
33
+ <Shape
34
+ listening={false}
35
+ perfectDrawEnabled={false}
36
+ sceneFunc={(ctx: { _context: CanvasRenderingContext2D }) => {
37
+ drawConnector(ctx._context, g, design, zoomLevel, connection?.style === 'dashed', connection?.label);
38
+ }}
39
+ />
255
40
  );
256
41
  });
257
42
 
@@ -1,72 +1,92 @@
1
1
  // ─── KonvaEdgeLayer ───────────────────────────────────────────────────────────
2
2
  //
3
- // Renders all edges for visible nodes. An edge is only drawn when BOTH its
4
- // source and target node are currently in the visible set.
5
- //
6
- // NOTE: Node positions are read from useNodeStore (live) so edges always follow
7
- // dragged nodes. The `nodes` prop is used only for initial viewport culling.
3
+ // One Konva <Shape> paints every visible connector. Geometry (endpoints, cubic
4
+ // curve, arrowhead) comes from the design system via connectorGeometry and is
5
+ // cached per connector until one of its two ideas moves or resizes.
8
6
 
9
7
  import React, { useRef, useLayoutEffect, useMemo } from 'react';
10
- import { KLayer as Layer } from '../konvaCompat';
8
+ import { KLayer as Layer, KShape as Shape } from '../konvaCompat';
11
9
  import type Konva from 'konva';
12
- import type { FlatNode, MindMapConnection } from '@visualli/core';
10
+ import { connectorGeometry, type FlatNode, type MindMapConnection } from '@visualli/core';
13
11
  import { useNodeStore } from '../stores/useNodeStore';
14
- import { useViewportStore } from '../stores/useViewportStore';
15
- import { useViewportNodes } from '../hooks/useViewportNodes';
16
- import KonvaEdge from './KonvaEdge';
12
+ import { DEFAULT_DESIGN, type Design } from '../design/design';
13
+ import { useFontsEpoch } from '../design/runtime';
14
+ import { drawConnector, prepareConnector, type PreparedConnector } from '../design/drawing';
15
+ import { stageViewRect } from './KonvaNodeLayer';
16
+ import type { RevealClock, EdgeArrival } from '../design/choreography';
17
+ import { useFrames } from './useFrames';
17
18
 
18
19
  export interface KonvaEdgeLayerProps {
19
20
  nodes: FlatNode[];
20
21
  connections: MindMapConnection[];
21
22
  currentLevel?: number;
23
+ /** @deprecated pass `design`; kept so existing callers still compile. */
22
24
  isDark?: boolean;
23
- /** Pass true while a node is being dragged to skip viewport culling. */
25
+ /** Pass true while an idea is being dragged (connectors are always drawn then). */
24
26
  isDragging?: boolean;
27
+ design?: Design;
28
+ /** The layer's arrival timeline (connectors draw in after the ideas). Omit for no arrival animation. */
29
+ clock?: RevealClock;
25
30
  }
26
31
 
27
- export default function KonvaEdgeLayer({
28
- nodes,
29
- connections,
30
- currentLevel,
31
- isDark = false,
32
- isDragging = false,
33
- }: KonvaEdgeLayerProps) {
34
- const layerRef = useRef<Konva.Layer | null>(null);
35
- const zoomLevel = useViewportStore(s => s.zoomLevel);
36
- // Live node positions from the store — updated every RAF during drag
37
- const storeNodes = useNodeStore(s => s.nodes);
38
- // Viewport culling still uses the layout snapshot for performance;
39
- // during drag, culling is skipped so newly-moved nodes are always visible.
40
- const visibleNodes = useViewportNodes(nodes, currentLevel, false, isDragging);
32
+ const key = (n: FlatNode) => `${n.x},${n.y},${n.width},${n.level},${n.branchCount ?? 0}`;
33
+ const MARGIN = 80; // bow + arrowhead + label slack around the endpoints' box
34
+
35
+ export default function KonvaEdgeLayer({ connections, isDragging = false, design = DEFAULT_DESIGN, clock }: KonvaEdgeLayerProps) {
36
+ const layerRef = useRef<Konva.Layer | null>(null);
37
+ const fonts = useFontsEpoch();
38
+ // Live positions: connectors follow dragged ideas.
39
+ const storeNodes = useNodeStore((s) => s.nodes);
40
+ const cache = useRef(new Map<string, { k: string; g: PreparedConnector }>());
41
+
42
+ const edges = useMemo(() => {
43
+ const out: Array<{ g: PreparedConnector; dashed: boolean; label?: string }> = [];
44
+ const seen = new Set<string>();
45
+ connections.forEach((conn, i) => {
46
+ const src = storeNodes.get(conn.from);
47
+ const tgt = storeNodes.get(conn.to);
48
+ if (!src || !tgt) return;
49
+ const id = `${conn.from}>${conn.to}#${i}`;
50
+ seen.add(id);
51
+ const k = `${key(src)}|${key(tgt)}`;
52
+ let hit = cache.current.get(id);
53
+ if (!hit || hit.k !== k) {
54
+ hit = { k, g: prepareConnector(connectorGeometry(src, tgt, design.metrics.edgeGap)) };
55
+ cache.current.set(id, hit);
56
+ }
57
+ out.push({ g: hit.g, dashed: conn.style === 'dashed', label: conn.label });
58
+ });
59
+ for (const id of cache.current.keys()) if (!seen.has(id)) cache.current.delete(id);
60
+ return out;
61
+ }, [connections, storeNodes, design.metrics.edgeGap]);
41
62
 
42
- // Set of visible node IDs for O(1) culling
43
- const visibleSet = useMemo(() => new Set(visibleNodes.map(n => n.id)), [visibleNodes]);
63
+ const frames = useFrames(layerRef, () => clock?.active() ?? false);
64
+ useLayoutEffect(() => clock?.subscribe(() => frames.run()), [clock, frames]);
44
65
 
45
- // Redraw before paint whenever visible edges change.
46
- // storeNodes must be included: edge endpoints read live positions from the store,
47
- // so when a node is dragged, the edge must redraw even though visibleSet is unchanged.
48
- useLayoutEffect(() => { layerRef.current?.batchDraw(); }, [connections, isDark, isDragging, visibleSet, storeNodes]);
66
+ useLayoutEffect(() => { layerRef.current?.batchDraw(); }, [edges, design, fonts, isDragging]);
67
+
68
+ const sceneFunc = (ctx: { _context: CanvasRenderingContext2D }, shape: Konva.Shape) => {
69
+ const stage = shape.getStage();
70
+ if (!stage) return;
71
+ const zoom = stage.scaleX() || 1;
72
+ const v = stageViewRect(stage);
73
+ const c = ctx._context;
74
+ const now = performance.now();
75
+ const arriving = clock?.active(now) ?? false;
76
+ const arrival: EdgeArrival = { alpha: 1, draw: 1 };
77
+ for (let i = 0; i < edges.length; i++) {
78
+ const e = edges[i]!;
79
+ const { a, b, mid } = e.g;
80
+ const minX = Math.min(a.x, b.x, mid.x) - MARGIN, maxX = Math.max(a.x, b.x, mid.x) + MARGIN;
81
+ const minY = Math.min(a.y, b.y, mid.y) - MARGIN, maxY = Math.max(a.y, b.y, mid.y) + MARGIN;
82
+ if (maxX < v.minX || minX > v.maxX || maxY < v.minY || minY > v.maxY) continue;
83
+ drawConnector(c, e.g, design, zoom, e.dashed, e.label, arriving && clock!.edge(i, e.dashed, now, arrival) ? arrival : null);
84
+ }
85
+ };
49
86
 
50
87
  return (
51
88
  <Layer ref={layerRef} listening={false}>
52
- {connections.map((conn, idx) => {
53
- // Use live store positions so edges move with dragged nodes
54
- const src = storeNodes.get(conn.from);
55
- const tgt = storeNodes.get(conn.to);
56
- if (!src || !tgt) return null;
57
- if (!isDragging && !visibleSet.has(src.id) && !visibleSet.has(tgt.id)) return null;
58
-
59
- return (
60
- <KonvaEdge
61
- key={`${conn.from}-${conn.to}-${idx}`}
62
- sourceNode={src}
63
- targetNode={tgt}
64
- connection={conn}
65
- isDark={isDark}
66
- zoomLevel={zoomLevel}
67
- />
68
- );
69
- })}
89
+ <Shape sceneFunc={sceneFunc} listening={false} perfectDrawEnabled={false} />
70
90
  </Layer>
71
91
  );
72
92
  }
@@ -1,21 +1,14 @@
1
1
  // ─── KonvaNode ────────────────────────────────────────────────────────────────
2
2
  //
3
- // Renders a single FlatNode as a single organic blob shape with Konva.
4
- // Matches the visualli.ai rendering:
5
- // - Centered blob at node (x, y) with radiusX / radiusY scaling
6
- // - Single blob shape (no background depth layers)
7
- // - HSL-based border darkening
8
- // - 'Story Script' cursive font, text clipped to blob silhouette
9
- // - External hover / pressed state (no Konva hit-canvas dependency)
10
- // - All shapes are listening=false; hit detection uses spatial index
3
+ // A single idea as one Konva shape (rings + body + label), drawn with the design
4
+ // system's geometry and tokens. The canvas layers batch all ideas into one shape
5
+ // (KonvaNodeLayer); this component is for composing custom Konva scenes.
11
6
 
12
- import React, { useMemo } from 'react';
13
- import { KGroup as Group, KShape as Shape, KText as Text } from '../konvaCompat';
14
- import type { FlatNode } from '@visualli/core';
15
- import { CANVAS_COLORS, NODE_HEIGHT, TEXT_LABEL_HIDE_BELOW_ZOOM, darkenHexColor } from '@visualli/core';
16
- import { getBlobTypeForLayer, BLOB_TEXT_OFFSETS, BLOB_LAYER_CONFIG, NODE_LAYER_CONFIG, drawBlobPath, computeNodeTextWorldScale } from '../config';
17
-
18
- // ── Component ─────────────────────────────────────────────────────────────────
7
+ import React from 'react';
8
+ import { KShape as Shape } from '../konvaCompat';
9
+ import { type FlatNode } from '@visualli/core';
10
+ import { DEFAULT_DESIGN, type Design } from '../design/design';
11
+ import { IDLE, drawIdea } from '../design/drawing';
19
12
 
20
13
  export interface KonvaNodeProps {
21
14
  node: FlatNode;
@@ -24,154 +17,26 @@ export interface KonvaNodeProps {
24
17
  isExternallyHovered?: boolean;
25
18
  /** True while this node is being pressed/dragged. */
26
19
  isExternallyPressed?: boolean;
27
- /** True while any node is being dragged — disables expensive shadows. */
20
+ isSelected?: boolean;
21
+ /** True while any node is being dragged — disables the drop shadow. */
28
22
  isDragging?: boolean;
23
+ design?: Design;
29
24
  }
30
25
 
31
26
  const KonvaNode = React.memo(function KonvaNode({
32
- node,
33
- zoomLevel,
34
- isExternallyHovered = false,
35
- isExternallyPressed = false,
36
- isDragging = false,
27
+ node, zoomLevel, isExternallyHovered = false, isExternallyPressed = false, isSelected = false, isDragging = false, design = DEFAULT_DESIGN,
37
28
  }: KonvaNodeProps) {
38
- // Border: monochromatic HSL-darkened version of the fill color
39
- const borderColor = useMemo(() => darkenHexColor(node.color, 0.13), [node.color]);
40
-
41
- // Number of background layers (capped at 3), driven by branchCount
42
- const layerCount = useMemo(() => {
43
- const count = node.branchCount ?? 0;
44
- return Math.min(count, 3);
45
- }, [node.branchCount]);
46
-
47
- // Base blob radii (same math as visualli.ai KonvaNode)
48
- const baseRadiusX = node.width / 2;
49
- const baseRadiusY = Math.max((NODE_HEIGHT * 1.6) / 2, baseRadiusX * 0.74);
50
-
51
- // Larger, consistent font size for better readability
52
- // Text will wrap to multiple lines if needed
53
- const calculateFontSize = useMemo(() => {
54
- // Use a larger base font size for better visibility
55
- return 30;
56
- }, [node.title]);
57
-
58
- // Text scale and layout
59
- const textNaturalWidth = node.width - 40;
60
- // Increase text height to allow 2-3 lines of wrapped text
61
- const textNaturalHeight = NODE_HEIGHT * 1.2;
62
- const maxScale = computeNodeTextWorldScale(node.width, zoomLevel);
63
-
64
- // Blob type and text offset
65
- const blobType = getBlobTypeForLayer(node.level);
66
- const textOffsetX = (BLOB_TEXT_OFFSETS[blobType] ?? 0) * baseRadiusX;
67
-
68
- // Interaction state (purely driven from stage-level delegation)
69
- const effectiveHover = isExternallyHovered;
70
- const isPressed = isExternallyPressed;
71
-
72
- // Blob fill/stroke:
73
- // default → fill=node.color, stroke=borderColor
74
- // hover → fill=borderColor, stroke=borderColor
75
- // pressed → fill=borderColor, stroke=node.color
76
- const topFill = effectiveHover || isPressed ? borderColor : node.color;
77
- const topStroke = isPressed ? node.color : borderColor;
78
-
79
- // Build the background layers config (outermost first for correct z-order)
80
- const backgroundLayers = useMemo(() => {
81
- if (layerCount === 0) return [];
82
- const startIdx = BLOB_LAYER_CONFIG.length - layerCount;
83
- const layers = [];
84
- for (let i = startIdx; i < BLOB_LAYER_CONFIG.length; i++) {
85
- const config = BLOB_LAYER_CONFIG[i];
86
- layers.push({
87
- ...config,
88
- radiusX: baseRadiusX * config.scaleUp,
89
- radiusY: baseRadiusY * config.scaleUp,
90
- });
91
- }
92
- return layers;
93
- }, [layerCount, baseRadiusX, baseRadiusY]);
94
-
95
29
  return (
96
- <Group x={node.x} y={node.y} listening={false}>
97
- {/* Background blob layers (outermost rendered first, behind the node) */}
98
- {backgroundLayers.map((layer, idx) => (
99
- <Shape
100
- key={`blob-layer-${idx}`}
101
- rotation={layer.rotation}
102
- opacity={layer.opacity}
103
- sceneFunc={(ctx: any, shape: any) => {
104
- drawBlobPath(ctx, layer.radiusX, layer.radiusY, blobType);
105
- ctx.fillStrokeShape(shape);
106
- }}
107
- fill={node.color}
108
- stroke={borderColor}
109
- strokeWidth={layer.strokeWidth}
110
- dash={layer.dash ? [...layer.dash] : undefined}
111
- listening={false}
112
- perfectDrawEnabled={false}
113
- />
114
- ))}
115
-
116
- {/* Node (top layer) - main interactive blob */}
117
- <Shape
118
- rotation={NODE_LAYER_CONFIG.rotation}
119
- opacity={NODE_LAYER_CONFIG.opacity}
120
- sceneFunc={(ctx: any, shape: any) => {
121
- drawBlobPath(ctx, baseRadiusX, baseRadiusY, blobType);
122
- ctx.fillStrokeShape(shape);
123
- }}
124
- hitFunc={(ctx: any, shape: any) => {
125
- drawBlobPath(ctx, baseRadiusX, baseRadiusY, blobType);
126
- ctx.fillStrokeShape(shape);
127
- }}
128
- fill={topFill}
129
- stroke={topStroke}
130
- strokeWidth={NODE_LAYER_CONFIG.strokeWidth}
131
- shadowEnabled={!isDragging}
132
- shadowColor="rgba(255,255,255,1)"
133
- shadowBlur={effectiveHover || isPressed ? 40 : 15}
134
- shadowOpacity={effectiveHover || isPressed ? 0.4 : 0.15}
135
- shadowOffsetX={0}
136
- shadowOffsetY={0}
137
- perfectDrawEnabled={false}
138
- />
139
-
140
- {/* Node Title — inverse-scaled so it stays legible at a constant screen size.
141
- Clipped to the blob silhouette via clipFunc so the growing text never visually
142
- bleeds outside the first layer boundary at low zoom levels. */}
143
- <Group
144
- clipFunc={(ctx: any) => {
145
- drawBlobPath(ctx, baseRadiusX, baseRadiusY, blobType);
146
- }}
147
- visible={zoomLevel >= TEXT_LABEL_HIDE_BELOW_ZOOM}
148
- listening={false}
149
- >
150
- <Text
151
- x={textOffsetX}
152
- y={0}
153
- offsetX={textNaturalWidth / 2}
154
- offsetY={textNaturalHeight / 2}
155
- width={textNaturalWidth}
156
- height={textNaturalHeight}
157
- scaleX={maxScale}
158
- scaleY={maxScale}
159
- text={node.title}
160
- fontSize={calculateFontSize}
161
- fontFamily="'Story Script', cursive"
162
- fontStyle="normal"
163
- fill={CANVAS_COLORS.node.titleColor}
164
- align="center"
165
- verticalAlign="middle"
166
- wrap="word"
167
- ellipsis={false}
168
- listening={false}
169
- perfectDrawEnabled={false}
170
- />
171
- </Group>
172
- </Group>
30
+ <Shape
31
+ listening={false}
32
+ perfectDrawEnabled={false}
33
+ sceneFunc={(ctx: { _context: CanvasRenderingContext2D }) => {
34
+ drawIdea(ctx._context, node, design, zoomLevel, { ...IDLE, hovered: isExternallyHovered, pressed: isExternallyPressed, selected: isSelected }, {
35
+ shadows: !isDragging,
36
+ });
37
+ }}
38
+ />
173
39
  );
174
40
  });
175
41
 
176
42
  export default KonvaNode;
177
-