@pixerate/editor-svelte 0.4.0 → 0.5.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/dist/canvas/index.d.ts +228 -0
- package/dist/canvas/index.js +557 -1
- package/dist/canvas/index.js.map +1 -1
- package/package.json +1 -1
- package/src/canvas/__tests__/canvas-explosion.test.ts +301 -0
- package/src/canvas/createCanvasDisplacement.ts +264 -0
- package/src/canvas/createCanvasExplosion.svelte.ts +368 -0
- package/src/canvas/createCanvasTrajectory.ts +188 -0
- package/src/canvas/index.d.ts +228 -0
- package/src/canvas/index.ts +22 -0
- package/src/canvas/types.ts +117 -0
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import dagre from '@dagrejs/dagre';
|
|
2
|
+
import type { Rect, XYPosition } from '@xyflow/svelte';
|
|
3
|
+
import type {
|
|
4
|
+
CanvasEdge,
|
|
5
|
+
CanvasLayoutOptions,
|
|
6
|
+
CanvasNode,
|
|
7
|
+
DirectionalDisplacementOptions
|
|
8
|
+
} from './types.js';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Calculates a bounding rectangle for a set of nodes if not provided by getNodesBounds.
|
|
12
|
+
*/
|
|
13
|
+
export function computeNodesBoundingBox<TNode extends CanvasNode = CanvasNode>(
|
|
14
|
+
nodes: TNode[],
|
|
15
|
+
defaultWidth = 200,
|
|
16
|
+
defaultHeight = 60
|
|
17
|
+
): Rect {
|
|
18
|
+
if (nodes.length === 0) {
|
|
19
|
+
return { x: 0, y: 0, width: 0, height: 0 };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
let minX = Infinity;
|
|
23
|
+
let minY = Infinity;
|
|
24
|
+
let maxX = -Infinity;
|
|
25
|
+
let maxY = -Infinity;
|
|
26
|
+
|
|
27
|
+
for (const node of nodes) {
|
|
28
|
+
const width = node.measured?.width ?? (node as any).width ?? defaultWidth;
|
|
29
|
+
const height = node.measured?.height ?? (node as any).height ?? defaultHeight;
|
|
30
|
+
|
|
31
|
+
minX = Math.min(minX, node.position.x);
|
|
32
|
+
minY = Math.min(minY, node.position.y);
|
|
33
|
+
maxX = Math.max(maxX, node.position.x + width);
|
|
34
|
+
maxY = Math.max(maxY, node.position.y + height);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
x: minX,
|
|
39
|
+
y: minY,
|
|
40
|
+
width: maxX - minX,
|
|
41
|
+
height: maxY - minY
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Pure directional displacement calculation.
|
|
47
|
+
* Shifts downstream or surrounding nodes along an axis to clear space for newly inserted child nodes.
|
|
48
|
+
*/
|
|
49
|
+
export function calculateDirectionalDisplacement<TNode extends CanvasNode = CanvasNode>(params: {
|
|
50
|
+
existingNodes: TNode[];
|
|
51
|
+
originNode: TNode;
|
|
52
|
+
childNodes: TNode[];
|
|
53
|
+
getNodesBounds?: (nodes: TNode[]) => Rect;
|
|
54
|
+
options?: DirectionalDisplacementOptions;
|
|
55
|
+
}): Map<string, XYPosition> {
|
|
56
|
+
const {
|
|
57
|
+
existingNodes,
|
|
58
|
+
originNode,
|
|
59
|
+
childNodes,
|
|
60
|
+
getNodesBounds,
|
|
61
|
+
options = {}
|
|
62
|
+
} = params;
|
|
63
|
+
|
|
64
|
+
const {
|
|
65
|
+
direction = 'right',
|
|
66
|
+
gap = 40,
|
|
67
|
+
spreadSiblingLanes = false,
|
|
68
|
+
siblingTolerance = 50,
|
|
69
|
+
minY
|
|
70
|
+
} = options;
|
|
71
|
+
|
|
72
|
+
const displacedMap = new Map<string, XYPosition>();
|
|
73
|
+
if (childNodes.length === 0 || existingNodes.length === 0) {
|
|
74
|
+
return displacedMap;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const childBounds = getNodesBounds
|
|
78
|
+
? getNodesBounds(childNodes)
|
|
79
|
+
: computeNodesBoundingBox(childNodes);
|
|
80
|
+
|
|
81
|
+
const originWidth = originNode.measured?.width ?? (originNode as any).width ?? 200;
|
|
82
|
+
const originHeight = originNode.measured?.height ?? (originNode as any).height ?? 60;
|
|
83
|
+
|
|
84
|
+
// The delta space required along primary axis
|
|
85
|
+
const deltaPrimary = (direction === 'right' || direction === 'left')
|
|
86
|
+
? childBounds.width + gap
|
|
87
|
+
: childBounds.height + gap;
|
|
88
|
+
|
|
89
|
+
// Vertical displacement parameters: upward and downward row clearance
|
|
90
|
+
const deltaUp = Math.max(0, originNode.position.y - childBounds.y + gap);
|
|
91
|
+
let deltaDown = Math.max(
|
|
92
|
+
0,
|
|
93
|
+
childBounds.y + childBounds.height - (originNode.position.y + originHeight) + gap
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
let effectiveDeltaUp = deltaUp;
|
|
97
|
+
if (direction === 'vertical' && minY !== undefined && deltaUp > 0) {
|
|
98
|
+
const aboveNodes = existingNodes.filter(
|
|
99
|
+
(n) => n.id !== originNode.id && n.position.y < originNode.position.y
|
|
100
|
+
);
|
|
101
|
+
if (aboveNodes.length > 0) {
|
|
102
|
+
const minAboveY = Math.min(...aboveNodes.map((n) => n.position.y));
|
|
103
|
+
const maxAllowedUp = Math.max(0, minAboveY - minY);
|
|
104
|
+
effectiveDeltaUp = Math.min(deltaUp, maxAllowedUp);
|
|
105
|
+
const unmetUp = deltaUp - effectiveDeltaUp;
|
|
106
|
+
deltaDown += unmetUp;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
for (const node of existingNodes) {
|
|
111
|
+
if (node.id === originNode.id) continue;
|
|
112
|
+
|
|
113
|
+
let newX = node.position.x;
|
|
114
|
+
let newY = node.position.y;
|
|
115
|
+
let moved = false;
|
|
116
|
+
|
|
117
|
+
if (direction === 'right') {
|
|
118
|
+
// Downstream nodes strictly to the right of the origin
|
|
119
|
+
if (node.position.x >= originNode.position.x + originWidth * 0.5) {
|
|
120
|
+
newX += deltaPrimary;
|
|
121
|
+
moved = true;
|
|
122
|
+
} else if (spreadSiblingLanes) {
|
|
123
|
+
// Sibling nodes occupying the same vertical space as the exploded cluster
|
|
124
|
+
const isVerticallyOverlapping =
|
|
125
|
+
node.position.y + ((node as any).height ?? 60) >= childBounds.y - gap &&
|
|
126
|
+
node.position.y <= childBounds.y + childBounds.height + gap;
|
|
127
|
+
|
|
128
|
+
if (isVerticallyOverlapping && Math.abs(node.position.x - originNode.position.x) <= siblingTolerance) {
|
|
129
|
+
const pushDirection = node.position.y >= originNode.position.y ? 1 : -1;
|
|
130
|
+
newY += pushDirection * (childBounds.height * 0.5 + gap);
|
|
131
|
+
moved = true;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
} else if (direction === 'left') {
|
|
135
|
+
if (node.position.x <= originNode.position.x) {
|
|
136
|
+
newX -= deltaPrimary;
|
|
137
|
+
moved = true;
|
|
138
|
+
}
|
|
139
|
+
} else if (direction === 'down') {
|
|
140
|
+
if (node.position.y >= originNode.position.y + originHeight * 0.5) {
|
|
141
|
+
newY += deltaPrimary;
|
|
142
|
+
moved = true;
|
|
143
|
+
}
|
|
144
|
+
} else if (direction === 'up') {
|
|
145
|
+
if (node.position.y <= originNode.position.y) {
|
|
146
|
+
newY -= deltaPrimary;
|
|
147
|
+
moved = true;
|
|
148
|
+
}
|
|
149
|
+
} else if (direction === 'vertical') {
|
|
150
|
+
// Row grows vertically:
|
|
151
|
+
// Nodes strictly above the origin node move up to accommodate childBounds above origin
|
|
152
|
+
// Nodes strictly below the origin node move down to accommodate childBounds below origin
|
|
153
|
+
// Horizontal position (x) is strictly preserved
|
|
154
|
+
if (node.position.y < originNode.position.y) {
|
|
155
|
+
if (effectiveDeltaUp > 0) {
|
|
156
|
+
newY -= effectiveDeltaUp;
|
|
157
|
+
moved = true;
|
|
158
|
+
}
|
|
159
|
+
} else if (node.position.y >= originNode.position.y + originHeight * 0.5) {
|
|
160
|
+
if (deltaDown > 0) {
|
|
161
|
+
newY += deltaDown;
|
|
162
|
+
moved = true;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (moved) {
|
|
168
|
+
displacedMap.set(node.id, { x: newX, y: newY });
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return displacedMap;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Pure hierarchical Dagre reflow displacement calculation.
|
|
177
|
+
* Calculates new coordinates for surrounding nodes using Dagre hierarchical layout,
|
|
178
|
+
* anchoring the origin node at its current position to eliminate layout jumping.
|
|
179
|
+
*/
|
|
180
|
+
export function calculateReflowDisplacement<
|
|
181
|
+
TNode extends CanvasNode = CanvasNode,
|
|
182
|
+
TEdge extends CanvasEdge = CanvasEdge
|
|
183
|
+
>(params: {
|
|
184
|
+
existingNodes: TNode[];
|
|
185
|
+
existingEdges: TEdge[];
|
|
186
|
+
originNode: TNode;
|
|
187
|
+
childNodes: TNode[];
|
|
188
|
+
childEdges?: TEdge[];
|
|
189
|
+
getNodesBounds?: (nodes: TNode[]) => Rect;
|
|
190
|
+
options?: CanvasLayoutOptions;
|
|
191
|
+
}): Map<string, XYPosition> {
|
|
192
|
+
const {
|
|
193
|
+
existingNodes,
|
|
194
|
+
existingEdges,
|
|
195
|
+
originNode,
|
|
196
|
+
childNodes,
|
|
197
|
+
childEdges = [],
|
|
198
|
+
options = {}
|
|
199
|
+
} = params;
|
|
200
|
+
|
|
201
|
+
const {
|
|
202
|
+
direction = 'LR',
|
|
203
|
+
ranksep = 80,
|
|
204
|
+
nodesep = 30,
|
|
205
|
+
defaultNodeWidth = 200,
|
|
206
|
+
defaultNodeHeight = 60
|
|
207
|
+
} = options;
|
|
208
|
+
|
|
209
|
+
const displacedMap = new Map<string, XYPosition>();
|
|
210
|
+
if (existingNodes.length === 0) return displacedMap;
|
|
211
|
+
|
|
212
|
+
const allNodes = [...existingNodes, ...childNodes];
|
|
213
|
+
const allEdges = [...existingEdges, ...childEdges];
|
|
214
|
+
|
|
215
|
+
const dagreGraph = new dagre.graphlib.Graph();
|
|
216
|
+
dagreGraph.setDefaultEdgeLabel(() => ({}));
|
|
217
|
+
dagreGraph.setGraph({ rankdir: direction, ranksep, nodesep });
|
|
218
|
+
|
|
219
|
+
const allNodeIds = new Set(allNodes.map((n) => n.id));
|
|
220
|
+
|
|
221
|
+
allNodes.forEach((node) => {
|
|
222
|
+
const nodeWidth = node.measured?.width ?? (node as any).width ?? defaultNodeWidth;
|
|
223
|
+
const nodeHeight = node.measured?.height ?? (node as any).height ?? defaultNodeHeight;
|
|
224
|
+
dagreGraph.setNode(node.id, { width: nodeWidth, height: nodeHeight });
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
allEdges.forEach((edge) => {
|
|
228
|
+
if (allNodeIds.has(edge.source) && allNodeIds.has(edge.target)) {
|
|
229
|
+
dagreGraph.setEdge(edge.source, edge.target);
|
|
230
|
+
}
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
dagre.layout(dagreGraph);
|
|
234
|
+
|
|
235
|
+
const dagreOrigin = dagreGraph.node(originNode.id);
|
|
236
|
+
if (!dagreOrigin) return displacedMap;
|
|
237
|
+
|
|
238
|
+
const originWidth = originNode.measured?.width ?? (originNode as any).width ?? defaultNodeWidth;
|
|
239
|
+
const originHeight = originNode.measured?.height ?? (originNode as any).height ?? defaultNodeHeight;
|
|
240
|
+
|
|
241
|
+
// Calculate offset so origin node remains anchored at its exact initial coordinates
|
|
242
|
+
const anchorOffsetX = originNode.position.x - (dagreOrigin.x - originWidth / 2);
|
|
243
|
+
const anchorOffsetY = originNode.position.y - (dagreOrigin.y - originHeight / 2);
|
|
244
|
+
|
|
245
|
+
existingNodes.forEach((node) => {
|
|
246
|
+
if (node.id === originNode.id) return;
|
|
247
|
+
|
|
248
|
+
const dagreNode = dagreGraph.node(node.id);
|
|
249
|
+
if (!dagreNode) return;
|
|
250
|
+
|
|
251
|
+
const nodeWidth = node.measured?.width ?? (node as any).width ?? defaultNodeWidth;
|
|
252
|
+
const nodeHeight = node.measured?.height ?? (node as any).height ?? defaultNodeHeight;
|
|
253
|
+
|
|
254
|
+
const targetX = dagreNode.x - nodeWidth / 2 + anchorOffsetX;
|
|
255
|
+
const targetY = dagreNode.y - nodeHeight / 2 + anchorOffsetY;
|
|
256
|
+
|
|
257
|
+
// Only include if position actually shifted
|
|
258
|
+
if (Math.abs(targetX - node.position.x) > 0.5 || Math.abs(targetY - node.position.y) > 0.5) {
|
|
259
|
+
displacedMap.set(node.id, { x: targetX, y: targetY });
|
|
260
|
+
}
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
return displacedMap;
|
|
264
|
+
}
|
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
import { cubicOut } from 'svelte/easing';
|
|
2
|
+
import type { Rect, XYPosition } from '@xyflow/svelte';
|
|
3
|
+
import type {
|
|
4
|
+
CanvasEdge,
|
|
5
|
+
CanvasNode,
|
|
6
|
+
CollapseNodeOptions,
|
|
7
|
+
ExplodeNodeOptions,
|
|
8
|
+
ExplosionSnapshot,
|
|
9
|
+
NodeTransition
|
|
10
|
+
} from './types.js';
|
|
11
|
+
import {
|
|
12
|
+
calculateDirectionalDisplacement,
|
|
13
|
+
calculateReflowDisplacement
|
|
14
|
+
} from './createCanvasDisplacement.js';
|
|
15
|
+
import {
|
|
16
|
+
createFanOutTrajectory,
|
|
17
|
+
linearTrajectory,
|
|
18
|
+
runMultiNodeTransition
|
|
19
|
+
} from './createCanvasTrajectory.js';
|
|
20
|
+
|
|
21
|
+
export interface CanvasGraphTarget<
|
|
22
|
+
TNode extends CanvasNode = CanvasNode,
|
|
23
|
+
TEdge extends CanvasEdge = CanvasEdge
|
|
24
|
+
> {
|
|
25
|
+
nodes: TNode[];
|
|
26
|
+
edges: TEdge[];
|
|
27
|
+
setNodes: (nodes: TNode[], triggerCallback?: boolean) => void;
|
|
28
|
+
setEdges: (edges: TEdge[], triggerCallback?: boolean) => void;
|
|
29
|
+
addNodeOrNodes: (nodes: TNode | TNode[], animateFrom?: XYPosition, save?: boolean) => void;
|
|
30
|
+
addEdges: (edges: TEdge[], save?: boolean) => void;
|
|
31
|
+
removeNodes: (nodes: TNode[], save?: boolean) => void;
|
|
32
|
+
removeEdges: (edges: TEdge[], save?: boolean) => void;
|
|
33
|
+
createEdge?: (sourceId: string, targetId: string, options?: any) => TEdge;
|
|
34
|
+
onSave?: () => void;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface CreateCanvasExplosionOptions<TNode extends CanvasNode = CanvasNode> {
|
|
38
|
+
getNodesBounds?: (nodes: TNode[]) => Rect;
|
|
39
|
+
onSave?: () => void;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Headless Svelte 5 rune managing canvas node explosion, spatial layout displacement,
|
|
44
|
+
* coordinated multi-node animation paths, and reversible collapsing.
|
|
45
|
+
*/
|
|
46
|
+
export function createCanvasExplosion<
|
|
47
|
+
TNode extends CanvasNode = CanvasNode,
|
|
48
|
+
TEdge extends CanvasEdge = CanvasEdge
|
|
49
|
+
>(
|
|
50
|
+
graph: CanvasGraphTarget<TNode, TEdge>,
|
|
51
|
+
options: CreateCanvasExplosionOptions<TNode> = {}
|
|
52
|
+
) {
|
|
53
|
+
let explodedNodeIds = $state<string[]>([]);
|
|
54
|
+
const snapshots = new Map<string, ExplosionSnapshot<TNode, TEdge>>();
|
|
55
|
+
let activeAnimationCancel: (() => void) | null = null;
|
|
56
|
+
|
|
57
|
+
function isExploded(nodeId: string): boolean {
|
|
58
|
+
return explodedNodeIds.includes(nodeId);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function explodeNode(
|
|
62
|
+
parentId: string,
|
|
63
|
+
explodeOptions: ExplodeNodeOptions<TNode, TEdge>
|
|
64
|
+
): boolean {
|
|
65
|
+
if (isExploded(parentId)) {
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const parentNode = graph.nodes.find((n) => n.id === parentId);
|
|
70
|
+
if (!parentNode) {
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const {
|
|
75
|
+
childNodes,
|
|
76
|
+
childEdges = [],
|
|
77
|
+
connectParentToChildren = 'all',
|
|
78
|
+
displacementStrategy = 'directional',
|
|
79
|
+
directionalOptions = {},
|
|
80
|
+
reflowOptions = {},
|
|
81
|
+
trajectory,
|
|
82
|
+
duration = 500,
|
|
83
|
+
staggerDelay = 20,
|
|
84
|
+
easing = cubicOut,
|
|
85
|
+
onComplete
|
|
86
|
+
} = explodeOptions;
|
|
87
|
+
|
|
88
|
+
if (childNodes.length === 0) {
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// 1. Calculate displacement for existing nodes
|
|
93
|
+
let displacementMap: Map<string, XYPosition>;
|
|
94
|
+
if (displacementStrategy === 'dagre') {
|
|
95
|
+
displacementMap = calculateReflowDisplacement({
|
|
96
|
+
existingNodes: graph.nodes,
|
|
97
|
+
existingEdges: graph.edges,
|
|
98
|
+
originNode: parentNode,
|
|
99
|
+
childNodes,
|
|
100
|
+
childEdges,
|
|
101
|
+
getNodesBounds: options.getNodesBounds,
|
|
102
|
+
options: {
|
|
103
|
+
...reflowOptions,
|
|
104
|
+
direction: (reflowOptions.direction as any) ?? 'LR'
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
} else if (displacementStrategy === 'none') {
|
|
108
|
+
displacementMap = new Map();
|
|
109
|
+
} else {
|
|
110
|
+
displacementMap = calculateDirectionalDisplacement({
|
|
111
|
+
existingNodes: graph.nodes,
|
|
112
|
+
originNode: parentNode,
|
|
113
|
+
childNodes,
|
|
114
|
+
getNodesBounds: options.getNodesBounds,
|
|
115
|
+
options: directionalOptions
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// 2. Build snapshot of displaced nodes before mutating positions
|
|
120
|
+
const displacedNodesSnapshot = Array.from(displacementMap.entries()).map(([id]) => {
|
|
121
|
+
const node = graph.nodes.find((n) => n.id === id);
|
|
122
|
+
return {
|
|
123
|
+
id,
|
|
124
|
+
originalPosition: { ...(node?.position ?? { x: 0, y: 0 }) }
|
|
125
|
+
};
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
// 3. Prepare child edges & parent connection edges
|
|
129
|
+
const edgesToAdd: TEdge[] = [...childEdges];
|
|
130
|
+
const defaultCreateEdge = (sourceId: string, targetId: string): TEdge => {
|
|
131
|
+
if (graph.createEdge) {
|
|
132
|
+
return graph.createEdge(sourceId, targetId);
|
|
133
|
+
}
|
|
134
|
+
return {
|
|
135
|
+
id: crypto.randomUUID(),
|
|
136
|
+
type: 'default',
|
|
137
|
+
source: sourceId,
|
|
138
|
+
target: targetId
|
|
139
|
+
} as unknown as TEdge;
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
if (connectParentToChildren === 'all' || connectParentToChildren === true) {
|
|
143
|
+
childNodes.forEach((child) => {
|
|
144
|
+
const exists = edgesToAdd.some((e) => e.source === parentId && e.target === child.id);
|
|
145
|
+
if (!exists) {
|
|
146
|
+
edgesToAdd.push(defaultCreateEdge(parentId, child.id));
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
} else if (connectParentToChildren === 'first' && childNodes.length > 0) {
|
|
150
|
+
const firstChild = childNodes[0];
|
|
151
|
+
const exists = edgesToAdd.some((e) => e.source === parentId && e.target === firstChild.id);
|
|
152
|
+
if (!exists) {
|
|
153
|
+
edgesToAdd.push(defaultCreateEdge(parentId, firstChild.id));
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const snapshot: ExplosionSnapshot<TNode, TEdge> = {
|
|
158
|
+
parentId,
|
|
159
|
+
childNodeIds: childNodes.map((n) => n.id),
|
|
160
|
+
childEdgeIds: edgesToAdd.map((e) => e.id),
|
|
161
|
+
displacedNodes: displacedNodesSnapshot,
|
|
162
|
+
timestamp: Date.now()
|
|
163
|
+
};
|
|
164
|
+
snapshots.set(parentId, snapshot);
|
|
165
|
+
|
|
166
|
+
// 4. Position child nodes initially at origin node coordinate
|
|
167
|
+
const originPos = { ...parentNode.position };
|
|
168
|
+
const targetPositions = childNodes.map((child) => ({ ...child.position }));
|
|
169
|
+
|
|
170
|
+
childNodes.forEach((child) => {
|
|
171
|
+
child.position = { ...originPos };
|
|
172
|
+
if (!child.data) child.data = {} as any;
|
|
173
|
+
(child.data as any).scale = 0;
|
|
174
|
+
(child.data as any).opacity = 0;
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
// Add nodes and edges to graph without saving yet
|
|
178
|
+
graph.addNodeOrNodes(childNodes, undefined, false);
|
|
179
|
+
if (edgesToAdd.length > 0) {
|
|
180
|
+
graph.addEdges(edgesToAdd, false);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// 5. Build transition list
|
|
184
|
+
const chosenTrajectory = trajectory ?? createFanOutTrajectory({ curvature: 0.35 });
|
|
185
|
+
const transitions: NodeTransition<TNode>[] = [];
|
|
186
|
+
const effectiveStagger = duration === 0 ? 0 : staggerDelay;
|
|
187
|
+
|
|
188
|
+
// Expanding child transitions
|
|
189
|
+
childNodes.forEach((child, i) => {
|
|
190
|
+
transitions.push({
|
|
191
|
+
node: child,
|
|
192
|
+
from: originPos,
|
|
193
|
+
to: targetPositions[i],
|
|
194
|
+
trajectory: chosenTrajectory,
|
|
195
|
+
delay: i * effectiveStagger,
|
|
196
|
+
duration,
|
|
197
|
+
easing,
|
|
198
|
+
index: i,
|
|
199
|
+
total: childNodes.length
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
// Displaced node transitions
|
|
204
|
+
displacementMap.forEach((targetPos, nodeId) => {
|
|
205
|
+
const node = graph.nodes.find((n) => n.id === nodeId);
|
|
206
|
+
if (node) {
|
|
207
|
+
transitions.push({
|
|
208
|
+
node,
|
|
209
|
+
from: { ...node.position },
|
|
210
|
+
to: targetPos,
|
|
211
|
+
trajectory: linearTrajectory,
|
|
212
|
+
duration,
|
|
213
|
+
easing
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
// 6. Run coordinated animation
|
|
219
|
+
if (activeAnimationCancel) {
|
|
220
|
+
activeAnimationCancel();
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
activeAnimationCancel = runMultiNodeTransition(transitions, {
|
|
224
|
+
defaultDuration: duration,
|
|
225
|
+
defaultEasing: easing,
|
|
226
|
+
onUpdate: () => {
|
|
227
|
+
graph.setNodes([...graph.nodes]);
|
|
228
|
+
},
|
|
229
|
+
onComplete: () => {
|
|
230
|
+
graph.setNodes([...graph.nodes]);
|
|
231
|
+
explodedNodeIds = [...explodedNodeIds, parentId];
|
|
232
|
+
activeAnimationCancel = null;
|
|
233
|
+
options.onSave?.();
|
|
234
|
+
graph.onSave?.();
|
|
235
|
+
onComplete?.();
|
|
236
|
+
}
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
return true;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function collapseNode(
|
|
243
|
+
parentId: string,
|
|
244
|
+
collapseOptions: CollapseNodeOptions = {}
|
|
245
|
+
): boolean {
|
|
246
|
+
const snapshot = snapshots.get(parentId);
|
|
247
|
+
if (!snapshot) {
|
|
248
|
+
return false;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const parentNode = graph.nodes.find((n) => n.id === parentId);
|
|
252
|
+
if (!parentNode) {
|
|
253
|
+
return false;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const {
|
|
257
|
+
duration = 400,
|
|
258
|
+
staggerDelay = 15,
|
|
259
|
+
easing = cubicOut,
|
|
260
|
+
trajectory,
|
|
261
|
+
onComplete
|
|
262
|
+
} = collapseOptions;
|
|
263
|
+
|
|
264
|
+
const childIdSet = new Set(snapshot.childNodeIds);
|
|
265
|
+
const activeChildNodes = graph.nodes.filter((n) => childIdSet.has(n.id));
|
|
266
|
+
const originPos = { ...parentNode.position };
|
|
267
|
+
|
|
268
|
+
// Reverse trajectory pulls nodes back toward parent while collapsing scale
|
|
269
|
+
const reverseTrajectory = trajectory ?? createFanOutTrajectory({
|
|
270
|
+
curvature: 0.2,
|
|
271
|
+
startScale: 1,
|
|
272
|
+
endScale: 0,
|
|
273
|
+
startOpacity: 1,
|
|
274
|
+
endOpacity: 0
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
const transitions: NodeTransition<TNode>[] = [];
|
|
278
|
+
const effectiveStagger = duration === 0 ? 0 : staggerDelay;
|
|
279
|
+
|
|
280
|
+
// Collapsing child transitions back into origin
|
|
281
|
+
activeChildNodes.forEach((child, i) => {
|
|
282
|
+
transitions.push({
|
|
283
|
+
node: child,
|
|
284
|
+
from: { ...child.position },
|
|
285
|
+
to: originPos,
|
|
286
|
+
trajectory: reverseTrajectory,
|
|
287
|
+
delay: (activeChildNodes.length - 1 - i) * effectiveStagger,
|
|
288
|
+
duration,
|
|
289
|
+
easing,
|
|
290
|
+
index: i,
|
|
291
|
+
total: activeChildNodes.length
|
|
292
|
+
});
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
// Displaced node restore transitions
|
|
296
|
+
snapshot.displacedNodes.forEach(({ id, originalPosition }) => {
|
|
297
|
+
const node = graph.nodes.find((n) => n.id === id);
|
|
298
|
+
if (node) {
|
|
299
|
+
transitions.push({
|
|
300
|
+
node,
|
|
301
|
+
from: { ...node.position },
|
|
302
|
+
to: originalPosition,
|
|
303
|
+
trajectory: linearTrajectory,
|
|
304
|
+
duration,
|
|
305
|
+
easing
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
if (activeAnimationCancel) {
|
|
311
|
+
activeAnimationCancel();
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
activeAnimationCancel = runMultiNodeTransition(transitions, {
|
|
315
|
+
defaultDuration: duration,
|
|
316
|
+
defaultEasing: easing,
|
|
317
|
+
onUpdate: () => {
|
|
318
|
+
graph.setNodes([...graph.nodes]);
|
|
319
|
+
},
|
|
320
|
+
onComplete: () => {
|
|
321
|
+
// Animation completed: prune child nodes and edges
|
|
322
|
+
graph.removeNodes(activeChildNodes, false);
|
|
323
|
+
|
|
324
|
+
const edgeIdSet = new Set(snapshot.childEdgeIds);
|
|
325
|
+
const edgesToRemove = graph.edges.filter((e) => edgeIdSet.has(e.id));
|
|
326
|
+
if (edgesToRemove.length > 0) {
|
|
327
|
+
graph.removeEdges(edgesToRemove, false);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
snapshots.delete(parentId);
|
|
331
|
+
explodedNodeIds = explodedNodeIds.filter((id) => id !== parentId);
|
|
332
|
+
activeAnimationCancel = null;
|
|
333
|
+
|
|
334
|
+
options.onSave?.();
|
|
335
|
+
graph.onSave?.();
|
|
336
|
+
onComplete?.();
|
|
337
|
+
}
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
return true;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function toggleExplode(
|
|
344
|
+
parentId: string,
|
|
345
|
+
explodeOptions: ExplodeNodeOptions<TNode, TEdge>,
|
|
346
|
+
collapseOptions?: CollapseNodeOptions
|
|
347
|
+
): boolean {
|
|
348
|
+
if (isExploded(parentId)) {
|
|
349
|
+
return collapseNode(parentId, collapseOptions);
|
|
350
|
+
}
|
|
351
|
+
return explodeNode(parentId, explodeOptions);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function getSnapshot(parentId: string) {
|
|
355
|
+
return snapshots.get(parentId);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
return {
|
|
359
|
+
get explodedNodeIds() {
|
|
360
|
+
return explodedNodeIds;
|
|
361
|
+
},
|
|
362
|
+
isExploded,
|
|
363
|
+
explodeNode,
|
|
364
|
+
collapseNode,
|
|
365
|
+
toggleExplode,
|
|
366
|
+
getSnapshot
|
|
367
|
+
};
|
|
368
|
+
}
|