vizcraft 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/.turbo/turbo-build.log +4 -0
- package/CHANGELOG.md +13 -0
- package/LICENSE +21 -0
- package/dist/animations.d.ts +22 -0
- package/dist/animations.js +30 -0
- package/dist/builder.d.ts +55 -0
- package/dist/builder.js +742 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +5 -0
- package/dist/index.test.d.ts +1 -0
- package/dist/index.test.js +15 -0
- package/dist/overlays.d.ts +38 -0
- package/dist/overlays.js +139 -0
- package/dist/styles.d.ts +1 -0
- package/dist/styles.js +67 -0
- package/dist/types.d.ts +86 -0
- package/dist/types.js +1 -0
- package/package.json +39 -0
- package/src/animations.ts +53 -0
- package/src/builder.ts +946 -0
- package/src/index.test.ts +17 -0
- package/src/index.ts +5 -0
- package/src/overlays.ts +203 -0
- package/src/styles.ts +67 -0
- package/src/types.ts +83 -0
- package/tsconfig.json +11 -0
package/dist/builder.js
ADDED
|
@@ -0,0 +1,742 @@
|
|
|
1
|
+
import { DEFAULT_VIZ_CSS } from './styles';
|
|
2
|
+
import { defaultCoreAnimationRegistry } from './animations';
|
|
3
|
+
import { defaultCoreOverlayRegistry } from './overlays';
|
|
4
|
+
class VizBuilderImpl {
|
|
5
|
+
_viewBox = { w: 800, h: 600 };
|
|
6
|
+
_nodes = new Map();
|
|
7
|
+
_edges = new Map();
|
|
8
|
+
_overlays = [];
|
|
9
|
+
_nodeOrder = [];
|
|
10
|
+
_edgeOrder = [];
|
|
11
|
+
_gridConfig = null;
|
|
12
|
+
/**
|
|
13
|
+
* Sets the view box.
|
|
14
|
+
* @param w The width of the view box
|
|
15
|
+
* @param h The height of the view box
|
|
16
|
+
* @returns The builder
|
|
17
|
+
*/
|
|
18
|
+
view(w, h) {
|
|
19
|
+
this._viewBox = { w, h };
|
|
20
|
+
return this;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Sets the grid configuration.
|
|
24
|
+
* @param cols The number of columns
|
|
25
|
+
* @param rows The number of rows
|
|
26
|
+
* @param padding The padding of the grid
|
|
27
|
+
* @returns The builder
|
|
28
|
+
*/
|
|
29
|
+
grid(cols, rows, padding = { x: 20, y: 20 }) {
|
|
30
|
+
this._gridConfig = { cols, rows, padding };
|
|
31
|
+
return this;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Adds an overlay to the scene.
|
|
35
|
+
* @param id The ID of the overlay
|
|
36
|
+
* @param params The parameters of the overlay
|
|
37
|
+
* @param key The key of the overlay
|
|
38
|
+
* @returns The builder
|
|
39
|
+
*/
|
|
40
|
+
overlay(id, params, key) {
|
|
41
|
+
this._overlays.push({ id, params, key });
|
|
42
|
+
return this;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Creates a node.
|
|
46
|
+
* @param id The ID of the node
|
|
47
|
+
* @returns The node builder
|
|
48
|
+
*/
|
|
49
|
+
node(id) {
|
|
50
|
+
if (!this._nodes.has(id)) {
|
|
51
|
+
// Set default position and shape
|
|
52
|
+
this._nodes.set(id, {
|
|
53
|
+
id,
|
|
54
|
+
pos: { x: 0, y: 0 },
|
|
55
|
+
shape: { kind: 'circle', r: 10 },
|
|
56
|
+
});
|
|
57
|
+
this._nodeOrder.push(id);
|
|
58
|
+
}
|
|
59
|
+
return new NodeBuilderImpl(this, this._nodes.get(id)); // The ! asserts that the node exists, because we just added it
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Creates an edge between two nodes.
|
|
63
|
+
* @param from The source node
|
|
64
|
+
* @param to The target node
|
|
65
|
+
* @param id The ID of the edge
|
|
66
|
+
* @returns The edge builder
|
|
67
|
+
*/
|
|
68
|
+
edge(from, to, id) {
|
|
69
|
+
const edgeId = id || `${from}->${to}`;
|
|
70
|
+
if (!this._edges.has(edgeId)) {
|
|
71
|
+
this._edges.set(edgeId, { id: edgeId, from, to });
|
|
72
|
+
this._edgeOrder.push(edgeId);
|
|
73
|
+
}
|
|
74
|
+
return new EdgeBuilderImpl(this, this._edges.get(edgeId));
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Builds the scene.
|
|
78
|
+
* @returns The scene
|
|
79
|
+
*/
|
|
80
|
+
build() {
|
|
81
|
+
this._edges.forEach((edge) => {
|
|
82
|
+
if (!this._nodes.has(edge.from)) {
|
|
83
|
+
console.warn(`VizBuilder: Edge ${edge.id} references missing source node ${edge.from}`);
|
|
84
|
+
}
|
|
85
|
+
if (!this._nodes.has(edge.to)) {
|
|
86
|
+
console.warn(`VizBuilder: Edge ${edge.id} references missing target node ${edge.to}`);
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
const nodes = this._nodeOrder.map((id) => this._nodes.get(id));
|
|
90
|
+
const edges = this._edgeOrder.map((id) => this._edges.get(id));
|
|
91
|
+
return {
|
|
92
|
+
viewBox: this._viewBox,
|
|
93
|
+
grid: this._gridConfig || undefined,
|
|
94
|
+
nodes,
|
|
95
|
+
edges,
|
|
96
|
+
overlays: this._overlays,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
_getGridConfig() {
|
|
100
|
+
return this._gridConfig;
|
|
101
|
+
}
|
|
102
|
+
_getViewBox() {
|
|
103
|
+
return this._viewBox;
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Returns the SVG string representation of the scene.
|
|
107
|
+
* @deprecated Use `mount` instead
|
|
108
|
+
*/
|
|
109
|
+
svg() {
|
|
110
|
+
const scene = this.build();
|
|
111
|
+
return this._renderSceneToSvg(scene);
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Mounts the scene to the DOM.
|
|
115
|
+
* @param container The container to mount the scene into
|
|
116
|
+
*/
|
|
117
|
+
mount(container) {
|
|
118
|
+
const scene = this.build();
|
|
119
|
+
this._renderSceneToDOM(scene, container);
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Renders the scene to the DOM.
|
|
123
|
+
* @param scene The scene to render
|
|
124
|
+
* @param container The container to render the scene into
|
|
125
|
+
*/
|
|
126
|
+
_renderSceneToDOM(scene, container) {
|
|
127
|
+
const { viewBox, nodes, edges, overlays } = scene;
|
|
128
|
+
const nodesById = new Map(nodes.map((n) => [n.id, n]));
|
|
129
|
+
const svgNS = 'http://www.w3.org/2000/svg';
|
|
130
|
+
let svg = container.querySelector('svg');
|
|
131
|
+
// Initial Render if SVG doesn't exist
|
|
132
|
+
if (!svg) {
|
|
133
|
+
container.innerHTML = ''; // Safety clear
|
|
134
|
+
svg = document.createElementNS(svgNS, 'svg');
|
|
135
|
+
svg.style.width = '100%';
|
|
136
|
+
svg.style.height = '100%';
|
|
137
|
+
svg.style.overflow = 'visible';
|
|
138
|
+
// Inject Styles
|
|
139
|
+
const style = document.createElement('style');
|
|
140
|
+
style.textContent = DEFAULT_VIZ_CSS;
|
|
141
|
+
svg.appendChild(style);
|
|
142
|
+
// Defs
|
|
143
|
+
const defs = document.createElementNS(svgNS, 'defs');
|
|
144
|
+
const marker = document.createElementNS(svgNS, 'marker');
|
|
145
|
+
marker.setAttribute('id', 'viz-arrow');
|
|
146
|
+
marker.setAttribute('markerWidth', '10');
|
|
147
|
+
marker.setAttribute('markerHeight', '7');
|
|
148
|
+
marker.setAttribute('refX', '9');
|
|
149
|
+
marker.setAttribute('refY', '3.5');
|
|
150
|
+
marker.setAttribute('orient', 'auto');
|
|
151
|
+
const poly = document.createElementNS(svgNS, 'polygon');
|
|
152
|
+
poly.setAttribute('points', '0 0, 10 3.5, 0 7');
|
|
153
|
+
poly.setAttribute('fill', 'currentColor');
|
|
154
|
+
marker.appendChild(poly);
|
|
155
|
+
defs.appendChild(marker);
|
|
156
|
+
svg.appendChild(defs);
|
|
157
|
+
// Layers
|
|
158
|
+
const edgeLayer = document.createElementNS(svgNS, 'g');
|
|
159
|
+
edgeLayer.setAttribute('class', 'viz-layer-edges');
|
|
160
|
+
svg.appendChild(edgeLayer);
|
|
161
|
+
const nodeLayer = document.createElementNS(svgNS, 'g');
|
|
162
|
+
nodeLayer.setAttribute('class', 'viz-layer-nodes');
|
|
163
|
+
svg.appendChild(nodeLayer);
|
|
164
|
+
const overlayLayer = document.createElementNS(svgNS, 'g');
|
|
165
|
+
overlayLayer.setAttribute('class', 'viz-layer-overlays');
|
|
166
|
+
svg.appendChild(overlayLayer);
|
|
167
|
+
container.appendChild(svg);
|
|
168
|
+
}
|
|
169
|
+
// Update ViewBox
|
|
170
|
+
svg.setAttribute('viewBox', `0 0 ${viewBox.w} ${viewBox.h}`);
|
|
171
|
+
const edgeLayer = svg.querySelector('.viz-layer-edges');
|
|
172
|
+
const nodeLayer = svg.querySelector('.viz-layer-nodes');
|
|
173
|
+
const overlayLayer = svg.querySelector('.viz-layer-overlays');
|
|
174
|
+
// --- 1. Reconcile Edges ---
|
|
175
|
+
const existingEdgeGroups = Array.from(edgeLayer.children).filter((el) => el.tagName === 'g');
|
|
176
|
+
const existingEdgesMap = new Map();
|
|
177
|
+
existingEdgeGroups.forEach((el) => {
|
|
178
|
+
const id = el.getAttribute('data-id');
|
|
179
|
+
if (id)
|
|
180
|
+
existingEdgesMap.set(id, el);
|
|
181
|
+
});
|
|
182
|
+
const processedEdgeIds = new Set();
|
|
183
|
+
edges.forEach((edge) => {
|
|
184
|
+
const start = nodesById.get(edge.from);
|
|
185
|
+
const end = nodesById.get(edge.to);
|
|
186
|
+
if (!start || !end)
|
|
187
|
+
return;
|
|
188
|
+
processedEdgeIds.add(edge.id);
|
|
189
|
+
let group = existingEdgesMap.get(edge.id);
|
|
190
|
+
if (!group) {
|
|
191
|
+
group = document.createElementNS(svgNS, 'g');
|
|
192
|
+
group.setAttribute('data-id', edge.id);
|
|
193
|
+
edgeLayer.appendChild(group);
|
|
194
|
+
// Initial creation of children
|
|
195
|
+
const line = document.createElementNS(svgNS, 'line');
|
|
196
|
+
line.setAttribute('class', 'viz-edge');
|
|
197
|
+
group.appendChild(line);
|
|
198
|
+
// Optional parts created on demand later, but structure expected
|
|
199
|
+
}
|
|
200
|
+
// Compute Classes & Styles
|
|
201
|
+
let classes = `viz-edge-group ${edge.className || ''}`;
|
|
202
|
+
// Reset styles
|
|
203
|
+
group.removeAttribute('style');
|
|
204
|
+
if (edge.animations) {
|
|
205
|
+
edge.animations.forEach((spec) => {
|
|
206
|
+
const renderer = defaultCoreAnimationRegistry.getEdgeRenderer(spec.id);
|
|
207
|
+
if (renderer) {
|
|
208
|
+
if (renderer.getClass)
|
|
209
|
+
classes += ` ${renderer.getClass({ spec, element: edge })}`;
|
|
210
|
+
if (renderer.getStyle) {
|
|
211
|
+
const s = renderer.getStyle({ spec, element: edge });
|
|
212
|
+
Object.entries(s).forEach(([k, v]) => {
|
|
213
|
+
group.style.setProperty(k, String(v));
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
group.setAttribute('class', classes);
|
|
220
|
+
// Update Line
|
|
221
|
+
const line = group.querySelector('.viz-edge');
|
|
222
|
+
line.setAttribute('x1', String(start.pos.x));
|
|
223
|
+
line.setAttribute('y1', String(start.pos.y));
|
|
224
|
+
line.setAttribute('x2', String(end.pos.x));
|
|
225
|
+
line.setAttribute('y2', String(end.pos.y));
|
|
226
|
+
line.setAttribute('stroke', 'currentColor');
|
|
227
|
+
if (edge.markerEnd === 'arrow') {
|
|
228
|
+
line.setAttribute('marker-end', 'url(#viz-arrow)');
|
|
229
|
+
}
|
|
230
|
+
else {
|
|
231
|
+
line.removeAttribute('marker-end');
|
|
232
|
+
}
|
|
233
|
+
const oldHit = group.querySelector('.viz-edge-hit');
|
|
234
|
+
if (oldHit)
|
|
235
|
+
oldHit.remove();
|
|
236
|
+
if (edge.hitArea || edge.onClick) {
|
|
237
|
+
const hit = document.createElementNS(svgNS, 'line');
|
|
238
|
+
hit.setAttribute('class', 'viz-edge-hit'); // Add class for selection
|
|
239
|
+
hit.setAttribute('x1', String(start.pos.x));
|
|
240
|
+
hit.setAttribute('y1', String(start.pos.y));
|
|
241
|
+
hit.setAttribute('x2', String(end.pos.x));
|
|
242
|
+
hit.setAttribute('y2', String(end.pos.y));
|
|
243
|
+
hit.setAttribute('stroke', 'transparent');
|
|
244
|
+
hit.setAttribute('stroke-width', String(edge.hitArea || 10));
|
|
245
|
+
hit.style.cursor = edge.onClick ? 'pointer' : '';
|
|
246
|
+
if (edge.onClick) {
|
|
247
|
+
hit.addEventListener('click', (e) => {
|
|
248
|
+
e.stopPropagation();
|
|
249
|
+
edge.onClick(edge.id, edge);
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
group.appendChild(hit);
|
|
253
|
+
}
|
|
254
|
+
// Label (Recreate vs Update)
|
|
255
|
+
const oldLabel = group.querySelector('.viz-edge-label');
|
|
256
|
+
if (oldLabel)
|
|
257
|
+
oldLabel.remove();
|
|
258
|
+
if (edge.label) {
|
|
259
|
+
const text = document.createElementNS(svgNS, 'text');
|
|
260
|
+
const mx = (start.pos.x + end.pos.x) / 2 + (edge.label.dx || 0);
|
|
261
|
+
const my = (start.pos.y + end.pos.y) / 2 + (edge.label.dy || 0);
|
|
262
|
+
text.setAttribute('x', String(mx));
|
|
263
|
+
text.setAttribute('y', String(my));
|
|
264
|
+
text.setAttribute('class', `viz-edge-label ${edge.label.className || ''}`);
|
|
265
|
+
text.setAttribute('text-anchor', 'middle');
|
|
266
|
+
text.setAttribute('dominant-baseline', 'middle');
|
|
267
|
+
text.textContent = edge.label.text;
|
|
268
|
+
group.appendChild(text);
|
|
269
|
+
}
|
|
270
|
+
});
|
|
271
|
+
// Remove stale edges
|
|
272
|
+
existingEdgeGroups.forEach((el) => {
|
|
273
|
+
const id = el.getAttribute('data-id');
|
|
274
|
+
if (id && !processedEdgeIds.has(id)) {
|
|
275
|
+
el.remove();
|
|
276
|
+
}
|
|
277
|
+
});
|
|
278
|
+
// --- 2. Reconcile Nodes ---
|
|
279
|
+
const existingNodeGroups = Array.from(nodeLayer.children).filter((el) => el.tagName === 'g');
|
|
280
|
+
const existingNodesMap = new Map();
|
|
281
|
+
existingNodeGroups.forEach((el) => {
|
|
282
|
+
const id = el.getAttribute('data-id');
|
|
283
|
+
if (id)
|
|
284
|
+
existingNodesMap.set(id, el);
|
|
285
|
+
});
|
|
286
|
+
const processedNodeIds = new Set();
|
|
287
|
+
nodes.forEach((node) => {
|
|
288
|
+
processedNodeIds.add(node.id);
|
|
289
|
+
let group = existingNodesMap.get(node.id);
|
|
290
|
+
if (!group) {
|
|
291
|
+
group = document.createElementNS(svgNS, 'g');
|
|
292
|
+
group.setAttribute('data-id', node.id);
|
|
293
|
+
nodeLayer.appendChild(group);
|
|
294
|
+
}
|
|
295
|
+
// Calculate Anim Classes
|
|
296
|
+
let classes = `viz-node-group ${node.className || ''}`;
|
|
297
|
+
group.removeAttribute('style');
|
|
298
|
+
if (node.animations) {
|
|
299
|
+
node.animations.forEach((spec) => {
|
|
300
|
+
const renderer = defaultCoreAnimationRegistry.getNodeRenderer(spec.id);
|
|
301
|
+
if (renderer) {
|
|
302
|
+
if (renderer.getClass)
|
|
303
|
+
classes += ` ${renderer.getClass({ spec, element: node })}`;
|
|
304
|
+
if (renderer.getStyle) {
|
|
305
|
+
const s = renderer.getStyle({ spec, element: node });
|
|
306
|
+
Object.entries(s).forEach(([k, v]) => {
|
|
307
|
+
group.style.setProperty(k, String(v));
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
group.setAttribute('class', classes);
|
|
314
|
+
// @ts-expect-error: Property _clickHandler does not exist on SVGGElement
|
|
315
|
+
group._clickHandler = node.onClick
|
|
316
|
+
? (e) => {
|
|
317
|
+
e.stopPropagation();
|
|
318
|
+
node.onClick(node.id, node);
|
|
319
|
+
}
|
|
320
|
+
: null;
|
|
321
|
+
if (!group.hasAttribute('data-click-initialized')) {
|
|
322
|
+
group.addEventListener('click', (e) => {
|
|
323
|
+
// @ts-expect-error: Property _clickHandler does not exist on SVGGElement
|
|
324
|
+
if (group._clickHandler)
|
|
325
|
+
group._clickHandler(e);
|
|
326
|
+
});
|
|
327
|
+
group.setAttribute('data-click-initialized', 'true');
|
|
328
|
+
}
|
|
329
|
+
group.style.cursor = node.onClick ? 'pointer' : '';
|
|
330
|
+
// Shape (Update geometry)
|
|
331
|
+
const { x, y } = node.pos;
|
|
332
|
+
// Ideally we reuse the shape element if the kind hasn't changed.
|
|
333
|
+
// Assuming kind rarely changes for same ID.
|
|
334
|
+
let shape = group.querySelector('.viz-node-shape');
|
|
335
|
+
// If shape doesn't exist or kind changed (simplified check: just recreate if kind mismatch logic needed,
|
|
336
|
+
// but here we just check tag name for simplicity or assume kind is stable).
|
|
337
|
+
const kindMap = {
|
|
338
|
+
circle: 'circle',
|
|
339
|
+
rect: 'rect',
|
|
340
|
+
diamond: 'polygon',
|
|
341
|
+
};
|
|
342
|
+
const expectedTag = kindMap[node.shape.kind];
|
|
343
|
+
if (!shape || shape.tagName !== expectedTag) {
|
|
344
|
+
if (shape)
|
|
345
|
+
shape.remove();
|
|
346
|
+
if (node.shape.kind === 'circle') {
|
|
347
|
+
shape = document.createElementNS(svgNS, 'circle');
|
|
348
|
+
}
|
|
349
|
+
else if (node.shape.kind === 'rect') {
|
|
350
|
+
shape = document.createElementNS(svgNS, 'rect');
|
|
351
|
+
}
|
|
352
|
+
else if (node.shape.kind === 'diamond') {
|
|
353
|
+
shape = document.createElementNS(svgNS, 'polygon');
|
|
354
|
+
}
|
|
355
|
+
shape.setAttribute('class', 'viz-node-shape');
|
|
356
|
+
group.prepend(shape); // Shape always at bottom
|
|
357
|
+
}
|
|
358
|
+
// Update Shape Attributes
|
|
359
|
+
if (node.shape.kind === 'circle') {
|
|
360
|
+
shape.setAttribute('cx', String(x));
|
|
361
|
+
shape.setAttribute('cy', String(y));
|
|
362
|
+
shape.setAttribute('r', String(node.shape.r));
|
|
363
|
+
}
|
|
364
|
+
else if (node.shape.kind === 'rect') {
|
|
365
|
+
shape.setAttribute('x', String(x - node.shape.w / 2));
|
|
366
|
+
shape.setAttribute('y', String(y - node.shape.h / 2));
|
|
367
|
+
shape.setAttribute('width', String(node.shape.w));
|
|
368
|
+
shape.setAttribute('height', String(node.shape.h));
|
|
369
|
+
if (node.shape.rx)
|
|
370
|
+
shape.setAttribute('rx', String(node.shape.rx));
|
|
371
|
+
}
|
|
372
|
+
else if (node.shape.kind === 'diamond') {
|
|
373
|
+
const hw = node.shape.w / 2;
|
|
374
|
+
const hh = node.shape.h / 2;
|
|
375
|
+
const pts = `${x},${y - hh} ${x + hw},${y} ${x},${y + hh} ${x - hw},${y}`;
|
|
376
|
+
shape.setAttribute('points', pts);
|
|
377
|
+
}
|
|
378
|
+
// Label (Recreate for simplicity as usually just text/pos changes)
|
|
379
|
+
let label = group.querySelector('.viz-node-label');
|
|
380
|
+
if (!label && node.label) {
|
|
381
|
+
label = document.createElementNS(svgNS, 'text');
|
|
382
|
+
label.setAttribute('class', 'viz-node-label');
|
|
383
|
+
label.setAttribute('text-anchor', 'middle');
|
|
384
|
+
label.setAttribute('dominant-baseline', 'middle');
|
|
385
|
+
group.appendChild(label);
|
|
386
|
+
}
|
|
387
|
+
if (node.label) {
|
|
388
|
+
const lx = x + (node.label.dx || 0);
|
|
389
|
+
const ly = y + (node.label.dy || 0);
|
|
390
|
+
label.setAttribute('x', String(lx));
|
|
391
|
+
label.setAttribute('y', String(ly));
|
|
392
|
+
// Update class carefully to preserve 'viz-node-label'
|
|
393
|
+
label.setAttribute('class', `viz-node-label ${node.label.className || ''}`);
|
|
394
|
+
label.textContent = node.label.text;
|
|
395
|
+
}
|
|
396
|
+
else if (label) {
|
|
397
|
+
label.remove();
|
|
398
|
+
}
|
|
399
|
+
});
|
|
400
|
+
// Remove stale nodes
|
|
401
|
+
existingNodeGroups.forEach((el) => {
|
|
402
|
+
const id = el.getAttribute('data-id');
|
|
403
|
+
if (id && !processedNodeIds.has(id)) {
|
|
404
|
+
el.remove();
|
|
405
|
+
}
|
|
406
|
+
});
|
|
407
|
+
// --- 3. Reconcile Overlays (Smart) ---
|
|
408
|
+
// 1. Map existing overlay groups
|
|
409
|
+
const existingOverlayGroups = Array.from(overlayLayer.children).filter((el) => el.tagName === 'g');
|
|
410
|
+
const existingOverlaysMap = new Map();
|
|
411
|
+
existingOverlayGroups.forEach((el) => {
|
|
412
|
+
const id = el.getAttribute('data-overlay-id');
|
|
413
|
+
if (id)
|
|
414
|
+
existingOverlaysMap.set(id, el);
|
|
415
|
+
});
|
|
416
|
+
const processedOverlayIds = new Set();
|
|
417
|
+
if (overlays && overlays.length > 0) {
|
|
418
|
+
overlays.forEach((spec) => {
|
|
419
|
+
const renderer = defaultCoreOverlayRegistry.get(spec.id);
|
|
420
|
+
if (renderer) {
|
|
421
|
+
const uniqueKey = spec.key || spec.id;
|
|
422
|
+
processedOverlayIds.add(uniqueKey);
|
|
423
|
+
let group = existingOverlaysMap.get(uniqueKey);
|
|
424
|
+
if (!group) {
|
|
425
|
+
group = document.createElementNS(svgNS, 'g');
|
|
426
|
+
group.setAttribute('data-overlay-id', uniqueKey);
|
|
427
|
+
group.setAttribute('class', `viz-overlay-${spec.id}`);
|
|
428
|
+
overlayLayer.appendChild(group);
|
|
429
|
+
}
|
|
430
|
+
const ctx = {
|
|
431
|
+
spec,
|
|
432
|
+
nodesById,
|
|
433
|
+
edgesById: new Map(edges.map((e) => [e.id, e])),
|
|
434
|
+
scene,
|
|
435
|
+
};
|
|
436
|
+
if (renderer.update) {
|
|
437
|
+
renderer.update(ctx, group);
|
|
438
|
+
}
|
|
439
|
+
else {
|
|
440
|
+
// Fallback: full re-render of this overlay's content
|
|
441
|
+
group.innerHTML = renderer.render(ctx);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
// Remove stale overlays
|
|
447
|
+
existingOverlayGroups.forEach((el) => {
|
|
448
|
+
const id = el.getAttribute('data-overlay-id');
|
|
449
|
+
if (id && !processedOverlayIds.has(id)) {
|
|
450
|
+
el.remove();
|
|
451
|
+
}
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
/**
|
|
455
|
+
* Returns the SVG string representation of the scene.
|
|
456
|
+
* @deprecated The use of this method is deprecated. Use `mount` instead.
|
|
457
|
+
* @param scene The scene to render
|
|
458
|
+
* @returns The SVG string representation of the scene
|
|
459
|
+
*/
|
|
460
|
+
_renderSceneToSvg(scene) {
|
|
461
|
+
const { viewBox, nodes, edges, overlays } = scene;
|
|
462
|
+
const nodesById = new Map(nodes.map((n) => [n.id, n]));
|
|
463
|
+
const edgesById = new Map(edges.map((e) => [e.id, e]));
|
|
464
|
+
let svgContent = `<svg viewBox="0 0 ${viewBox.w} ${viewBox.h}" xmlns="http://www.w3.org/2000/svg">`;
|
|
465
|
+
// Inject Styles
|
|
466
|
+
svgContent += `<style>${DEFAULT_VIZ_CSS}</style>`;
|
|
467
|
+
// Defs (Arrow Marker)
|
|
468
|
+
svgContent += `
|
|
469
|
+
<defs>
|
|
470
|
+
<marker id="viz-arrow" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
|
|
471
|
+
<polygon points="0 0, 10 3.5, 0 7" fill="currentColor" />
|
|
472
|
+
</marker>
|
|
473
|
+
</defs>`;
|
|
474
|
+
// Render Edges
|
|
475
|
+
svgContent += '<g class="viz-layer-edges">';
|
|
476
|
+
edges.forEach((edge) => {
|
|
477
|
+
const start = nodesById.get(edge.from);
|
|
478
|
+
const end = nodesById.get(edge.to);
|
|
479
|
+
if (!start || !end)
|
|
480
|
+
return;
|
|
481
|
+
// Animations
|
|
482
|
+
let animClasses = '';
|
|
483
|
+
let animStyleStr = '';
|
|
484
|
+
if (edge.animations) {
|
|
485
|
+
edge.animations.forEach((spec) => {
|
|
486
|
+
const renderer = defaultCoreAnimationRegistry.getEdgeRenderer(spec.id);
|
|
487
|
+
if (renderer) {
|
|
488
|
+
if (renderer.getClass) {
|
|
489
|
+
animClasses += ` ${renderer.getClass({ spec, element: edge })}`;
|
|
490
|
+
}
|
|
491
|
+
if (renderer.getStyle) {
|
|
492
|
+
const styles = renderer.getStyle({ spec, element: edge });
|
|
493
|
+
Object.entries(styles).forEach(([k, v]) => {
|
|
494
|
+
animStyleStr += `${k}: ${v}; `;
|
|
495
|
+
});
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
});
|
|
499
|
+
}
|
|
500
|
+
const markerEnd = edge.markerEnd === 'arrow' ? 'marker-end="url(#viz-arrow)"' : '';
|
|
501
|
+
svgContent += `<g class="viz-edge-group ${edge.className || ''} ${animClasses}" style="${animStyleStr}">`;
|
|
502
|
+
svgContent += `<line x1="${start.pos.x}" y1="${start.pos.y}" x2="${end.pos.x}" y2="${end.pos.y}" class="viz-edge" ${markerEnd} stroke="currentColor" />`;
|
|
503
|
+
// Edge Label
|
|
504
|
+
if (edge.label) {
|
|
505
|
+
const mx = (start.pos.x + end.pos.x) / 2 + (edge.label.dx || 0);
|
|
506
|
+
const my = (start.pos.y + end.pos.y) / 2 + (edge.label.dy || 0);
|
|
507
|
+
const labelClass = `viz-edge-label ${edge.label.className || ''}`;
|
|
508
|
+
svgContent += `<text x="${mx}" y="${my}" class="${labelClass}" text-anchor="middle" dominant-baseline="middle">${edge.label.text}</text>`;
|
|
509
|
+
}
|
|
510
|
+
svgContent += '</g>';
|
|
511
|
+
});
|
|
512
|
+
svgContent += '</g>';
|
|
513
|
+
// Render Nodes
|
|
514
|
+
svgContent += '<g class="viz-layer-nodes">';
|
|
515
|
+
nodes.forEach((node) => {
|
|
516
|
+
const { x, y } = node.pos;
|
|
517
|
+
const { shape } = node;
|
|
518
|
+
// Animations (Nodes)
|
|
519
|
+
let animClasses = '';
|
|
520
|
+
let animStyleStr = '';
|
|
521
|
+
if (node.animations) {
|
|
522
|
+
node.animations.forEach((spec) => {
|
|
523
|
+
const renderer = defaultCoreAnimationRegistry.getNodeRenderer(spec.id);
|
|
524
|
+
if (renderer) {
|
|
525
|
+
if (renderer.getClass) {
|
|
526
|
+
animClasses += ` ${renderer.getClass({ spec, element: node })}`;
|
|
527
|
+
}
|
|
528
|
+
if (renderer.getStyle) {
|
|
529
|
+
const styles = renderer.getStyle({ spec, element: node });
|
|
530
|
+
Object.entries(styles).forEach(([k, v]) => {
|
|
531
|
+
animStyleStr += `${k}: ${v}; `;
|
|
532
|
+
});
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
});
|
|
536
|
+
}
|
|
537
|
+
const className = `viz-node-group ${node.className || ''} ${animClasses}`;
|
|
538
|
+
svgContent += `<g class="${className}" style="${animStyleStr}">`;
|
|
539
|
+
// Shape
|
|
540
|
+
if (shape.kind === 'circle') {
|
|
541
|
+
svgContent += `<circle cx="${x}" cy="${y}" r="${shape.r}" class="viz-node-shape" />`;
|
|
542
|
+
}
|
|
543
|
+
else if (shape.kind === 'rect') {
|
|
544
|
+
svgContent += `<rect x="${x - shape.w / 2}" y="${y - shape.h / 2}" width="${shape.w}" height="${shape.h}" rx="${shape.rx || 0}" class="viz-node-shape" />`;
|
|
545
|
+
}
|
|
546
|
+
else if (shape.kind === 'diamond') {
|
|
547
|
+
const hw = shape.w / 2;
|
|
548
|
+
const hh = shape.h / 2;
|
|
549
|
+
const pts = `${x},${y - hh} ${x + hw},${y} ${x},${y + hh} ${x - hw},${y}`;
|
|
550
|
+
svgContent += `<polygon points="${pts}" class="viz-node-shape" />`;
|
|
551
|
+
}
|
|
552
|
+
// Label
|
|
553
|
+
if (node.label) {
|
|
554
|
+
const lx = x + (node.label.dx || 0);
|
|
555
|
+
const ly = y + (node.label.dy || 0);
|
|
556
|
+
const labelClass = `viz-node-label ${node.label.className || ''}`;
|
|
557
|
+
svgContent += `<text x="${lx}" y="${ly}" class="${labelClass}" text-anchor="middle" dominant-baseline="middle">${node.label.text}</text>`;
|
|
558
|
+
}
|
|
559
|
+
svgContent += '</g>';
|
|
560
|
+
});
|
|
561
|
+
svgContent += '</g>';
|
|
562
|
+
// Render Overlays
|
|
563
|
+
if (overlays && overlays.length > 0) {
|
|
564
|
+
svgContent += '<g class="viz-layer-overlays">';
|
|
565
|
+
overlays.forEach((spec) => {
|
|
566
|
+
const renderer = defaultCoreOverlayRegistry.get(spec.id);
|
|
567
|
+
if (renderer) {
|
|
568
|
+
svgContent += renderer.render({ spec, nodesById, edgesById, scene });
|
|
569
|
+
}
|
|
570
|
+
});
|
|
571
|
+
svgContent += '</g>';
|
|
572
|
+
}
|
|
573
|
+
svgContent += '</svg>';
|
|
574
|
+
return svgContent;
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
class NodeBuilderImpl {
|
|
578
|
+
parent;
|
|
579
|
+
nodeDef;
|
|
580
|
+
constructor(parent, nodeDef) {
|
|
581
|
+
this.parent = parent;
|
|
582
|
+
this.nodeDef = nodeDef;
|
|
583
|
+
}
|
|
584
|
+
at(x, y) {
|
|
585
|
+
this.nodeDef.pos = { x, y };
|
|
586
|
+
return this;
|
|
587
|
+
}
|
|
588
|
+
cell(col, row, align = 'center') {
|
|
589
|
+
const grid = this.parent._getGridConfig();
|
|
590
|
+
if (!grid) {
|
|
591
|
+
console.warn('VizBuilder: .cell() called but no grid configured. Use .grid() first.');
|
|
592
|
+
return this;
|
|
593
|
+
}
|
|
594
|
+
const view = this.parent._getViewBox();
|
|
595
|
+
const availableW = view.w - grid.padding.x * 2;
|
|
596
|
+
const availableH = view.h - grid.padding.y * 2;
|
|
597
|
+
const cellW = availableW / grid.cols;
|
|
598
|
+
const cellH = availableH / grid.rows;
|
|
599
|
+
let x = grid.padding.x + col * cellW;
|
|
600
|
+
let y = grid.padding.y + row * cellH;
|
|
601
|
+
// Alignment adjustments
|
|
602
|
+
if (align === 'center') {
|
|
603
|
+
x += cellW / 2;
|
|
604
|
+
y += cellH / 2;
|
|
605
|
+
}
|
|
606
|
+
else if (align === 'end') {
|
|
607
|
+
x += cellW;
|
|
608
|
+
y += cellH;
|
|
609
|
+
}
|
|
610
|
+
this.nodeDef.pos = { x, y };
|
|
611
|
+
return this;
|
|
612
|
+
}
|
|
613
|
+
circle(r) {
|
|
614
|
+
this.nodeDef.shape = { kind: 'circle', r };
|
|
615
|
+
return this;
|
|
616
|
+
}
|
|
617
|
+
rect(w, h, rx) {
|
|
618
|
+
this.nodeDef.shape = { kind: 'rect', w, h, rx };
|
|
619
|
+
return this;
|
|
620
|
+
}
|
|
621
|
+
diamond(w, h) {
|
|
622
|
+
this.nodeDef.shape = { kind: 'diamond', w, h };
|
|
623
|
+
return this;
|
|
624
|
+
}
|
|
625
|
+
label(text, opts) {
|
|
626
|
+
this.nodeDef.label = { text, ...opts };
|
|
627
|
+
return this;
|
|
628
|
+
}
|
|
629
|
+
class(name) {
|
|
630
|
+
if (this.nodeDef.className) {
|
|
631
|
+
this.nodeDef.className += ` ${name}`;
|
|
632
|
+
}
|
|
633
|
+
else {
|
|
634
|
+
this.nodeDef.className = name;
|
|
635
|
+
}
|
|
636
|
+
return this;
|
|
637
|
+
}
|
|
638
|
+
animate(type, config) {
|
|
639
|
+
if (!this.nodeDef.animations) {
|
|
640
|
+
this.nodeDef.animations = [];
|
|
641
|
+
}
|
|
642
|
+
this.nodeDef.animations.push({ id: type, params: config });
|
|
643
|
+
return this;
|
|
644
|
+
}
|
|
645
|
+
data(payload) {
|
|
646
|
+
this.nodeDef.data = payload;
|
|
647
|
+
return this;
|
|
648
|
+
}
|
|
649
|
+
onClick(handler) {
|
|
650
|
+
this.nodeDef.onClick = handler;
|
|
651
|
+
return this;
|
|
652
|
+
}
|
|
653
|
+
done() {
|
|
654
|
+
return this.parent;
|
|
655
|
+
}
|
|
656
|
+
// Chaining
|
|
657
|
+
node(id) {
|
|
658
|
+
return this.parent.node(id);
|
|
659
|
+
}
|
|
660
|
+
edge(from, to, id) {
|
|
661
|
+
return this.parent.edge(from, to, id);
|
|
662
|
+
}
|
|
663
|
+
overlay(id, params, key) {
|
|
664
|
+
return this.parent.overlay(id, params, key);
|
|
665
|
+
}
|
|
666
|
+
build() {
|
|
667
|
+
return this.parent.build();
|
|
668
|
+
}
|
|
669
|
+
svg() {
|
|
670
|
+
return this.parent.svg();
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
class EdgeBuilderImpl {
|
|
674
|
+
parent;
|
|
675
|
+
edgeDef;
|
|
676
|
+
constructor(parent, edgeDef) {
|
|
677
|
+
this.parent = parent;
|
|
678
|
+
this.edgeDef = edgeDef;
|
|
679
|
+
}
|
|
680
|
+
straight() {
|
|
681
|
+
// No-op for now as it is default
|
|
682
|
+
return this;
|
|
683
|
+
}
|
|
684
|
+
label(text, opts) {
|
|
685
|
+
this.edgeDef.label = { position: 'mid', text, ...opts };
|
|
686
|
+
return this;
|
|
687
|
+
}
|
|
688
|
+
arrow(enabled = true) {
|
|
689
|
+
this.edgeDef.markerEnd = enabled ? 'arrow' : 'none';
|
|
690
|
+
return this;
|
|
691
|
+
}
|
|
692
|
+
class(name) {
|
|
693
|
+
if (this.edgeDef.className) {
|
|
694
|
+
this.edgeDef.className += ` ${name}`;
|
|
695
|
+
}
|
|
696
|
+
else {
|
|
697
|
+
this.edgeDef.className = name;
|
|
698
|
+
}
|
|
699
|
+
return this;
|
|
700
|
+
}
|
|
701
|
+
animate(type, config) {
|
|
702
|
+
if (!this.edgeDef.animations) {
|
|
703
|
+
this.edgeDef.animations = [];
|
|
704
|
+
}
|
|
705
|
+
this.edgeDef.animations.push({ id: type, params: config });
|
|
706
|
+
return this;
|
|
707
|
+
}
|
|
708
|
+
hitArea(px) {
|
|
709
|
+
this.edgeDef.hitArea = px;
|
|
710
|
+
return this;
|
|
711
|
+
}
|
|
712
|
+
data(payload) {
|
|
713
|
+
this.edgeDef.data = payload;
|
|
714
|
+
return this;
|
|
715
|
+
}
|
|
716
|
+
onClick(handler) {
|
|
717
|
+
this.edgeDef.onClick = handler;
|
|
718
|
+
return this;
|
|
719
|
+
}
|
|
720
|
+
done() {
|
|
721
|
+
return this.parent;
|
|
722
|
+
}
|
|
723
|
+
// Chaining
|
|
724
|
+
node(id) {
|
|
725
|
+
return this.parent.node(id);
|
|
726
|
+
}
|
|
727
|
+
edge(from, to, id) {
|
|
728
|
+
return this.parent.edge(from, to, id);
|
|
729
|
+
}
|
|
730
|
+
overlay(id, params, key) {
|
|
731
|
+
return this.parent.overlay(id, params, key);
|
|
732
|
+
}
|
|
733
|
+
build() {
|
|
734
|
+
return this.parent.build();
|
|
735
|
+
}
|
|
736
|
+
svg() {
|
|
737
|
+
return this.parent.svg();
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
export function viz() {
|
|
741
|
+
return new VizBuilderImpl();
|
|
742
|
+
}
|