vizcraft 0.2.2 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/LICENSE.txt +21 -0
- package/README.md +104 -2
- package/dist/anim/animationBuilder.d.ts +2 -0
- package/dist/anim/animationBuilder.js +6 -1
- package/dist/anim/spec.d.ts +1 -1
- package/dist/anim/vizcraftAdapter.js +68 -1
- package/dist/builder.d.ts +70 -2
- package/dist/builder.js +719 -118
- package/dist/edgeLabels.d.ts +15 -0
- package/dist/edgeLabels.js +26 -0
- package/dist/edgePaths.d.ts +43 -0
- package/dist/edgePaths.js +253 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/overlayBuilder.d.ts +50 -0
- package/dist/overlayBuilder.js +80 -0
- package/dist/overlays.d.ts +113 -21
- package/dist/overlays.js +319 -1
- package/dist/runtimePatcher.d.ts +3 -3
- package/dist/runtimePatcher.js +231 -31
- package/dist/shapes.d.ts +25 -3
- package/dist/shapes.js +1009 -0
- package/dist/styles.d.ts +1 -1
- package/dist/styles.js +24 -0
- package/dist/types.d.ts +207 -1
- package/dist/types.js +2 -1
- package/package.json +1 -1
- package/dist/anim/player.test.d.ts +0 -1
- package/dist/anim/player.test.js +0 -49
- package/dist/index.test.d.ts +0 -1
- package/dist/index.test.js +0 -66
package/dist/builder.js
CHANGED
|
@@ -1,13 +1,112 @@
|
|
|
1
|
+
import { OVERLAY_RUNTIME_DIRTY } from './types';
|
|
1
2
|
import { DEFAULT_VIZ_CSS } from './styles';
|
|
2
3
|
import { defaultCoreAnimationRegistry } from './animations';
|
|
3
4
|
import { defaultCoreOverlayRegistry } from './overlays';
|
|
5
|
+
import { OverlayBuilder } from './overlayBuilder';
|
|
4
6
|
import { createRuntimePatchCtx, patchRuntime, } from './runtimePatcher';
|
|
7
|
+
import { computeEdgePath, computeEdgeEndpoints } from './edgePaths';
|
|
8
|
+
import { resolveEdgeLabelPosition, collectEdgeLabels } from './edgeLabels';
|
|
9
|
+
/**
|
|
10
|
+
* Sanitise a CSS color value for use as a suffix in an SVG marker `id`.
|
|
11
|
+
* Non-alphanumeric characters are replaced with underscores.
|
|
12
|
+
*/
|
|
13
|
+
function colorToMarkerSuffix(color) {
|
|
14
|
+
return color.replace(/[^a-zA-Z0-9]/g, '_');
|
|
15
|
+
}
|
|
16
|
+
/** Return the marker id to use for a marker type with an optional custom stroke and position. */
|
|
17
|
+
function markerIdFor(markerType, stroke, position = 'end') {
|
|
18
|
+
if (markerType === 'none')
|
|
19
|
+
return '';
|
|
20
|
+
const base = `viz-${markerType}`;
|
|
21
|
+
const suffix = position === 'start' ? '-start' : '';
|
|
22
|
+
return stroke
|
|
23
|
+
? `${base}${suffix}-${colorToMarkerSuffix(stroke)}`
|
|
24
|
+
: `${base}${suffix}`;
|
|
25
|
+
}
|
|
26
|
+
/** @deprecated Use markerIdFor instead. Kept for backward compatibility. */
|
|
27
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
28
|
+
function arrowMarkerIdFor(stroke) {
|
|
29
|
+
return markerIdFor('arrow', stroke);
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Generate SVG markup for a single marker definition.
|
|
33
|
+
* @param markerType The type of marker
|
|
34
|
+
* @param color Fill color for the marker (or stroke for open markers)
|
|
35
|
+
* @param id The marker element id
|
|
36
|
+
* @param position Whether this marker is used at the start or end of an edge
|
|
37
|
+
*/
|
|
38
|
+
/** Escape a string for safe use inside an XML/SVG attribute value. */
|
|
39
|
+
function escapeXmlAttr(value) {
|
|
40
|
+
return value
|
|
41
|
+
.replace(/&/g, '&')
|
|
42
|
+
.replace(/"/g, '"')
|
|
43
|
+
.replace(/</g, '<')
|
|
44
|
+
.replace(/>/g, '>');
|
|
45
|
+
}
|
|
46
|
+
function generateMarkerSvg(markerType, color, id, position = 'end') {
|
|
47
|
+
if (markerType === 'none')
|
|
48
|
+
return '';
|
|
49
|
+
// Sanitise color for safe interpolation into SVG attribute strings
|
|
50
|
+
const safeColor = escapeXmlAttr(color);
|
|
51
|
+
// Common marker properties
|
|
52
|
+
const viewBox = '0 0 10 10';
|
|
53
|
+
// refX=9 positions the marker tip at the path endpoint.
|
|
54
|
+
// Start markers use orient="auto-start-reverse" which flips the marker,
|
|
55
|
+
// so the same refX=9 keeps the tip at the node boundary.
|
|
56
|
+
const refX = '9';
|
|
57
|
+
const refY = '5'; // Center vertically
|
|
58
|
+
const markerWidth = '10';
|
|
59
|
+
const markerHeight = '10';
|
|
60
|
+
let content = '';
|
|
61
|
+
switch (markerType) {
|
|
62
|
+
case 'arrow':
|
|
63
|
+
// Filled triangle
|
|
64
|
+
content = `<polygon points="0,2 10,5 0,8" fill="${safeColor}" />`;
|
|
65
|
+
break;
|
|
66
|
+
case 'arrowOpen':
|
|
67
|
+
// Open V-shape triangle (white fill hides the edge line behind the marker)
|
|
68
|
+
content = `<polyline points="0,2 10,5 0,8" fill="white" stroke="${safeColor}" stroke-width="1.5" stroke-linejoin="miter" />`;
|
|
69
|
+
break;
|
|
70
|
+
case 'diamond':
|
|
71
|
+
// Filled diamond
|
|
72
|
+
content = `<polygon points="0,5 5,2 10,5 5,8" fill="${safeColor}" />`;
|
|
73
|
+
break;
|
|
74
|
+
case 'diamondOpen':
|
|
75
|
+
// Open diamond (white fill hides the edge line behind the marker)
|
|
76
|
+
content = `<polygon points="0,5 5,2 10,5 5,8" fill="white" stroke="${safeColor}" stroke-width="1.5" />`;
|
|
77
|
+
break;
|
|
78
|
+
case 'circle':
|
|
79
|
+
// Filled circle
|
|
80
|
+
content = `<circle cx="5" cy="5" r="3" fill="${safeColor}" />`;
|
|
81
|
+
break;
|
|
82
|
+
case 'circleOpen':
|
|
83
|
+
// Open circle (white fill hides the edge line behind the marker)
|
|
84
|
+
content = `<circle cx="5" cy="5" r="3" fill="white" stroke="${safeColor}" stroke-width="1.5" />`;
|
|
85
|
+
break;
|
|
86
|
+
case 'square':
|
|
87
|
+
// Filled square
|
|
88
|
+
content = `<rect x="2" y="2" width="6" height="6" fill="${safeColor}" />`;
|
|
89
|
+
break;
|
|
90
|
+
case 'bar':
|
|
91
|
+
// Perpendicular line (T shape)
|
|
92
|
+
content = `<line x1="5" y1="1" x2="5" y2="9" stroke="${safeColor}" stroke-width="2" stroke-linecap="round" />`;
|
|
93
|
+
break;
|
|
94
|
+
case 'halfArrow':
|
|
95
|
+
// Single-sided arrow (top half of a filled triangle)
|
|
96
|
+
content = `<polygon points="0,2 10,5 0,5" fill="${safeColor}" />`;
|
|
97
|
+
break;
|
|
98
|
+
default:
|
|
99
|
+
return '';
|
|
100
|
+
}
|
|
101
|
+
const orient = position === 'start' ? 'auto-start-reverse' : 'auto';
|
|
102
|
+
return `<marker id="${id}" viewBox="${viewBox}" refX="${refX}" refY="${refY}" markerWidth="${markerWidth}" markerHeight="${markerHeight}" orient="${orient}">${content}</marker>`;
|
|
103
|
+
}
|
|
5
104
|
import { buildAnimationSpec, } from './anim/animationBuilder';
|
|
6
105
|
import { createBuilderPlayback, } from './anim/playback';
|
|
7
106
|
import { getAdapterExtensions } from './anim/specExtensions';
|
|
8
107
|
const runtimePatchCtxBySvg = new WeakMap();
|
|
9
108
|
const autoplayControllerByContainer = new WeakMap();
|
|
10
|
-
import { applyShapeGeometry,
|
|
109
|
+
import { applyShapeGeometry, effectivePos, getShapeBehavior, shapeSvgMarkup, } from './shapes';
|
|
11
110
|
function setSvgAttributes(el, attrs) {
|
|
12
111
|
Object.entries(attrs).forEach(([key, value]) => {
|
|
13
112
|
if (value === undefined) {
|
|
@@ -35,15 +134,6 @@ function animFallbackStyleEntries(params) {
|
|
|
35
134
|
.filter(([, v]) => v !== undefined)
|
|
36
135
|
.map(([k, v]) => [`--viz-anim-${k}`, String(v)]);
|
|
37
136
|
}
|
|
38
|
-
function computeEdgeEndpoints(start, end, edge) {
|
|
39
|
-
const anchor = edge.anchor ?? 'boundary';
|
|
40
|
-
// Use effective positions of start/end nodes to calculate anchors
|
|
41
|
-
const startPos = effectivePos(start);
|
|
42
|
-
const endPos = effectivePos(end);
|
|
43
|
-
const startAnchor = computeNodeAnchor(start, endPos, anchor);
|
|
44
|
-
const endAnchor = computeNodeAnchor(end, startPos, anchor);
|
|
45
|
-
return { start: startAnchor, end: endAnchor };
|
|
46
|
-
}
|
|
47
137
|
class VizBuilderImpl {
|
|
48
138
|
_viewBox = { w: 800, h: 600 };
|
|
49
139
|
_nodes = new Map();
|
|
@@ -75,14 +165,16 @@ class VizBuilderImpl {
|
|
|
75
165
|
this._gridConfig = { cols, rows, padding };
|
|
76
166
|
return this;
|
|
77
167
|
}
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
168
|
+
overlay(arg1, arg2, arg3) {
|
|
169
|
+
if (typeof arg1 === 'function') {
|
|
170
|
+
const overlay = new OverlayBuilder();
|
|
171
|
+
arg1(overlay);
|
|
172
|
+
this._overlays.push(...overlay.build());
|
|
173
|
+
return this;
|
|
174
|
+
}
|
|
175
|
+
const id = arg1;
|
|
176
|
+
const params = arg2;
|
|
177
|
+
const key = arg3;
|
|
86
178
|
this._overlays.push({ id, params, key });
|
|
87
179
|
return this;
|
|
88
180
|
}
|
|
@@ -274,6 +366,92 @@ class VizBuilderImpl {
|
|
|
274
366
|
runtimePatchCtxBySvg.set(svg, ctx);
|
|
275
367
|
}
|
|
276
368
|
patchRuntime(scene, ctx);
|
|
369
|
+
// Keep overlays in sync during animation playback.
|
|
370
|
+
//
|
|
371
|
+
// Animations flush via `patchRuntime()` (to avoid full re-mounts). Nodes/edges
|
|
372
|
+
// are patched via `runtimePatcher`, but overlays are registry-rendered and
|
|
373
|
+
// need an explicit reconcile pass to reflect animated `spec.params` changes.
|
|
374
|
+
const overlayLayer = svg.querySelector('[data-viz-layer="overlays"]') ||
|
|
375
|
+
svg.querySelector('.viz-layer-overlays');
|
|
376
|
+
if (overlayLayer) {
|
|
377
|
+
const overlays = scene.overlays ?? [];
|
|
378
|
+
const nodesById = new Map(scene.nodes.map((n) => [n.id, n]));
|
|
379
|
+
const edgesById = new Map(scene.edges.map((e) => [e.id, e]));
|
|
380
|
+
const svgNS = 'http://www.w3.org/2000/svg';
|
|
381
|
+
// 1) Map existing overlay groups
|
|
382
|
+
const existingOverlayGroups = Array.from(overlayLayer.children).filter((el) => el.tagName === 'g');
|
|
383
|
+
const existingOverlaysMap = new Map();
|
|
384
|
+
existingOverlayGroups.forEach((el) => {
|
|
385
|
+
const id = el.getAttribute('data-overlay-id');
|
|
386
|
+
if (id)
|
|
387
|
+
existingOverlaysMap.set(id, el);
|
|
388
|
+
});
|
|
389
|
+
// Fast decision: if nothing is dirty and keys match, skip overlay work.
|
|
390
|
+
const overlayKeyCountMatches = overlays.length === existingOverlaysMap.size;
|
|
391
|
+
let needsOverlayPass = !overlayKeyCountMatches;
|
|
392
|
+
const dirtyOverlays = [];
|
|
393
|
+
if (!needsOverlayPass) {
|
|
394
|
+
for (const spec of overlays) {
|
|
395
|
+
const uniqueKey = spec.key || spec.id;
|
|
396
|
+
if (!existingOverlaysMap.has(uniqueKey)) {
|
|
397
|
+
needsOverlayPass = true;
|
|
398
|
+
break;
|
|
399
|
+
}
|
|
400
|
+
if (spec[OVERLAY_RUNTIME_DIRTY]) {
|
|
401
|
+
dirtyOverlays.push(spec);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
if (!needsOverlayPass && dirtyOverlays.length === 0)
|
|
406
|
+
return;
|
|
407
|
+
const processedOverlayIds = new Set();
|
|
408
|
+
// 2) Render/update overlays
|
|
409
|
+
const toUpdate = needsOverlayPass ? overlays : dirtyOverlays;
|
|
410
|
+
toUpdate.forEach((spec) => {
|
|
411
|
+
const renderer = defaultCoreOverlayRegistry.get(spec.id);
|
|
412
|
+
if (!renderer)
|
|
413
|
+
return;
|
|
414
|
+
const uniqueKey = spec.key || spec.id;
|
|
415
|
+
processedOverlayIds.add(uniqueKey);
|
|
416
|
+
let group = existingOverlaysMap.get(uniqueKey);
|
|
417
|
+
if (!group) {
|
|
418
|
+
group = document.createElementNS(svgNS, 'g');
|
|
419
|
+
group.setAttribute('data-overlay-id', uniqueKey);
|
|
420
|
+
group.setAttribute('data-viz-role', 'overlay-group');
|
|
421
|
+
overlayLayer.appendChild(group);
|
|
422
|
+
}
|
|
423
|
+
// Keep wrapper class in sync even when reusing an existing group.
|
|
424
|
+
const expectedClass = `viz-overlay-${spec.id}${spec.className ? ` ${spec.className}` : ''}`;
|
|
425
|
+
const currentClass = group.getAttribute('class');
|
|
426
|
+
if (currentClass !== expectedClass) {
|
|
427
|
+
group.setAttribute('class', expectedClass);
|
|
428
|
+
}
|
|
429
|
+
const overlayCtx = {
|
|
430
|
+
spec,
|
|
431
|
+
nodesById,
|
|
432
|
+
edgesById,
|
|
433
|
+
scene,
|
|
434
|
+
registry: defaultCoreOverlayRegistry,
|
|
435
|
+
};
|
|
436
|
+
if (renderer.update) {
|
|
437
|
+
renderer.update(overlayCtx, group);
|
|
438
|
+
}
|
|
439
|
+
else {
|
|
440
|
+
group.innerHTML = renderer.render(overlayCtx);
|
|
441
|
+
}
|
|
442
|
+
// Clear dirty flag after successful update.
|
|
443
|
+
delete spec[OVERLAY_RUNTIME_DIRTY];
|
|
444
|
+
});
|
|
445
|
+
// 3) Remove stale overlays only if keys may have changed.
|
|
446
|
+
if (!overlayKeyCountMatches) {
|
|
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
|
+
}
|
|
277
455
|
}
|
|
278
456
|
/**
|
|
279
457
|
* Renders the scene to the DOM.
|
|
@@ -296,20 +474,49 @@ class VizBuilderImpl {
|
|
|
296
474
|
const style = document.createElement('style');
|
|
297
475
|
style.textContent = DEFAULT_VIZ_CSS;
|
|
298
476
|
svg.appendChild(style);
|
|
299
|
-
// Defs
|
|
477
|
+
// Defs — marker definitions only for marker types/positions actually used
|
|
300
478
|
const defs = document.createElementNS(svgNS, 'defs');
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
479
|
+
// Collect the set of (markerType, color, position) tuples actually needed
|
|
480
|
+
// and pre-generate their SVG content strings.
|
|
481
|
+
const neededMarkers = new Set();
|
|
482
|
+
const markerSvgById = new Map();
|
|
483
|
+
edges.forEach((e) => {
|
|
484
|
+
const stroke = e.style?.stroke;
|
|
485
|
+
if (e.markerEnd && e.markerEnd !== 'none') {
|
|
486
|
+
const mid = markerIdFor(e.markerEnd, stroke, 'end');
|
|
487
|
+
if (!neededMarkers.has(mid)) {
|
|
488
|
+
neededMarkers.add(mid);
|
|
489
|
+
markerSvgById.set(mid, generateMarkerSvg(e.markerEnd, stroke ?? 'currentColor', mid, 'end'));
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
if (e.markerStart && e.markerStart !== 'none') {
|
|
493
|
+
const mid = markerIdFor(e.markerStart, stroke, 'start');
|
|
494
|
+
if (!neededMarkers.has(mid)) {
|
|
495
|
+
neededMarkers.add(mid);
|
|
496
|
+
markerSvgById.set(mid, generateMarkerSvg(e.markerStart, stroke ?? 'currentColor', mid, 'start'));
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
});
|
|
500
|
+
neededMarkers.forEach((mid) => {
|
|
501
|
+
const markerEl = document.createElementNS(svgNS, 'marker');
|
|
502
|
+
markerEl.setAttribute('id', mid);
|
|
503
|
+
markerEl.setAttribute('viewBox', '0 0 10 10');
|
|
504
|
+
markerEl.setAttribute('markerWidth', '10');
|
|
505
|
+
markerEl.setAttribute('markerHeight', '10');
|
|
506
|
+
markerEl.setAttribute('refX', '9');
|
|
507
|
+
markerEl.setAttribute('refY', '5');
|
|
508
|
+
const isStart = mid.includes('-start');
|
|
509
|
+
markerEl.setAttribute('orient', isStart ? 'auto-start-reverse' : 'auto');
|
|
510
|
+
const tmp = document.createElementNS(svgNS, 'svg');
|
|
511
|
+
tmp.innerHTML = markerSvgById.get(mid) ?? '';
|
|
512
|
+
const parsed = tmp.querySelector('marker');
|
|
513
|
+
if (parsed) {
|
|
514
|
+
while (parsed.firstChild) {
|
|
515
|
+
markerEl.appendChild(parsed.firstChild);
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
defs.appendChild(markerEl);
|
|
519
|
+
});
|
|
313
520
|
svg.appendChild(defs);
|
|
314
521
|
// Layers
|
|
315
522
|
const edgeLayer = document.createElementNS(svgNS, 'g');
|
|
@@ -361,10 +568,10 @@ class VizBuilderImpl {
|
|
|
361
568
|
group.setAttribute('data-viz-role', 'edge-group');
|
|
362
569
|
edgeLayer.appendChild(group);
|
|
363
570
|
// Initial creation of children
|
|
364
|
-
const
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
group.appendChild(
|
|
571
|
+
const path = document.createElementNS(svgNS, 'path');
|
|
572
|
+
path.setAttribute('class', 'viz-edge');
|
|
573
|
+
path.setAttribute('data-viz-role', 'edge-line');
|
|
574
|
+
group.appendChild(path);
|
|
368
575
|
// Optional parts created on demand later, but structure expected
|
|
369
576
|
}
|
|
370
577
|
// Compute Classes & Styles
|
|
@@ -397,6 +604,7 @@ class VizBuilderImpl {
|
|
|
397
604
|
group.setAttribute('class', classes);
|
|
398
605
|
// Use effective positions (handles runtime overrides internally via helper)
|
|
399
606
|
const endpoints = computeEdgeEndpoints(start, end, edge);
|
|
607
|
+
const edgePath = computeEdgePath(endpoints.start, endpoints.end, edge.routing, edge.waypoints);
|
|
400
608
|
// Apply Edge Runtime Overrides
|
|
401
609
|
if (edge.runtime?.opacity !== undefined) {
|
|
402
610
|
group.style.opacity = String(edge.runtime.opacity);
|
|
@@ -404,7 +612,7 @@ class VizBuilderImpl {
|
|
|
404
612
|
else {
|
|
405
613
|
group.style.removeProperty('opacity');
|
|
406
614
|
}
|
|
407
|
-
// Update
|
|
615
|
+
// Update Path
|
|
408
616
|
const line = group.querySelector('[data-viz-role="edge-line"]') ||
|
|
409
617
|
group.querySelector('.viz-edge');
|
|
410
618
|
if (!line)
|
|
@@ -418,29 +626,51 @@ class VizBuilderImpl {
|
|
|
418
626
|
line.style.removeProperty('stroke-dashoffset');
|
|
419
627
|
line.removeAttribute('stroke-dashoffset');
|
|
420
628
|
}
|
|
421
|
-
line.setAttribute('
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
if (edge.markerEnd === 'arrow') {
|
|
427
|
-
line.setAttribute('marker-end', 'url(#viz-arrow)');
|
|
629
|
+
line.setAttribute('d', edgePath.d);
|
|
630
|
+
// Update marker-end
|
|
631
|
+
if (edge.markerEnd && edge.markerEnd !== 'none') {
|
|
632
|
+
const mid = markerIdFor(edge.markerEnd, edge.style?.stroke, 'end');
|
|
633
|
+
line.setAttribute('marker-end', `url(#${mid})`);
|
|
428
634
|
}
|
|
429
635
|
else {
|
|
430
636
|
line.removeAttribute('marker-end');
|
|
431
637
|
}
|
|
638
|
+
// Update marker-start
|
|
639
|
+
if (edge.markerStart && edge.markerStart !== 'none') {
|
|
640
|
+
const mid = markerIdFor(edge.markerStart, edge.style?.stroke, 'start');
|
|
641
|
+
line.setAttribute('marker-start', `url(#${mid})`);
|
|
642
|
+
}
|
|
643
|
+
else {
|
|
644
|
+
line.removeAttribute('marker-start');
|
|
645
|
+
}
|
|
646
|
+
// Per-edge style overrides (inline style wins over CSS class defaults)
|
|
647
|
+
if (edge.style?.stroke !== undefined) {
|
|
648
|
+
line.style.stroke = edge.style.stroke;
|
|
649
|
+
}
|
|
650
|
+
else {
|
|
651
|
+
line.style.removeProperty('stroke');
|
|
652
|
+
}
|
|
653
|
+
if (edge.style?.strokeWidth !== undefined)
|
|
654
|
+
line.style.strokeWidth = String(edge.style.strokeWidth);
|
|
655
|
+
else
|
|
656
|
+
line.style.removeProperty('stroke-width');
|
|
657
|
+
if (edge.style?.fill !== undefined)
|
|
658
|
+
line.style.fill = edge.style.fill;
|
|
659
|
+
else
|
|
660
|
+
line.style.removeProperty('fill');
|
|
661
|
+
if (edge.style?.opacity !== undefined)
|
|
662
|
+
line.style.opacity = String(edge.style.opacity);
|
|
663
|
+
else
|
|
664
|
+
line.style.removeProperty('opacity');
|
|
432
665
|
const oldHit = group.querySelector('[data-viz-role="edge-hit"]') ||
|
|
433
666
|
group.querySelector('.viz-edge-hit');
|
|
434
667
|
if (oldHit)
|
|
435
668
|
oldHit.remove();
|
|
436
669
|
if (edge.hitArea || edge.onClick) {
|
|
437
|
-
const hit = document.createElementNS(svgNS, '
|
|
670
|
+
const hit = document.createElementNS(svgNS, 'path');
|
|
438
671
|
hit.setAttribute('class', 'viz-edge-hit'); // Add class for selection
|
|
439
672
|
hit.setAttribute('data-viz-role', 'edge-hit');
|
|
440
|
-
hit.setAttribute('
|
|
441
|
-
hit.setAttribute('y1', String(endpoints.start.y));
|
|
442
|
-
hit.setAttribute('x2', String(endpoints.end.x));
|
|
443
|
-
hit.setAttribute('y2', String(endpoints.end.y));
|
|
673
|
+
hit.setAttribute('d', edgePath.d);
|
|
444
674
|
hit.setAttribute('stroke', 'transparent');
|
|
445
675
|
hit.setAttribute('stroke-width', String(edge.hitArea || 10));
|
|
446
676
|
hit.style.cursor = edge.onClick ? 'pointer' : '';
|
|
@@ -452,24 +682,25 @@ class VizBuilderImpl {
|
|
|
452
682
|
}
|
|
453
683
|
group.appendChild(hit);
|
|
454
684
|
}
|
|
455
|
-
//
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
685
|
+
// Labels (remove all old, re-create from labels[])
|
|
686
|
+
group
|
|
687
|
+
.querySelectorAll('[data-viz-role="edge-label"],.viz-edge-label')
|
|
688
|
+
.forEach((el) => el.remove());
|
|
689
|
+
const allLabels = collectEdgeLabels(edge);
|
|
690
|
+
allLabels.forEach((lbl, idx) => {
|
|
691
|
+
const pos = resolveEdgeLabelPosition(lbl, edgePath);
|
|
461
692
|
const text = document.createElementNS(svgNS, 'text');
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
text.setAttribute('
|
|
465
|
-
text.setAttribute('y', String(my));
|
|
466
|
-
text.setAttribute('class', `viz-edge-label ${edge.label.className || ''}`);
|
|
693
|
+
text.setAttribute('x', String(pos.x));
|
|
694
|
+
text.setAttribute('y', String(pos.y));
|
|
695
|
+
text.setAttribute('class', `viz-edge-label ${lbl.className || ''}`);
|
|
467
696
|
text.setAttribute('data-viz-role', 'edge-label');
|
|
697
|
+
text.setAttribute('data-label-index', String(idx));
|
|
698
|
+
text.setAttribute('data-label-position', lbl.position);
|
|
468
699
|
text.setAttribute('text-anchor', 'middle');
|
|
469
700
|
text.setAttribute('dominant-baseline', 'middle');
|
|
470
|
-
text.textContent =
|
|
701
|
+
text.textContent = lbl.text;
|
|
471
702
|
group.appendChild(text);
|
|
472
|
-
}
|
|
703
|
+
});
|
|
473
704
|
});
|
|
474
705
|
// Remove stale edges
|
|
475
706
|
existingEdgeGroups.forEach((el) => {
|
|
@@ -479,7 +710,24 @@ class VizBuilderImpl {
|
|
|
479
710
|
}
|
|
480
711
|
});
|
|
481
712
|
// --- 2. Reconcile Nodes ---
|
|
482
|
-
|
|
713
|
+
// Build parent→children map for container grouping
|
|
714
|
+
const childrenByParentDOM = new Map();
|
|
715
|
+
const rootNodesDOM = [];
|
|
716
|
+
nodes.forEach((n) => {
|
|
717
|
+
if (n.parentId) {
|
|
718
|
+
let arr = childrenByParentDOM.get(n.parentId);
|
|
719
|
+
if (!arr) {
|
|
720
|
+
arr = [];
|
|
721
|
+
childrenByParentDOM.set(n.parentId, arr);
|
|
722
|
+
}
|
|
723
|
+
arr.push(n);
|
|
724
|
+
}
|
|
725
|
+
else {
|
|
726
|
+
rootNodesDOM.push(n);
|
|
727
|
+
}
|
|
728
|
+
});
|
|
729
|
+
// Collect ALL existing node groups (including nested inside containers)
|
|
730
|
+
const existingNodeGroups = Array.from(nodeLayer.querySelectorAll('g[data-viz-role="node-group"]'));
|
|
483
731
|
const existingNodesMap = new Map();
|
|
484
732
|
existingNodeGroups.forEach((el) => {
|
|
485
733
|
const id = el.getAttribute('data-id');
|
|
@@ -487,17 +735,22 @@ class VizBuilderImpl {
|
|
|
487
735
|
existingNodesMap.set(id, el);
|
|
488
736
|
});
|
|
489
737
|
const processedNodeIds = new Set();
|
|
490
|
-
|
|
738
|
+
const reconcileNodeDOM = (node, parentGroup) => {
|
|
491
739
|
processedNodeIds.add(node.id);
|
|
492
740
|
let group = existingNodesMap.get(node.id);
|
|
493
741
|
if (!group) {
|
|
494
742
|
group = document.createElementNS(svgNS, 'g');
|
|
495
743
|
group.setAttribute('data-id', node.id);
|
|
496
744
|
group.setAttribute('data-viz-role', 'node-group');
|
|
497
|
-
|
|
745
|
+
parentGroup.appendChild(group);
|
|
746
|
+
}
|
|
747
|
+
else if (group.parentElement !== parentGroup) {
|
|
748
|
+
// Re-parent if the node moved to/from a container
|
|
749
|
+
parentGroup.appendChild(group);
|
|
498
750
|
}
|
|
751
|
+
const isContainer = !!node.container;
|
|
499
752
|
// Calculate Anim Classes
|
|
500
|
-
let classes = `viz-node-group ${node.className || ''}`;
|
|
753
|
+
let classes = `viz-node-group${isContainer ? ' viz-container' : ''} ${node.className || ''}`;
|
|
501
754
|
group.removeAttribute('style');
|
|
502
755
|
if (node.animations) {
|
|
503
756
|
node.animations.forEach((spec) => {
|
|
@@ -541,8 +794,6 @@ class VizBuilderImpl {
|
|
|
541
794
|
group.style.cursor = node.onClick ? 'pointer' : '';
|
|
542
795
|
// Shape (Update geometry)
|
|
543
796
|
const { x, y } = effectivePos(node);
|
|
544
|
-
// Ideally we reuse the shape element if the kind hasn't changed.
|
|
545
|
-
// Assuming kind rarely changes for same ID.
|
|
546
797
|
let shape = group.querySelector('[data-viz-role="node-shape"]') ||
|
|
547
798
|
group.querySelector('.viz-node-shape');
|
|
548
799
|
const behavior = getShapeBehavior(node.shape);
|
|
@@ -553,9 +804,8 @@ class VizBuilderImpl {
|
|
|
553
804
|
shape = document.createElementNS(svgNS, expectedTag);
|
|
554
805
|
shape.setAttribute('class', 'viz-node-shape');
|
|
555
806
|
shape.setAttribute('data-viz-role', 'node-shape');
|
|
556
|
-
group.prepend(shape);
|
|
807
|
+
group.prepend(shape);
|
|
557
808
|
}
|
|
558
|
-
// Update Shape Attributes
|
|
559
809
|
applyShapeGeometry(shape, node.shape, { x, y });
|
|
560
810
|
setSvgAttributes(shape, {
|
|
561
811
|
fill: node.style?.fill ?? 'none',
|
|
@@ -563,7 +813,35 @@ class VizBuilderImpl {
|
|
|
563
813
|
'stroke-width': node.style?.strokeWidth ?? 2,
|
|
564
814
|
opacity: node.runtime?.opacity ?? node.style?.opacity,
|
|
565
815
|
});
|
|
566
|
-
//
|
|
816
|
+
// Container header line
|
|
817
|
+
if (isContainer &&
|
|
818
|
+
node.container.headerHeight &&
|
|
819
|
+
'w' in node.shape &&
|
|
820
|
+
'h' in node.shape) {
|
|
821
|
+
const sw = node.shape.w;
|
|
822
|
+
const sh = node.shape.h;
|
|
823
|
+
const headerY = y - sh / 2 + node.container.headerHeight;
|
|
824
|
+
let headerLine = group.querySelector('[data-viz-role="container-header"]');
|
|
825
|
+
if (!headerLine) {
|
|
826
|
+
headerLine = document.createElementNS(svgNS, 'line');
|
|
827
|
+
headerLine.setAttribute('class', 'viz-container-header');
|
|
828
|
+
headerLine.setAttribute('data-viz-role', 'container-header');
|
|
829
|
+
group.appendChild(headerLine);
|
|
830
|
+
}
|
|
831
|
+
headerLine.setAttribute('x1', String(x - sw / 2));
|
|
832
|
+
headerLine.setAttribute('y1', String(headerY));
|
|
833
|
+
headerLine.setAttribute('x2', String(x + sw / 2));
|
|
834
|
+
headerLine.setAttribute('y2', String(headerY));
|
|
835
|
+
headerLine.setAttribute('stroke', node.style?.stroke ?? '#111');
|
|
836
|
+
headerLine.setAttribute('stroke-width', String(node.style?.strokeWidth ?? 2));
|
|
837
|
+
}
|
|
838
|
+
else {
|
|
839
|
+
// Remove stale header line if no longer a container with headerHeight
|
|
840
|
+
const staleHeader = group.querySelector('[data-viz-role="container-header"]');
|
|
841
|
+
if (staleHeader)
|
|
842
|
+
staleHeader.remove();
|
|
843
|
+
}
|
|
844
|
+
// Label
|
|
567
845
|
let label = group.querySelector('[data-viz-role="node-label"]') ||
|
|
568
846
|
group.querySelector('.viz-node-label');
|
|
569
847
|
if (!label && node.label) {
|
|
@@ -573,13 +851,21 @@ class VizBuilderImpl {
|
|
|
573
851
|
group.appendChild(label);
|
|
574
852
|
}
|
|
575
853
|
if (node.label) {
|
|
576
|
-
|
|
577
|
-
|
|
854
|
+
let lx = x + (node.label.dx || 0);
|
|
855
|
+
let ly = y + (node.label.dy || 0);
|
|
856
|
+
// If container with headerHeight, center label in header area
|
|
857
|
+
if (isContainer &&
|
|
858
|
+
node.container.headerHeight &&
|
|
859
|
+
'h' in node.shape &&
|
|
860
|
+
!node.label.dy) {
|
|
861
|
+
const sh = node.shape.h;
|
|
862
|
+
ly = y - sh / 2 + node.container.headerHeight / 2;
|
|
863
|
+
lx = x + (node.label.dx || 0);
|
|
864
|
+
}
|
|
578
865
|
label.setAttribute('x', String(lx));
|
|
579
866
|
label.setAttribute('y', String(ly));
|
|
580
867
|
label.setAttribute('text-anchor', node.label.textAnchor || 'middle');
|
|
581
868
|
label.setAttribute('dominant-baseline', node.label.dominantBaseline || 'middle');
|
|
582
|
-
// Update class carefully to preserve 'viz-node-label'
|
|
583
869
|
label.setAttribute('class', `viz-node-label ${node.label.className || ''}`);
|
|
584
870
|
label.setAttribute('data-viz-role', 'node-label');
|
|
585
871
|
setSvgAttributes(label, {
|
|
@@ -592,8 +878,45 @@ class VizBuilderImpl {
|
|
|
592
878
|
else if (label) {
|
|
593
879
|
label.remove();
|
|
594
880
|
}
|
|
595
|
-
|
|
596
|
-
|
|
881
|
+
// Ports — render small circles at each explicit port position.
|
|
882
|
+
// Remove stale ports first, then recreate from current spec.
|
|
883
|
+
const oldPorts = group.querySelectorAll('[data-viz-role="port"]');
|
|
884
|
+
oldPorts.forEach((el) => el.remove());
|
|
885
|
+
if (node.ports && node.ports.length > 0) {
|
|
886
|
+
node.ports.forEach((port) => {
|
|
887
|
+
const portEl = document.createElementNS(svgNS, 'circle');
|
|
888
|
+
portEl.setAttribute('cx', String(x + port.offset.x));
|
|
889
|
+
portEl.setAttribute('cy', String(y + port.offset.y));
|
|
890
|
+
portEl.setAttribute('r', '4');
|
|
891
|
+
portEl.setAttribute('class', 'viz-port');
|
|
892
|
+
portEl.setAttribute('data-viz-role', 'port');
|
|
893
|
+
portEl.setAttribute('data-node', node.id);
|
|
894
|
+
portEl.setAttribute('data-port', port.id);
|
|
895
|
+
group.appendChild(portEl);
|
|
896
|
+
});
|
|
897
|
+
}
|
|
898
|
+
// Container children
|
|
899
|
+
const children = childrenByParentDOM.get(node.id);
|
|
900
|
+
if (children && children.length > 0) {
|
|
901
|
+
let childrenGroup = group.querySelector(':scope > [data-viz-role="container-children"]');
|
|
902
|
+
if (!childrenGroup) {
|
|
903
|
+
childrenGroup = document.createElementNS(svgNS, 'g');
|
|
904
|
+
childrenGroup.setAttribute('class', 'viz-container-children');
|
|
905
|
+
childrenGroup.setAttribute('data-viz-role', 'container-children');
|
|
906
|
+
group.appendChild(childrenGroup);
|
|
907
|
+
}
|
|
908
|
+
children.forEach((child) => reconcileNodeDOM(child, childrenGroup));
|
|
909
|
+
}
|
|
910
|
+
else {
|
|
911
|
+
// Remove stale children group
|
|
912
|
+
const staleChildren = group.querySelector(':scope > [data-viz-role="container-children"]');
|
|
913
|
+
if (staleChildren)
|
|
914
|
+
staleChildren.remove();
|
|
915
|
+
}
|
|
916
|
+
};
|
|
917
|
+
// Reconcile root nodes only; children are reconciled recursively
|
|
918
|
+
rootNodesDOM.forEach((node) => reconcileNodeDOM(node, nodeLayer));
|
|
919
|
+
// Remove stale nodes (from anywhere in the tree)
|
|
597
920
|
existingNodeGroups.forEach((el) => {
|
|
598
921
|
const id = el.getAttribute('data-id');
|
|
599
922
|
if (id && !processedNodeIds.has(id)) {
|
|
@@ -620,15 +943,17 @@ class VizBuilderImpl {
|
|
|
620
943
|
if (!group) {
|
|
621
944
|
group = document.createElementNS(svgNS, 'g');
|
|
622
945
|
group.setAttribute('data-overlay-id', uniqueKey);
|
|
623
|
-
group.setAttribute('class', `viz-overlay-${spec.id}`);
|
|
624
946
|
group.setAttribute('data-viz-role', 'overlay-group');
|
|
625
947
|
overlayLayer.appendChild(group);
|
|
626
948
|
}
|
|
949
|
+
// Keep wrapper class in sync even when reusing an existing group.
|
|
950
|
+
group.setAttribute('class', `viz-overlay-${spec.id}${spec.className ? ` ${spec.className}` : ''}`);
|
|
627
951
|
const ctx = {
|
|
628
952
|
spec,
|
|
629
953
|
nodesById,
|
|
630
954
|
edgesById: new Map(edges.map((e) => [e.id, e])),
|
|
631
955
|
scene,
|
|
956
|
+
registry: defaultCoreOverlayRegistry,
|
|
632
957
|
};
|
|
633
958
|
if (renderer.update) {
|
|
634
959
|
renderer.update(ctx, group);
|
|
@@ -663,12 +988,29 @@ class VizBuilderImpl {
|
|
|
663
988
|
let svgContent = `<svg viewBox="0 0 ${viewBox.w} ${viewBox.h}" xmlns="http://www.w3.org/2000/svg">`;
|
|
664
989
|
// Inject Styles
|
|
665
990
|
svgContent += `<style>${DEFAULT_VIZ_CSS}</style>`;
|
|
666
|
-
// Defs (
|
|
991
|
+
// Defs (Marker definitions for all marker types)
|
|
992
|
+
svgContent += `
|
|
993
|
+
<defs>`;
|
|
994
|
+
// Only generate marker defs for types/positions actually used by edges
|
|
995
|
+
const markerDefinitions = new Set();
|
|
996
|
+
edges.forEach((e) => {
|
|
997
|
+
const stroke = e.style?.stroke;
|
|
998
|
+
if (e.markerEnd && e.markerEnd !== 'none') {
|
|
999
|
+
const mid = markerIdFor(e.markerEnd, stroke, 'end');
|
|
1000
|
+
if (!markerDefinitions.has(mid)) {
|
|
1001
|
+
markerDefinitions.add(mid);
|
|
1002
|
+
svgContent += generateMarkerSvg(e.markerEnd, stroke ?? 'currentColor', mid, 'end');
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
if (e.markerStart && e.markerStart !== 'none') {
|
|
1006
|
+
const mid = markerIdFor(e.markerStart, stroke, 'start');
|
|
1007
|
+
if (!markerDefinitions.has(mid)) {
|
|
1008
|
+
markerDefinitions.add(mid);
|
|
1009
|
+
svgContent += generateMarkerSvg(e.markerStart, stroke ?? 'currentColor', mid, 'start');
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
});
|
|
667
1013
|
svgContent += `
|
|
668
|
-
<defs>
|
|
669
|
-
<marker id="viz-arrow" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
|
|
670
|
-
<polygon points="0 0, 10 3.5, 0 7" fill="currentColor" />
|
|
671
|
-
</marker>
|
|
672
1014
|
</defs>`;
|
|
673
1015
|
// Render Edges
|
|
674
1016
|
svgContent += '<g class="viz-layer-edges" data-viz-layer="edges">';
|
|
@@ -704,8 +1046,14 @@ class VizBuilderImpl {
|
|
|
704
1046
|
}
|
|
705
1047
|
});
|
|
706
1048
|
}
|
|
707
|
-
const markerEnd = edge.markerEnd
|
|
1049
|
+
const markerEnd = edge.markerEnd && edge.markerEnd !== 'none'
|
|
1050
|
+
? `marker-end="url(#${markerIdFor(edge.markerEnd, edge.style?.stroke, 'end')})"`
|
|
1051
|
+
: '';
|
|
1052
|
+
const markerStart = edge.markerStart && edge.markerStart !== 'none'
|
|
1053
|
+
? `marker-start="url(#${markerIdFor(edge.markerStart, edge.style?.stroke, 'start')})"`
|
|
1054
|
+
: '';
|
|
708
1055
|
const endpoints = computeEdgeEndpoints(start, end, edge);
|
|
1056
|
+
const edgePath = computeEdgePath(endpoints.start, endpoints.end, edge.routing, edge.waypoints);
|
|
709
1057
|
// Runtime overrides for SVG export
|
|
710
1058
|
let runtimeStyle = '';
|
|
711
1059
|
if (edge.runtime?.opacity !== undefined) {
|
|
@@ -718,20 +1066,41 @@ class VizBuilderImpl {
|
|
|
718
1066
|
lineRuntimeAttrs += ` stroke-dashoffset="${edge.runtime.strokeDashoffset}"`;
|
|
719
1067
|
}
|
|
720
1068
|
svgContent += `<g data-id="${edge.id}" data-viz-role="edge-group" class="viz-edge-group ${edge.className || ''} ${animClasses}" style="${animStyleStr}${runtimeStyle}">`;
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
1069
|
+
let edgeInlineStyle = lineRuntimeStyle;
|
|
1070
|
+
if (edge.style?.stroke !== undefined)
|
|
1071
|
+
edgeInlineStyle += `stroke: ${edge.style.stroke}; `;
|
|
1072
|
+
if (edge.style?.strokeWidth !== undefined)
|
|
1073
|
+
edgeInlineStyle += `stroke-width: ${edge.style.strokeWidth}; `;
|
|
1074
|
+
if (edge.style?.fill !== undefined)
|
|
1075
|
+
edgeInlineStyle += `fill: ${edge.style.fill}; `;
|
|
1076
|
+
if (edge.style?.opacity !== undefined)
|
|
1077
|
+
edgeInlineStyle += `opacity: ${edge.style.opacity}; `;
|
|
1078
|
+
svgContent += `<path d="${edgePath.d}" class="viz-edge" data-viz-role="edge-line" ${markerEnd} ${markerStart} style="${edgeInlineStyle}"${lineRuntimeAttrs} />`;
|
|
1079
|
+
// Edge Labels (multi-position)
|
|
1080
|
+
const allLabels = collectEdgeLabels(edge);
|
|
1081
|
+
allLabels.forEach((lbl, idx) => {
|
|
1082
|
+
const pos = resolveEdgeLabelPosition(lbl, edgePath);
|
|
1083
|
+
const labelClass = `viz-edge-label ${lbl.className || ''}`;
|
|
1084
|
+
svgContent += `<text x="${pos.x}" y="${pos.y}" class="${labelClass}" data-viz-role="edge-label" data-label-index="${idx}" data-label-position="${lbl.position}" text-anchor="middle" dominant-baseline="middle">${lbl.text}</text>`;
|
|
1085
|
+
});
|
|
729
1086
|
svgContent += '</g>';
|
|
730
1087
|
});
|
|
731
1088
|
svgContent += '</g>';
|
|
732
|
-
//
|
|
733
|
-
|
|
734
|
-
nodes.forEach((
|
|
1089
|
+
// Build parent→children map for container grouping
|
|
1090
|
+
const childrenByParent = new Map();
|
|
1091
|
+
nodes.forEach((n) => {
|
|
1092
|
+
if (n.parentId) {
|
|
1093
|
+
let arr = childrenByParent.get(n.parentId);
|
|
1094
|
+
if (!arr) {
|
|
1095
|
+
arr = [];
|
|
1096
|
+
childrenByParent.set(n.parentId, arr);
|
|
1097
|
+
}
|
|
1098
|
+
arr.push(n);
|
|
1099
|
+
}
|
|
1100
|
+
});
|
|
1101
|
+
// Recursive node renderer
|
|
1102
|
+
const renderNodeToSvg = (node) => {
|
|
1103
|
+
let content = '';
|
|
735
1104
|
const { x, y } = effectivePos(node);
|
|
736
1105
|
const { shape } = node;
|
|
737
1106
|
// Animations (Nodes)
|
|
@@ -765,8 +1134,9 @@ class VizBuilderImpl {
|
|
|
765
1134
|
}
|
|
766
1135
|
});
|
|
767
1136
|
}
|
|
768
|
-
const
|
|
769
|
-
|
|
1137
|
+
const isContainer = !!node.container;
|
|
1138
|
+
const className = `viz-node-group${isContainer ? ' viz-container' : ''} ${node.className || ''} ${animClasses}`;
|
|
1139
|
+
content += `<g data-id="${node.id}" data-viz-role="node-group" class="${className}" style="${animStyleStr}">`;
|
|
770
1140
|
const shapeStyleAttrs = svgAttributeString({
|
|
771
1141
|
fill: node.style?.fill ?? 'none',
|
|
772
1142
|
stroke: node.style?.stroke ?? '#111',
|
|
@@ -774,11 +1144,30 @@ class VizBuilderImpl {
|
|
|
774
1144
|
opacity: node.style?.opacity,
|
|
775
1145
|
});
|
|
776
1146
|
// Shape
|
|
777
|
-
|
|
1147
|
+
content += shapeSvgMarkup(shape, { x, y }, shapeStyleAttrs);
|
|
1148
|
+
// Container header line
|
|
1149
|
+
if (isContainer &&
|
|
1150
|
+
node.container.headerHeight &&
|
|
1151
|
+
'w' in shape &&
|
|
1152
|
+
'h' in shape) {
|
|
1153
|
+
const sw = shape.w;
|
|
1154
|
+
const sh = shape.h;
|
|
1155
|
+
const headerY = y - sh / 2 + node.container.headerHeight;
|
|
1156
|
+
content += `<line x1="${x - sw / 2}" y1="${headerY}" x2="${x + sw / 2}" y2="${headerY}" stroke="${node.style?.stroke ?? '#111'}" stroke-width="${node.style?.strokeWidth ?? 2}" class="viz-container-header" data-viz-role="container-header" />`;
|
|
1157
|
+
}
|
|
778
1158
|
// Label
|
|
779
1159
|
if (node.label) {
|
|
780
|
-
|
|
781
|
-
|
|
1160
|
+
let lx = x + (node.label.dx || 0);
|
|
1161
|
+
let ly = y + (node.label.dy || 0);
|
|
1162
|
+
// If container with headerHeight, center label in header area
|
|
1163
|
+
if (isContainer &&
|
|
1164
|
+
node.container.headerHeight &&
|
|
1165
|
+
'h' in shape &&
|
|
1166
|
+
!node.label.dy) {
|
|
1167
|
+
const sh = shape.h;
|
|
1168
|
+
ly = y - sh / 2 + node.container.headerHeight / 2;
|
|
1169
|
+
lx = x + (node.label.dx || 0);
|
|
1170
|
+
}
|
|
782
1171
|
const labelClass = `viz-node-label ${node.label.className || ''}`;
|
|
783
1172
|
const labelAttrs = svgAttributeString({
|
|
784
1173
|
fill: node.label.fill,
|
|
@@ -787,9 +1176,35 @@ class VizBuilderImpl {
|
|
|
787
1176
|
'text-anchor': node.label.textAnchor || 'middle',
|
|
788
1177
|
'dominant-baseline': node.label.dominantBaseline || 'middle',
|
|
789
1178
|
});
|
|
790
|
-
|
|
1179
|
+
content += `<text x="${lx}" y="${ly}" class="${labelClass}" data-viz-role="node-label"${labelAttrs}>${node.label.text}</text>`;
|
|
1180
|
+
}
|
|
1181
|
+
// Ports (small circles on the shape boundary, hidden by default, shown on hover via CSS)
|
|
1182
|
+
if (node.ports && node.ports.length > 0) {
|
|
1183
|
+
node.ports.forEach((port) => {
|
|
1184
|
+
const px = x + port.offset.x;
|
|
1185
|
+
const py = y + port.offset.y;
|
|
1186
|
+
content += `<circle cx="${px}" cy="${py}" r="4" class="viz-port" data-viz-role="port" data-node="${node.id}" data-port="${port.id}" />`;
|
|
1187
|
+
});
|
|
1188
|
+
}
|
|
1189
|
+
// Container children
|
|
1190
|
+
const children = childrenByParent.get(node.id);
|
|
1191
|
+
if (children && children.length > 0) {
|
|
1192
|
+
content +=
|
|
1193
|
+
'<g class="viz-container-children" data-viz-role="container-children">';
|
|
1194
|
+
children.forEach((child) => {
|
|
1195
|
+
content += renderNodeToSvg(child);
|
|
1196
|
+
});
|
|
1197
|
+
content += '</g>';
|
|
1198
|
+
}
|
|
1199
|
+
content += '</g>';
|
|
1200
|
+
return content;
|
|
1201
|
+
};
|
|
1202
|
+
// Render Nodes (only root nodes; children are rendered inside their containers)
|
|
1203
|
+
svgContent += '<g class="viz-layer-nodes" data-viz-layer="nodes">';
|
|
1204
|
+
nodes.forEach((node) => {
|
|
1205
|
+
if (!node.parentId) {
|
|
1206
|
+
svgContent += renderNodeToSvg(node);
|
|
791
1207
|
}
|
|
792
|
-
svgContent += '</g>';
|
|
793
1208
|
});
|
|
794
1209
|
svgContent += '</g>';
|
|
795
1210
|
// Render Overlays
|
|
@@ -798,7 +1213,13 @@ class VizBuilderImpl {
|
|
|
798
1213
|
overlays.forEach((spec) => {
|
|
799
1214
|
const renderer = defaultCoreOverlayRegistry.get(spec.id);
|
|
800
1215
|
if (renderer) {
|
|
801
|
-
svgContent += renderer.render({
|
|
1216
|
+
svgContent += renderer.render({
|
|
1217
|
+
spec,
|
|
1218
|
+
nodesById,
|
|
1219
|
+
edgesById,
|
|
1220
|
+
scene,
|
|
1221
|
+
registry: defaultCoreOverlayRegistry,
|
|
1222
|
+
});
|
|
802
1223
|
}
|
|
803
1224
|
});
|
|
804
1225
|
svgContent += '</g>';
|
|
@@ -808,10 +1229,10 @@ class VizBuilderImpl {
|
|
|
808
1229
|
}
|
|
809
1230
|
}
|
|
810
1231
|
class NodeBuilderImpl {
|
|
811
|
-
|
|
1232
|
+
_builder;
|
|
812
1233
|
nodeDef;
|
|
813
1234
|
constructor(parent, nodeDef) {
|
|
814
|
-
this.
|
|
1235
|
+
this._builder = parent;
|
|
815
1236
|
this.nodeDef = nodeDef;
|
|
816
1237
|
}
|
|
817
1238
|
at(x, y) {
|
|
@@ -819,12 +1240,12 @@ class NodeBuilderImpl {
|
|
|
819
1240
|
return this;
|
|
820
1241
|
}
|
|
821
1242
|
cell(col, row, align = 'center') {
|
|
822
|
-
const grid = this.
|
|
1243
|
+
const grid = this._builder._getGridConfig();
|
|
823
1244
|
if (!grid) {
|
|
824
1245
|
console.warn('VizBuilder: .cell() called but no grid configured. Use .grid() first.');
|
|
825
1246
|
return this;
|
|
826
1247
|
}
|
|
827
|
-
const view = this.
|
|
1248
|
+
const view = this._builder._getViewBox();
|
|
828
1249
|
const availableW = view.w - grid.padding.x * 2;
|
|
829
1250
|
const availableH = view.h - grid.padding.y * 2;
|
|
830
1251
|
const cellW = availableW / grid.cols;
|
|
@@ -855,6 +1276,86 @@ class NodeBuilderImpl {
|
|
|
855
1276
|
this.nodeDef.shape = { kind: 'diamond', w, h };
|
|
856
1277
|
return this;
|
|
857
1278
|
}
|
|
1279
|
+
cylinder(w, h, arcHeight) {
|
|
1280
|
+
this.nodeDef.shape = { kind: 'cylinder', w, h, arcHeight };
|
|
1281
|
+
return this;
|
|
1282
|
+
}
|
|
1283
|
+
hexagon(r, orientation) {
|
|
1284
|
+
this.nodeDef.shape = { kind: 'hexagon', r, orientation };
|
|
1285
|
+
return this;
|
|
1286
|
+
}
|
|
1287
|
+
ellipse(rx, ry) {
|
|
1288
|
+
this.nodeDef.shape = { kind: 'ellipse', rx, ry };
|
|
1289
|
+
return this;
|
|
1290
|
+
}
|
|
1291
|
+
arc(r, startAngle, endAngle, closed) {
|
|
1292
|
+
this.nodeDef.shape = { kind: 'arc', r, startAngle, endAngle, closed };
|
|
1293
|
+
return this;
|
|
1294
|
+
}
|
|
1295
|
+
blockArrow(length, bodyWidth, headWidth, headLength, direction) {
|
|
1296
|
+
this.nodeDef.shape = {
|
|
1297
|
+
kind: 'blockArrow',
|
|
1298
|
+
length,
|
|
1299
|
+
bodyWidth,
|
|
1300
|
+
headWidth,
|
|
1301
|
+
headLength,
|
|
1302
|
+
direction,
|
|
1303
|
+
};
|
|
1304
|
+
return this;
|
|
1305
|
+
}
|
|
1306
|
+
callout(w, h, opts) {
|
|
1307
|
+
this.nodeDef.shape = {
|
|
1308
|
+
kind: 'callout',
|
|
1309
|
+
w,
|
|
1310
|
+
h,
|
|
1311
|
+
rx: opts?.rx,
|
|
1312
|
+
pointerSide: opts?.pointerSide,
|
|
1313
|
+
pointerHeight: opts?.pointerHeight,
|
|
1314
|
+
pointerWidth: opts?.pointerWidth,
|
|
1315
|
+
pointerPosition: opts?.pointerPosition,
|
|
1316
|
+
};
|
|
1317
|
+
return this;
|
|
1318
|
+
}
|
|
1319
|
+
cloud(w, h) {
|
|
1320
|
+
this.nodeDef.shape = { kind: 'cloud', w, h };
|
|
1321
|
+
return this;
|
|
1322
|
+
}
|
|
1323
|
+
cross(size, barWidth) {
|
|
1324
|
+
this.nodeDef.shape = { kind: 'cross', size, barWidth };
|
|
1325
|
+
return this;
|
|
1326
|
+
}
|
|
1327
|
+
cube(w, h, depth) {
|
|
1328
|
+
this.nodeDef.shape = { kind: 'cube', w, h, depth };
|
|
1329
|
+
return this;
|
|
1330
|
+
}
|
|
1331
|
+
path(d, w, h) {
|
|
1332
|
+
this.nodeDef.shape = { kind: 'path', d, w, h };
|
|
1333
|
+
return this;
|
|
1334
|
+
}
|
|
1335
|
+
document(w, h, waveHeight) {
|
|
1336
|
+
this.nodeDef.shape = { kind: 'document', w, h, waveHeight };
|
|
1337
|
+
return this;
|
|
1338
|
+
}
|
|
1339
|
+
note(w, h, foldSize) {
|
|
1340
|
+
this.nodeDef.shape = { kind: 'note', w, h, foldSize };
|
|
1341
|
+
return this;
|
|
1342
|
+
}
|
|
1343
|
+
parallelogram(w, h, skew) {
|
|
1344
|
+
this.nodeDef.shape = { kind: 'parallelogram', w, h, skew };
|
|
1345
|
+
return this;
|
|
1346
|
+
}
|
|
1347
|
+
star(points, outerR, innerR) {
|
|
1348
|
+
this.nodeDef.shape = { kind: 'star', points, outerR, innerR };
|
|
1349
|
+
return this;
|
|
1350
|
+
}
|
|
1351
|
+
trapezoid(topW, bottomW, h) {
|
|
1352
|
+
this.nodeDef.shape = { kind: 'trapezoid', topW, bottomW, h };
|
|
1353
|
+
return this;
|
|
1354
|
+
}
|
|
1355
|
+
triangle(w, h, direction) {
|
|
1356
|
+
this.nodeDef.shape = { kind: 'triangle', w, h, direction };
|
|
1357
|
+
return this;
|
|
1358
|
+
}
|
|
858
1359
|
label(text, opts) {
|
|
859
1360
|
this.nodeDef.label = { text, ...opts };
|
|
860
1361
|
return this;
|
|
@@ -903,7 +1404,7 @@ class NodeBuilderImpl {
|
|
|
903
1404
|
throw new Error('NodeBuilder.animate(cb): node has no id');
|
|
904
1405
|
}
|
|
905
1406
|
// Compile to a portable AnimationSpec and store on the scene via the parent builder.
|
|
906
|
-
this.
|
|
1407
|
+
this._builder.animate((anim) => {
|
|
907
1408
|
anim.node(id);
|
|
908
1409
|
typeOrCb(anim);
|
|
909
1410
|
});
|
|
@@ -922,24 +1423,41 @@ class NodeBuilderImpl {
|
|
|
922
1423
|
this.nodeDef.onClick = handler;
|
|
923
1424
|
return this;
|
|
924
1425
|
}
|
|
1426
|
+
container(config) {
|
|
1427
|
+
this.nodeDef.container = config ?? { layout: 'free' };
|
|
1428
|
+
return this;
|
|
1429
|
+
}
|
|
1430
|
+
port(id, offset, direction) {
|
|
1431
|
+
if (!this.nodeDef.ports) {
|
|
1432
|
+
this.nodeDef.ports = [];
|
|
1433
|
+
}
|
|
1434
|
+
this.nodeDef.ports.push({ id, offset, direction });
|
|
1435
|
+
return this;
|
|
1436
|
+
}
|
|
1437
|
+
parent(parentId) {
|
|
1438
|
+
this.nodeDef.parentId = parentId;
|
|
1439
|
+
return this;
|
|
1440
|
+
}
|
|
925
1441
|
done() {
|
|
926
|
-
return this.
|
|
1442
|
+
return this._builder;
|
|
927
1443
|
}
|
|
928
1444
|
// Chaining
|
|
929
1445
|
node(id) {
|
|
930
|
-
return this.
|
|
1446
|
+
return this._builder.node(id);
|
|
931
1447
|
}
|
|
932
1448
|
edge(from, to, id) {
|
|
933
|
-
return this.
|
|
1449
|
+
return this._builder.edge(from, to, id);
|
|
934
1450
|
}
|
|
935
|
-
overlay(
|
|
936
|
-
|
|
1451
|
+
overlay(arg1, arg2, arg3) {
|
|
1452
|
+
if (typeof arg1 === 'function')
|
|
1453
|
+
return this._builder.overlay(arg1);
|
|
1454
|
+
return this._builder.overlay(arg1, arg2, arg3);
|
|
937
1455
|
}
|
|
938
1456
|
build() {
|
|
939
|
-
return this.
|
|
1457
|
+
return this._builder.build();
|
|
940
1458
|
}
|
|
941
1459
|
svg() {
|
|
942
|
-
return this.
|
|
1460
|
+
return this._builder.svg();
|
|
943
1461
|
}
|
|
944
1462
|
}
|
|
945
1463
|
class EdgeBuilderImpl {
|
|
@@ -950,21 +1468,102 @@ class EdgeBuilderImpl {
|
|
|
950
1468
|
this.edgeDef = edgeDef;
|
|
951
1469
|
}
|
|
952
1470
|
straight() {
|
|
953
|
-
|
|
1471
|
+
this.edgeDef.routing = 'straight';
|
|
1472
|
+
return this;
|
|
1473
|
+
}
|
|
1474
|
+
curved() {
|
|
1475
|
+
this.edgeDef.routing = 'curved';
|
|
1476
|
+
return this;
|
|
1477
|
+
}
|
|
1478
|
+
orthogonal() {
|
|
1479
|
+
this.edgeDef.routing = 'orthogonal';
|
|
1480
|
+
return this;
|
|
1481
|
+
}
|
|
1482
|
+
routing(mode) {
|
|
1483
|
+
this.edgeDef.routing = mode;
|
|
1484
|
+
return this;
|
|
1485
|
+
}
|
|
1486
|
+
via(x, y) {
|
|
1487
|
+
if (!this.edgeDef.waypoints) {
|
|
1488
|
+
this.edgeDef.waypoints = [];
|
|
1489
|
+
}
|
|
1490
|
+
this.edgeDef.waypoints.push({ x, y });
|
|
954
1491
|
return this;
|
|
955
1492
|
}
|
|
956
1493
|
label(text, opts) {
|
|
957
|
-
|
|
1494
|
+
const lbl = { position: 'mid', text, dy: -10, ...opts };
|
|
1495
|
+
// Accumulate into the labels array
|
|
1496
|
+
if (!this.edgeDef.labels) {
|
|
1497
|
+
this.edgeDef.labels = [];
|
|
1498
|
+
}
|
|
1499
|
+
this.edgeDef.labels.push(lbl);
|
|
1500
|
+
// Backwards compat: keep the first mid label in `label`
|
|
1501
|
+
if (lbl.position === 'mid' && !this.edgeDef.label) {
|
|
1502
|
+
this.edgeDef.label = lbl;
|
|
1503
|
+
}
|
|
958
1504
|
return this;
|
|
959
1505
|
}
|
|
960
1506
|
arrow(enabled = true) {
|
|
961
|
-
|
|
1507
|
+
if (enabled === 'both') {
|
|
1508
|
+
this.edgeDef.markerStart = 'arrow';
|
|
1509
|
+
this.edgeDef.markerEnd = 'arrow';
|
|
1510
|
+
}
|
|
1511
|
+
else if (enabled === 'start') {
|
|
1512
|
+
this.edgeDef.markerStart = 'arrow';
|
|
1513
|
+
}
|
|
1514
|
+
else if (enabled === 'end') {
|
|
1515
|
+
this.edgeDef.markerEnd = 'arrow';
|
|
1516
|
+
}
|
|
1517
|
+
else if (enabled === true) {
|
|
1518
|
+
this.edgeDef.markerEnd = 'arrow';
|
|
1519
|
+
}
|
|
1520
|
+
else {
|
|
1521
|
+
this.edgeDef.markerEnd = 'none';
|
|
1522
|
+
}
|
|
1523
|
+
return this;
|
|
1524
|
+
}
|
|
1525
|
+
markerEnd(type) {
|
|
1526
|
+
this.edgeDef.markerEnd = type;
|
|
1527
|
+
return this;
|
|
1528
|
+
}
|
|
1529
|
+
markerStart(type) {
|
|
1530
|
+
this.edgeDef.markerStart = type;
|
|
962
1531
|
return this;
|
|
963
1532
|
}
|
|
964
1533
|
connect(anchor) {
|
|
965
1534
|
this.edgeDef.anchor = anchor;
|
|
966
1535
|
return this;
|
|
967
1536
|
}
|
|
1537
|
+
fromPort(portId) {
|
|
1538
|
+
this.edgeDef.fromPort = portId;
|
|
1539
|
+
return this;
|
|
1540
|
+
}
|
|
1541
|
+
toPort(portId) {
|
|
1542
|
+
this.edgeDef.toPort = portId;
|
|
1543
|
+
return this;
|
|
1544
|
+
}
|
|
1545
|
+
fill(color) {
|
|
1546
|
+
this.edgeDef.style = {
|
|
1547
|
+
...(this.edgeDef.style || {}),
|
|
1548
|
+
fill: color,
|
|
1549
|
+
};
|
|
1550
|
+
return this;
|
|
1551
|
+
}
|
|
1552
|
+
stroke(color, width) {
|
|
1553
|
+
this.edgeDef.style = {
|
|
1554
|
+
...(this.edgeDef.style || {}),
|
|
1555
|
+
stroke: color,
|
|
1556
|
+
strokeWidth: width ?? this.edgeDef.style?.strokeWidth,
|
|
1557
|
+
};
|
|
1558
|
+
return this;
|
|
1559
|
+
}
|
|
1560
|
+
opacity(value) {
|
|
1561
|
+
this.edgeDef.style = {
|
|
1562
|
+
...(this.edgeDef.style || {}),
|
|
1563
|
+
opacity: value,
|
|
1564
|
+
};
|
|
1565
|
+
return this;
|
|
1566
|
+
}
|
|
968
1567
|
class(name) {
|
|
969
1568
|
if (this.edgeDef.className) {
|
|
970
1569
|
this.edgeDef.className += ` ${name}`;
|
|
@@ -1026,8 +1625,10 @@ class EdgeBuilderImpl {
|
|
|
1026
1625
|
edge(from, to, id) {
|
|
1027
1626
|
return this.parent.edge(from, to, id || `${from}->${to}`); // Default ID to from->to
|
|
1028
1627
|
}
|
|
1029
|
-
overlay(
|
|
1030
|
-
|
|
1628
|
+
overlay(arg1, arg2, arg3) {
|
|
1629
|
+
if (typeof arg1 === 'function')
|
|
1630
|
+
return this.parent.overlay(arg1);
|
|
1631
|
+
return this.parent.overlay(arg1, arg2, arg3);
|
|
1031
1632
|
}
|
|
1032
1633
|
build() {
|
|
1033
1634
|
return this.parent.build();
|