@visualli/react 0.1.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 +335 -0
- package/dist/index.cjs +3262 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +375 -0
- package/dist/index.d.ts +375 -0
- package/dist/index.js +3289 -0
- package/dist/index.js.map +1 -0
- package/package.json +48 -0
- package/src/VisualliCanvas.tsx +1087 -0
- package/src/VisualliRenderer.tsx +427 -0
- package/src/animations/index.ts +2 -0
- package/src/animations/konvaLayerTransition.ts +223 -0
- package/src/components/KonvaContainer.tsx +255 -0
- package/src/components/KonvaContainerLayer.tsx +63 -0
- package/src/components/KonvaEdge.tsx +254 -0
- package/src/components/KonvaEdgeLayer.tsx +72 -0
- package/src/components/KonvaNode.tsx +177 -0
- package/src/components/KonvaNodeLayer.tsx +61 -0
- package/src/components/KonvaStage.tsx +80 -0
- package/src/components/NavigationStack.tsx +184 -0
- package/src/components/SketchyBoxKonva.tsx +121 -0
- package/src/components/ZoomControls.tsx +110 -0
- package/src/config/blobShapes.ts +204 -0
- package/src/config/index.ts +21 -0
- package/src/config/textScaling.ts +69 -0
- package/src/context/VisualliContext.tsx +50 -0
- package/src/hooks/index.ts +7 -0
- package/src/hooks/useKonvaLayerTransition.ts +221 -0
- package/src/hooks/useKonvaRenderer.ts +185 -0
- package/src/hooks/useViewportNodes.ts +59 -0
- package/src/index.ts +81 -0
- package/src/konvaCompat.ts +29 -0
- package/src/stores/index.ts +3 -0
- package/src/stores/stores.ts +17 -0
- package/src/stores/useNodeStore.ts +101 -0
- package/src/stores/useViewportStore.ts +95 -0
- package/src/utils/index.ts +1 -0
- package/src/utils/layerNavigation.ts +15 -0
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
// ─── KonvaContainer ──────────────────────────────────────────────────────────
|
|
2
|
+
//
|
|
3
|
+
// Renders a smooth dashed hull around a group of nodes with an inverse-scaled
|
|
4
|
+
// pill label — matches the visualli.ai KonvaContainer exactly.
|
|
5
|
+
//
|
|
6
|
+
// • 48-point ellipse sampling per node (not bounding box corners)
|
|
7
|
+
// • Graham-scan convex hull → 6-pass Chaikin smoothing
|
|
8
|
+
// • Dashed stroke "#747270", no fill
|
|
9
|
+
// • Pill label: parchment bg (#ddd9d0), Playpen Sans 800, inverse-zoom-scaled
|
|
10
|
+
// • Label anchored to the hull edge (bottom for horizontal, top for vertical)
|
|
11
|
+
|
|
12
|
+
import React, { memo, useMemo } from 'react';
|
|
13
|
+
import {
|
|
14
|
+
KGroup as Group,
|
|
15
|
+
KLine as Line,
|
|
16
|
+
KRect as Rect,
|
|
17
|
+
KText as Text,
|
|
18
|
+
} from '../konvaCompat';
|
|
19
|
+
import type { FlatNode } from '@visualli/core';
|
|
20
|
+
import { NODE_HEIGHT, TEXT_LABEL_HIDE_BELOW_ZOOM } from '@visualli/core';
|
|
21
|
+
import { useViewportStore } from '../stores/useViewportStore';
|
|
22
|
+
import {
|
|
23
|
+
CONTAINER_LABEL_BASE_FONT_PX,
|
|
24
|
+
computeNodeTextScreenScale,
|
|
25
|
+
} from '../config/textScaling';
|
|
26
|
+
|
|
27
|
+
// ── Constants (mirrors visualli.ai) ──────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
const CONTAINER_PADDING = 50;
|
|
30
|
+
const CONTAINER_DASH = [10, 5] as const;
|
|
31
|
+
const LABEL_BG = '#ddd9d0';
|
|
32
|
+
const LABEL_BORDER_RADIUS = 20;
|
|
33
|
+
const LABEL_FONT_SIZE = CONTAINER_LABEL_BASE_FONT_PX;
|
|
34
|
+
const LABEL_FONT_WEIGHT = '800';
|
|
35
|
+
const LABEL_FONT_FAMILY = "'Playpen Sans', Quicksand, sans-serif";
|
|
36
|
+
const LABEL_PAD_H = 20;
|
|
37
|
+
const LABEL_PAD_V = 10;
|
|
38
|
+
const LABEL_LINE_HEIGHT = 1.2;
|
|
39
|
+
// Max normalized blob extents — Shape 5 reaches ±1.275 X, ±1.234 Y
|
|
40
|
+
const MAX_BLOB_EXTENT_X = 1.275;
|
|
41
|
+
const MAX_BLOB_EXTENT_Y = 1.234;
|
|
42
|
+
|
|
43
|
+
// ── Text measurement ──────────────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
let _labelCtx: CanvasRenderingContext2D | null = null;
|
|
46
|
+
function measureLabelBoxWidth(text: string, fontSize: number): number {
|
|
47
|
+
if (!_labelCtx) {
|
|
48
|
+
const c = document.createElement('canvas');
|
|
49
|
+
_labelCtx = c.getContext('2d');
|
|
50
|
+
}
|
|
51
|
+
if (!_labelCtx) return Math.ceil(text.length * fontSize * 0.65 + 2 * LABEL_PAD_H);
|
|
52
|
+
_labelCtx.font = `${LABEL_FONT_WEIGHT} ${fontSize}px ${LABEL_FONT_FAMILY}`;
|
|
53
|
+
return Math.ceil(_labelCtx.measureText(text).width + 2 * LABEL_PAD_H);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ── Convex hull (Graham scan) ─────────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
type Pt = { x: number; y: number };
|
|
59
|
+
|
|
60
|
+
function isLeftTurn(p1: Pt, p2: Pt, p3: Pt): boolean {
|
|
61
|
+
return (p2.x - p1.x) * (p3.y - p1.y) - (p2.y - p1.y) * (p3.x - p1.x) > 0;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function convexHull(points: Pt[]): Pt[] {
|
|
65
|
+
if (points.length < 3) return points;
|
|
66
|
+
let start = 0;
|
|
67
|
+
for (let i = 1; i < points.length; i++) {
|
|
68
|
+
if (points[i].y < points[start].y ||
|
|
69
|
+
(points[i].y === points[start].y && points[i].x < points[start].x)) start = i;
|
|
70
|
+
}
|
|
71
|
+
const sorted = [...points];
|
|
72
|
+
const sp = sorted.splice(start, 1)[0];
|
|
73
|
+
sorted.sort((a, b) => {
|
|
74
|
+
const da = Math.atan2(a.y - sp.y, a.x - sp.x);
|
|
75
|
+
const db = Math.atan2(b.y - sp.y, b.x - sp.x);
|
|
76
|
+
if (da !== db) return da - db;
|
|
77
|
+
return Math.hypot(a.x - sp.x, a.y - sp.y) - Math.hypot(b.x - sp.x, b.y - sp.y);
|
|
78
|
+
});
|
|
79
|
+
const hull: Pt[] = [sp, sorted[0]];
|
|
80
|
+
for (let i = 1; i < sorted.length; i++) {
|
|
81
|
+
while (hull.length > 1 && !isLeftTurn(hull[hull.length - 2], hull[hull.length - 1], sorted[i])) hull.pop();
|
|
82
|
+
hull.push(sorted[i]);
|
|
83
|
+
}
|
|
84
|
+
return hull;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ── Chaikin smoothing (6 passes, returns flat [x,y,…] array) ─────────────────
|
|
88
|
+
|
|
89
|
+
function chaikinSmooth(pts: Pt[], passes = 6): number[] {
|
|
90
|
+
let cur = pts;
|
|
91
|
+
for (let iter = 0; iter < passes; iter++) {
|
|
92
|
+
const next: Pt[] = [];
|
|
93
|
+
const n = cur.length;
|
|
94
|
+
for (let i = 0; i < n; i++) {
|
|
95
|
+
const a = cur[i], b = cur[(i + 1) % n];
|
|
96
|
+
next.push({ x: 0.75 * a.x + 0.25 * b.x, y: 0.75 * a.y + 0.25 * b.y });
|
|
97
|
+
next.push({ x: 0.25 * a.x + 0.75 * b.x, y: 0.25 * a.y + 0.75 * b.y });
|
|
98
|
+
}
|
|
99
|
+
cur = next;
|
|
100
|
+
}
|
|
101
|
+
return cur.flatMap(p => [p.x, p.y]);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ── Hull edge Y at a given X (for label anchor) ───────────────────────────────
|
|
105
|
+
|
|
106
|
+
function hullEdgeYAtX(points: number[], targetX: number, isBottom: boolean): number {
|
|
107
|
+
const n = points.length;
|
|
108
|
+
let result = isBottom ? -Infinity : Infinity;
|
|
109
|
+
for (let i = 0; i < n; i += 2) {
|
|
110
|
+
const x1 = points[i], y1 = points[i + 1];
|
|
111
|
+
const x2 = points[(i + 2) % n], y2 = points[(i + 3) % n];
|
|
112
|
+
if ((x1 < targetX && x2 >= targetX) || (x2 < targetX && x1 >= targetX)) {
|
|
113
|
+
const t = (targetX - x1) / (x2 - x1);
|
|
114
|
+
const y = y1 + t * (y2 - y1);
|
|
115
|
+
if (isBottom) result = Math.max(result, y); else result = Math.min(result, y);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
if (!isFinite(result)) {
|
|
119
|
+
for (let i = 1; i < n; i += 2) {
|
|
120
|
+
if (isBottom) result = Math.max(result, points[i]); else result = Math.min(result, points[i]);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return result;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ── Hull calculation ──────────────────────────────────────────────────────────
|
|
127
|
+
|
|
128
|
+
function calculateHull(
|
|
129
|
+
containerNodes: FlatNode[],
|
|
130
|
+
): { points: number[]; anchorX: number; anchorEdgeY: number } | null {
|
|
131
|
+
if (containerNodes.length === 0) return null;
|
|
132
|
+
|
|
133
|
+
const NUM_SAMPLES = 48;
|
|
134
|
+
const samples: Pt[] = [];
|
|
135
|
+
for (const node of containerNodes) {
|
|
136
|
+
const baseRX = node.width / 2;
|
|
137
|
+
const baseRY = Math.max((NODE_HEIGHT * 1.6) / 2, baseRX * 0.74);
|
|
138
|
+
const halfW = baseRX * MAX_BLOB_EXTENT_X + CONTAINER_PADDING;
|
|
139
|
+
const halfH = baseRY * MAX_BLOB_EXTENT_Y + CONTAINER_PADDING;
|
|
140
|
+
for (let j = 0; j < NUM_SAMPLES; j++) {
|
|
141
|
+
const angle = (j / NUM_SAMPLES) * Math.PI * 2;
|
|
142
|
+
samples.push({ x: node.x + halfW * Math.cos(angle), y: node.y + halfH * Math.sin(angle) });
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const hull = convexHull(samples);
|
|
147
|
+
const points = chaikinSmooth(hull, 6);
|
|
148
|
+
|
|
149
|
+
const xs = containerNodes.map(n => n.x);
|
|
150
|
+
const ys = containerNodes.map(n => n.y);
|
|
151
|
+
const spanX = Math.max(...xs) - Math.min(...xs);
|
|
152
|
+
const spanY = Math.max(...ys) - Math.min(...ys);
|
|
153
|
+
const useTopEdge = spanY > spanX * 2.0;
|
|
154
|
+
|
|
155
|
+
let hullMinX = Infinity, hullMaxX = -Infinity;
|
|
156
|
+
for (let i = 0; i < points.length; i += 2) {
|
|
157
|
+
if (points[i] < hullMinX) hullMinX = points[i];
|
|
158
|
+
if (points[i] > hullMaxX) hullMaxX = points[i];
|
|
159
|
+
}
|
|
160
|
+
const anchorX = (hullMinX + hullMaxX) / 2;
|
|
161
|
+
const anchorEdgeY = hullEdgeYAtX(points, anchorX, !useTopEdge);
|
|
162
|
+
|
|
163
|
+
return { points, anchorX, anchorEdgeY };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ── Props ─────────────────────────────────────────────────────────────────────
|
|
167
|
+
|
|
168
|
+
export interface KonvaContainerProps {
|
|
169
|
+
/** All nodes in this container group (already resolved from nodeMap). */
|
|
170
|
+
nodes: FlatNode[];
|
|
171
|
+
/** Label text shown on the pill badge. */
|
|
172
|
+
label?: string;
|
|
173
|
+
/** Unused – kept for API compat; stroke is always #747270. */
|
|
174
|
+
color?: string;
|
|
175
|
+
isDark?: boolean;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// ── Component ─────────────────────────────────────────────────────────────────
|
|
179
|
+
|
|
180
|
+
const KonvaContainer = memo(function KonvaContainer({
|
|
181
|
+
nodes,
|
|
182
|
+
label = '',
|
|
183
|
+
}: KonvaContainerProps) {
|
|
184
|
+
const zoomLevel = useViewportStore(s => s.zoomLevel);
|
|
185
|
+
|
|
186
|
+
const hullData = useMemo(() => calculateHull(nodes), [nodes]);
|
|
187
|
+
|
|
188
|
+
if (!hullData) return null;
|
|
189
|
+
|
|
190
|
+
// Inverse-scaled label metrics
|
|
191
|
+
const refNode = nodes[0];
|
|
192
|
+
const screenScale = refNode ? computeNodeTextScreenScale(refNode.width, zoomLevel) : 1;
|
|
193
|
+
const labelFontSize = LABEL_FONT_SIZE * screenScale;
|
|
194
|
+
const labelBoxHeight = Math.ceil(labelFontSize * LABEL_LINE_HEIGHT + 2 * LABEL_PAD_V);
|
|
195
|
+
const boxWidth = label ? measureLabelBoxWidth(label, labelFontSize) : 0;
|
|
196
|
+
const boxX = hullData.anchorX - boxWidth / 2;
|
|
197
|
+
const boxY = hullData.anchorEdgeY - labelBoxHeight / 2;
|
|
198
|
+
|
|
199
|
+
return (
|
|
200
|
+
<Group listening={false}>
|
|
201
|
+
{/* Dashed hull outline */}
|
|
202
|
+
<Line
|
|
203
|
+
points={hullData.points}
|
|
204
|
+
stroke="#747270"
|
|
205
|
+
strokeWidth={1}
|
|
206
|
+
dash={CONTAINER_DASH}
|
|
207
|
+
closed={true}
|
|
208
|
+
tension={0}
|
|
209
|
+
listening={false}
|
|
210
|
+
perfectDrawEnabled={false}
|
|
211
|
+
/>
|
|
212
|
+
|
|
213
|
+
{/* Inverse-scaled pill label */}
|
|
214
|
+
{label.length > 0 && (
|
|
215
|
+
<Group
|
|
216
|
+
x={boxX + boxWidth / 2}
|
|
217
|
+
y={boxY + labelBoxHeight / 2}
|
|
218
|
+
offsetX={boxWidth / 2}
|
|
219
|
+
offsetY={labelBoxHeight / 2}
|
|
220
|
+
scaleX={1 / zoomLevel}
|
|
221
|
+
scaleY={1 / zoomLevel}
|
|
222
|
+
visible={zoomLevel >= TEXT_LABEL_HIDE_BELOW_ZOOM}
|
|
223
|
+
listening={false}
|
|
224
|
+
>
|
|
225
|
+
<Rect
|
|
226
|
+
width={boxWidth}
|
|
227
|
+
height={labelBoxHeight}
|
|
228
|
+
fill={LABEL_BG}
|
|
229
|
+
cornerRadius={LABEL_BORDER_RADIUS}
|
|
230
|
+
shadowEnabled={false}
|
|
231
|
+
perfectDrawEnabled={false}
|
|
232
|
+
/>
|
|
233
|
+
<Text
|
|
234
|
+
x={LABEL_PAD_H}
|
|
235
|
+
y={LABEL_PAD_V}
|
|
236
|
+
width={boxWidth - 2 * LABEL_PAD_H}
|
|
237
|
+
height={labelBoxHeight - 2 * LABEL_PAD_V}
|
|
238
|
+
text={label}
|
|
239
|
+
fontSize={labelFontSize}
|
|
240
|
+
fontFamily={LABEL_FONT_FAMILY}
|
|
241
|
+
fontStyle={LABEL_FONT_WEIGHT}
|
|
242
|
+
fill="rgba(0,0,0,1)"
|
|
243
|
+
align="center"
|
|
244
|
+
verticalAlign="middle"
|
|
245
|
+
lineHeight={LABEL_LINE_HEIGHT}
|
|
246
|
+
perfectDrawEnabled={false}
|
|
247
|
+
listening={false}
|
|
248
|
+
/>
|
|
249
|
+
</Group>
|
|
250
|
+
)}
|
|
251
|
+
</Group>
|
|
252
|
+
);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
export default KonvaContainer;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// ─── KonvaContainerLayer ─────────────────────────────────────────────────────
|
|
2
|
+
//
|
|
3
|
+
// Renders container outlines beneath nodes (non-interactive).
|
|
4
|
+
//
|
|
5
|
+
// Node positions are read from useNodeStore (live), matching the visualli.ai
|
|
6
|
+
// reference — containers follow dragged nodes in real-time every RAF frame.
|
|
7
|
+
|
|
8
|
+
import React, { useRef, useLayoutEffect } from 'react';
|
|
9
|
+
import { KLayer as Layer } from '../konvaCompat';
|
|
10
|
+
import type Konva from 'konva';
|
|
11
|
+
import type { FlatNode } from '@visualli/core';
|
|
12
|
+
import { useNodeStore } from '../stores/useNodeStore';
|
|
13
|
+
import KonvaContainer from './KonvaContainer';
|
|
14
|
+
|
|
15
|
+
export interface ContainerGroup {
|
|
16
|
+
id: string;
|
|
17
|
+
label?: string;
|
|
18
|
+
nodeIds: string[];
|
|
19
|
+
level: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface KonvaContainerLayerProps {
|
|
23
|
+
/** Layout-snapshot nodes — used only as a seed for initial IDs. */
|
|
24
|
+
nodes: FlatNode[];
|
|
25
|
+
containers: ContainerGroup[];
|
|
26
|
+
isDark?: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export default function KonvaContainerLayer({ nodes, containers, isDark = false }: KonvaContainerLayerProps) {
|
|
30
|
+
const layerRef = useRef<Konva.Layer | null>(null);
|
|
31
|
+
|
|
32
|
+
// Subscribe to live store — updated every RAF frame during drag.
|
|
33
|
+
// This is the same pattern used by KonvaEdgeLayer so containers and edges
|
|
34
|
+
// both track dragged nodes in real-time.
|
|
35
|
+
const storeNodes = useNodeStore(s => s.nodes);
|
|
36
|
+
|
|
37
|
+
// batchDraw deps include storeNodes so the layer redraws whenever any node
|
|
38
|
+
// position changes (including mid-drag RAF updates).
|
|
39
|
+
useLayoutEffect(() => { layerRef.current?.batchDraw(); }, [containers, isDark, storeNodes]);
|
|
40
|
+
|
|
41
|
+
if (containers.length === 0) return null;
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<Layer ref={layerRef} listening={false}>
|
|
45
|
+
{containers.map(c => {
|
|
46
|
+
// Read positions from live store — not from the layout snapshot
|
|
47
|
+
const groupNodes = c.nodeIds
|
|
48
|
+
.map(id => storeNodes.get(id))
|
|
49
|
+
.filter((n): n is FlatNode => !!n);
|
|
50
|
+
if (groupNodes.length === 0) return null;
|
|
51
|
+
|
|
52
|
+
return (
|
|
53
|
+
<KonvaContainer
|
|
54
|
+
key={c.id}
|
|
55
|
+
nodes={groupNodes}
|
|
56
|
+
label={c.label}
|
|
57
|
+
isDark={isDark}
|
|
58
|
+
/>
|
|
59
|
+
);
|
|
60
|
+
})}
|
|
61
|
+
</Layer>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
// ─── KonvaEdge ────────────────────────────────────────────────────────────────
|
|
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
|
|
10
|
+
|
|
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 ─────────────────────────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
export interface KonvaEdgeProps {
|
|
39
|
+
sourceNode: FlatNode;
|
|
40
|
+
targetNode: FlatNode;
|
|
41
|
+
connection?: MindMapConnection;
|
|
42
|
+
isDark?: boolean;
|
|
43
|
+
colorOverride?: string;
|
|
44
|
+
zoomLevel?: number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const EDGE_SPACING = 25; // Increased spacing to ensure arrow head stays out of organic shapes
|
|
48
|
+
const STROKE_COLOR = '#000000';
|
|
49
|
+
const STROKE_WIDTH = 2.5;
|
|
50
|
+
|
|
51
|
+
const KonvaEdge = memo(function KonvaEdge({
|
|
52
|
+
sourceNode: from,
|
|
53
|
+
targetNode: to,
|
|
54
|
+
connection,
|
|
55
|
+
zoomLevel = 1.0,
|
|
56
|
+
}: KonvaEdgeProps) {
|
|
57
|
+
const label = (connection as { label?: string } | undefined)?.label;
|
|
58
|
+
|
|
59
|
+
// ── Build multi-point bezier curve ─────────────────────────────────────────
|
|
60
|
+
const points = useMemo(() => {
|
|
61
|
+
const fromBaseX = from.width / 2;
|
|
62
|
+
const fromBaseY = Math.max((NODE_HEIGHT * 1.6) / 2, fromBaseX * 0.74);
|
|
63
|
+
const fromScale = getOutermostScaleUp(from.branchCount);
|
|
64
|
+
const fromRadiusX = fromBaseX * fromScale;
|
|
65
|
+
const fromRadiusY = fromBaseY * fromScale;
|
|
66
|
+
|
|
67
|
+
const toBaseX = to.width / 2;
|
|
68
|
+
const toBaseY = Math.max((NODE_HEIGHT * 1.6) / 2, toBaseX * 0.74);
|
|
69
|
+
const toScale = getOutermostScaleUp(to.branchCount);
|
|
70
|
+
const toRadiusX = toBaseX * toScale;
|
|
71
|
+
const toRadiusY = toBaseY * toScale;
|
|
72
|
+
|
|
73
|
+
const angle = Math.atan2(to.y - from.y, to.x - from.x);
|
|
74
|
+
const fromEdge = getEllipseIntersection(from.x, from.y, fromRadiusX, fromRadiusY, angle);
|
|
75
|
+
const toEdge = getEllipseIntersection(to.x, to.y, toRadiusX, toRadiusY, angle + Math.PI);
|
|
76
|
+
|
|
77
|
+
// Apply spacing from edges (increased to 25 to avoid leaking into organic shapes)
|
|
78
|
+
const fromX = fromEdge.x + EDGE_SPACING * Math.cos(angle);
|
|
79
|
+
const fromY = fromEdge.y + EDGE_SPACING * Math.sin(angle);
|
|
80
|
+
const toX = toEdge.x - EDGE_SPACING * Math.cos(angle);
|
|
81
|
+
const toY = toEdge.y - EDGE_SPACING * Math.sin(angle);
|
|
82
|
+
|
|
83
|
+
const midX = (fromX + toX) / 2;
|
|
84
|
+
const midY = (fromY + toY) / 2;
|
|
85
|
+
|
|
86
|
+
const distance = Math.sqrt((toX - fromX) ** 2 + (toY - fromY) ** 2);
|
|
87
|
+
const arcHeight = Math.min(45, 45 / (1 + Math.max(0, (distance - 200) / 400)));
|
|
88
|
+
|
|
89
|
+
const perpX = -(toY - fromY) / distance;
|
|
90
|
+
const perpY = (toX - fromX) / distance;
|
|
91
|
+
|
|
92
|
+
const offsetMidX = midX + perpX * arcHeight;
|
|
93
|
+
const offsetMidY = midY + perpY * arcHeight;
|
|
94
|
+
|
|
95
|
+
const ctrl1X = fromX + (offsetMidX - fromX) * 0.66;
|
|
96
|
+
const ctrl1Y = fromY + (offsetMidY - fromY) * 0.66;
|
|
97
|
+
const ctrl2X = toX + (offsetMidX - toX) * 0.66;
|
|
98
|
+
const ctrl2Y = toY + (offsetMidY - toY) * 0.66;
|
|
99
|
+
|
|
100
|
+
const NUM_SEGMENTS = 20;
|
|
101
|
+
const pts: number[] = [];
|
|
102
|
+
for (let i = 0; i <= NUM_SEGMENTS; i++) {
|
|
103
|
+
const t = i / NUM_SEGMENTS;
|
|
104
|
+
const mt = 1 - t;
|
|
105
|
+
const mt2 = mt * mt; const mt3 = mt2 * mt;
|
|
106
|
+
const t2 = t * t; const t3 = t2 * t;
|
|
107
|
+
pts.push(
|
|
108
|
+
mt3 * fromX + 3 * mt2 * t * ctrl1X + 3 * mt * t2 * ctrl2X + t3 * toX,
|
|
109
|
+
mt3 * fromY + 3 * mt2 * t * ctrl1Y + 3 * mt * t2 * ctrl2Y + t3 * toY,
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
return pts;
|
|
113
|
+
}, [from.x, from.y, from.width, from.branchCount, to.x, to.y, to.width, to.branchCount]);
|
|
114
|
+
|
|
115
|
+
// ── Arrowhead ─────────────────────────────────────────────────────────────
|
|
116
|
+
const arrowHeadPoints = useMemo(() => {
|
|
117
|
+
const len = points.length;
|
|
118
|
+
const tipX = points[len - 2];
|
|
119
|
+
const tipY = points[len - 1];
|
|
120
|
+
const prevX = points[len - 4];
|
|
121
|
+
const prevY = points[len - 3];
|
|
122
|
+
const angle = Math.atan2(tipY - prevY, tipX - prevX);
|
|
123
|
+
const armLength = 11;
|
|
124
|
+
const armAngle = Math.PI / 5.5; // ~33°
|
|
125
|
+
return [
|
|
126
|
+
tipX - armLength * Math.cos(angle - armAngle),
|
|
127
|
+
tipY - armLength * Math.sin(angle - armAngle),
|
|
128
|
+
tipX, tipY,
|
|
129
|
+
tipX - armLength * Math.cos(angle + armAngle),
|
|
130
|
+
tipY - armLength * Math.sin(angle + armAngle),
|
|
131
|
+
];
|
|
132
|
+
}, [points]);
|
|
133
|
+
|
|
134
|
+
// ── TextPath data (optional label) ────────────────────────────────────────
|
|
135
|
+
const { pathData, fontSize } = useMemo(() => {
|
|
136
|
+
if (!label) return { pathData: null, fontSize: 12 };
|
|
137
|
+
|
|
138
|
+
const fromBaseX = from.width / 2;
|
|
139
|
+
const fromBaseY = Math.max((NODE_HEIGHT * 1.6) / 2, fromBaseX * 0.74);
|
|
140
|
+
const fromScale = getOutermostScaleUp(from.branchCount);
|
|
141
|
+
const fromRadiusX = fromBaseX * fromScale;
|
|
142
|
+
const fromRadiusY = fromBaseY * fromScale;
|
|
143
|
+
|
|
144
|
+
const toBaseX = to.width / 2;
|
|
145
|
+
const toBaseY = Math.max((NODE_HEIGHT * 1.6) / 2, toBaseX * 0.74);
|
|
146
|
+
const toScale = getOutermostScaleUp(to.branchCount);
|
|
147
|
+
const toRadiusX = toBaseX * toScale;
|
|
148
|
+
const toRadiusY = toBaseY * toScale;
|
|
149
|
+
|
|
150
|
+
const angle = Math.atan2(to.y - from.y, to.x - from.x);
|
|
151
|
+
const fromEdge = getEllipseIntersection(from.x, from.y, fromRadiusX, fromRadiusY, angle);
|
|
152
|
+
const toEdge = getEllipseIntersection(to.x, to.y, toRadiusX, toRadiusY, angle + Math.PI);
|
|
153
|
+
|
|
154
|
+
const fromX = fromEdge.x + EDGE_SPACING * Math.cos(angle);
|
|
155
|
+
const fromY = fromEdge.y + EDGE_SPACING * Math.sin(angle);
|
|
156
|
+
const toX = toEdge.x - EDGE_SPACING * Math.cos(angle);
|
|
157
|
+
const toY = toEdge.y - EDGE_SPACING * Math.sin(angle);
|
|
158
|
+
|
|
159
|
+
const midX = (fromX + toX) / 2;
|
|
160
|
+
const midY = (fromY + toY) / 2;
|
|
161
|
+
const distance = Math.sqrt((toX - fromX) ** 2 + (toY - fromY) ** 2);
|
|
162
|
+
const arcHeight = Math.min(45, 45 / (1 + Math.max(0, (distance - 200) / 400)));
|
|
163
|
+
|
|
164
|
+
const perpX = -(toY - fromY) / distance;
|
|
165
|
+
const perpY = (toX - fromX) / distance;
|
|
166
|
+
|
|
167
|
+
const offsetMidX = midX + perpX * arcHeight;
|
|
168
|
+
const offsetMidY = midY + perpY * arcHeight;
|
|
169
|
+
|
|
170
|
+
const ctrl1X = fromX + (offsetMidX - fromX) * 0.66;
|
|
171
|
+
const ctrl1Y = fromY + (offsetMidY - fromY) * 0.66;
|
|
172
|
+
const ctrl2X = toX + (offsetMidX - toX) * 0.66;
|
|
173
|
+
const ctrl2Y = toY + (offsetMidY - toY) * 0.66;
|
|
174
|
+
|
|
175
|
+
const TEXT_OFFSET = 15;
|
|
176
|
+
const fxo = fromX + perpX * TEXT_OFFSET; const fyo = fromY + perpY * TEXT_OFFSET;
|
|
177
|
+
const txo = toX + perpX * TEXT_OFFSET; const tyo = toY + perpY * TEXT_OFFSET;
|
|
178
|
+
const c1xo = ctrl1X + perpX * TEXT_OFFSET; const c1yo = ctrl1Y + perpY * TEXT_OFFSET;
|
|
179
|
+
const c2xo = ctrl2X + perpX * TEXT_OFFSET; const c2yo = ctrl2Y + perpY * TEXT_OFFSET;
|
|
180
|
+
|
|
181
|
+
// Variable font size: scales with zoom level to remain visible when zoomed out
|
|
182
|
+
// and also scales with node width.
|
|
183
|
+
const baseFontSize = 14;
|
|
184
|
+
const zoomFactor = Math.max(1, 1 / zoomLevel); // Increase font size as we zoom out
|
|
185
|
+
const scaledFontSize = Math.max(12, Math.min(24, baseFontSize * (from.width / 180) * zoomFactor));
|
|
186
|
+
|
|
187
|
+
let d = '';
|
|
188
|
+
if (fromX > toX) {
|
|
189
|
+
d = `M ${txo},${tyo} C ${c2xo},${c2yo} ${c1xo},${c1yo} ${fxo},${fyo}`;
|
|
190
|
+
} else {
|
|
191
|
+
d = `M ${fxo},${fyo} C ${c1xo},${c1yo} ${c2xo},${c2yo} ${txo},${tyo}`;
|
|
192
|
+
}
|
|
193
|
+
return { pathData: d, fontSize: scaledFontSize };
|
|
194
|
+
}, [from.x, from.y, from.width, from.branchCount, to.x, to.y, to.width, to.branchCount, label, zoomLevel]);
|
|
195
|
+
|
|
196
|
+
// ── Render ────────────────────────────────────────────────────────────────
|
|
197
|
+
return (
|
|
198
|
+
<Group>
|
|
199
|
+
<Line
|
|
200
|
+
points={points}
|
|
201
|
+
stroke={STROKE_COLOR}
|
|
202
|
+
strokeWidth={STROKE_WIDTH}
|
|
203
|
+
tension={0}
|
|
204
|
+
opacity={0.9}
|
|
205
|
+
lineCap="round"
|
|
206
|
+
lineJoin="round"
|
|
207
|
+
listening={false}
|
|
208
|
+
perfectDrawEnabled={false}
|
|
209
|
+
/>
|
|
210
|
+
<Line
|
|
211
|
+
points={arrowHeadPoints}
|
|
212
|
+
stroke={STROKE_COLOR}
|
|
213
|
+
strokeWidth={STROKE_WIDTH}
|
|
214
|
+
lineCap="round"
|
|
215
|
+
lineJoin="round"
|
|
216
|
+
opacity={0.9}
|
|
217
|
+
listening={false}
|
|
218
|
+
perfectDrawEnabled={false}
|
|
219
|
+
/>
|
|
220
|
+
{label && pathData && (
|
|
221
|
+
<>
|
|
222
|
+
{/* Background text for contrast */}
|
|
223
|
+
<TextPath
|
|
224
|
+
data={pathData}
|
|
225
|
+
text={label}
|
|
226
|
+
fontSize={fontSize}
|
|
227
|
+
fontFamily="'Playpen Sans', cursive"
|
|
228
|
+
fontStyle="normal"
|
|
229
|
+
fill="#ffffff"
|
|
230
|
+
align="center"
|
|
231
|
+
letterSpacing={0.5}
|
|
232
|
+
listening={false}
|
|
233
|
+
opacity={0.9}
|
|
234
|
+
/>
|
|
235
|
+
{/* Foreground label text */}
|
|
236
|
+
<TextPath
|
|
237
|
+
data={pathData}
|
|
238
|
+
text={label}
|
|
239
|
+
fontSize={fontSize}
|
|
240
|
+
fontFamily="'Playpen Sans', cursive"
|
|
241
|
+
fontStyle="normal"
|
|
242
|
+
fill="#6b6860"
|
|
243
|
+
align="center"
|
|
244
|
+
letterSpacing={0.5}
|
|
245
|
+
listening={false}
|
|
246
|
+
opacity={0.9}
|
|
247
|
+
/>
|
|
248
|
+
</>
|
|
249
|
+
)}
|
|
250
|
+
</Group>
|
|
251
|
+
);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
export default KonvaEdge;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// ─── KonvaEdgeLayer ───────────────────────────────────────────────────────────
|
|
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.
|
|
8
|
+
|
|
9
|
+
import React, { useRef, useLayoutEffect, useMemo } from 'react';
|
|
10
|
+
import { KLayer as Layer } from '../konvaCompat';
|
|
11
|
+
import type Konva from 'konva';
|
|
12
|
+
import type { FlatNode, MindMapConnection } from '@visualli/core';
|
|
13
|
+
import { useNodeStore } from '../stores/useNodeStore';
|
|
14
|
+
import { useViewportStore } from '../stores/useViewportStore';
|
|
15
|
+
import { useViewportNodes } from '../hooks/useViewportNodes';
|
|
16
|
+
import KonvaEdge from './KonvaEdge';
|
|
17
|
+
|
|
18
|
+
export interface KonvaEdgeLayerProps {
|
|
19
|
+
nodes: FlatNode[];
|
|
20
|
+
connections: MindMapConnection[];
|
|
21
|
+
currentLevel?: number;
|
|
22
|
+
isDark?: boolean;
|
|
23
|
+
/** Pass true while a node is being dragged to skip viewport culling. */
|
|
24
|
+
isDragging?: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
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);
|
|
41
|
+
|
|
42
|
+
// Set of visible node IDs for O(1) culling
|
|
43
|
+
const visibleSet = useMemo(() => new Set(visibleNodes.map(n => n.id)), [visibleNodes]);
|
|
44
|
+
|
|
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]);
|
|
49
|
+
|
|
50
|
+
return (
|
|
51
|
+
<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
|
+
})}
|
|
70
|
+
</Layer>
|
|
71
|
+
);
|
|
72
|
+
}
|