@visualli/react 0.1.7 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -5
- package/dist/index.cjs +1900 -2033
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +354 -97
- package/dist/index.d.ts +354 -97
- package/dist/index.js +2005 -2095
- package/dist/index.js.map +1 -1
- package/fonts/Caveat-Variable.ttf +0 -0
- package/licenses/visualli-design-system/LICENSE +9 -0
- package/licenses/visualli-design-system/NOTICE.md +6 -0
- package/package.json +9 -4
- package/src/VisualliCanvas.tsx +452 -409
- package/src/VisualliRenderer.tsx +90 -112
- package/src/components/A11yLayer.tsx +73 -0
- package/src/components/KonvaContainer.tsx +28 -237
- package/src/components/KonvaContainerLabelLayer.tsx +41 -0
- package/src/components/KonvaContainerLayer.tsx +34 -33
- package/src/components/KonvaEdge.tsx +25 -240
- package/src/components/KonvaEdgeLayer.tsx +69 -49
- package/src/components/KonvaNode.tsx +21 -156
- package/src/components/KonvaNodeLayer.tsx +102 -34
- package/src/components/KonvaStage.tsx +17 -3
- package/src/components/NavigationStack.tsx +16 -159
- package/src/components/Overlays.tsx +327 -0
- package/src/components/ZoomControls.tsx +3 -107
- package/src/components/useFrames.ts +30 -0
- package/src/context/VisualliContext.tsx +3 -3
- package/src/design/choreography.ts +157 -0
- package/src/design/containers.ts +150 -0
- package/src/design/design.ts +115 -0
- package/src/design/drawing.ts +303 -0
- package/src/design/measure.ts +34 -0
- package/src/design/runtime.ts +154 -0
- package/src/design/useDesign.ts +38 -0
- package/src/generated/specCss.ts +15 -0
- package/src/hooks/index.ts +0 -2
- package/src/hooks/useKonvaRenderer.ts +13 -18
- package/src/hooks/useLayerChoreography.ts +159 -0
- package/src/hooks/useViewportNodes.ts +0 -1
- package/src/index.ts +22 -22
- package/src/stores/stores.ts +2 -2
- package/src/stores/useViewportStore.ts +12 -2
- package/src/animations/index.ts +0 -2
- package/src/animations/konvaLayerTransition.ts +0 -223
- package/src/components/SketchyBoxKonva.tsx +0 -121
- package/src/config/blobShapes.ts +0 -204
- package/src/config/index.ts +0 -21
- package/src/config/textScaling.ts +0 -69
- package/src/hooks/useKonvaLayerTransition.ts +0 -221
|
@@ -1,257 +1,42 @@
|
|
|
1
1
|
// ─── KonvaEdge ────────────────────────────────────────────────────────────────
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
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, {
|
|
12
|
-
import {
|
|
13
|
-
import type
|
|
14
|
-
import {
|
|
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
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
<
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|
-
//
|
|
4
|
-
//
|
|
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
|
|
10
|
+
import { connectorGeometry, type FlatNode, type MindMapConnection } from '@visualli/core';
|
|
13
11
|
import { useNodeStore } from '../stores/useNodeStore';
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
16
|
-
import
|
|
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
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
const
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
43
|
-
|
|
63
|
+
const frames = useFrames(layerRef, () => clock?.active() ?? false);
|
|
64
|
+
useLayoutEffect(() => clock?.subscribe(() => frames.run()), [clock, frames]);
|
|
44
65
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
{
|
|
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
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
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
|
|
13
|
-
import {
|
|
14
|
-
import type
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
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
|
-
|
|
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
|
-
<
|
|
97
|
-
{
|
|
98
|
-
{
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
|