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/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, '&lt;')
44
+ .replace(/>/g, '&gt;');
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, computeNodeAnchor, effectivePos, getShapeBehavior, shapeSvgMarkup, } from './shapes';
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
- * Adds an overlay to the scene.
80
- * @param id The ID of the overlay
81
- * @param params The parameters of the overlay
82
- * @param key The key of the overlay
83
- * @returns The builder
84
- */
85
- overlay(id, params, key) {
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
- const marker = document.createElementNS(svgNS, 'marker');
302
- marker.setAttribute('id', 'viz-arrow');
303
- marker.setAttribute('markerWidth', '10');
304
- marker.setAttribute('markerHeight', '7');
305
- marker.setAttribute('refX', '9');
306
- marker.setAttribute('refY', '3.5');
307
- marker.setAttribute('orient', 'auto');
308
- const poly = document.createElementNS(svgNS, 'polygon');
309
- poly.setAttribute('points', '0 0, 10 3.5, 0 7');
310
- poly.setAttribute('fill', 'currentColor');
311
- marker.appendChild(poly);
312
- defs.appendChild(marker);
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 line = document.createElementNS(svgNS, 'line');
365
- line.setAttribute('class', 'viz-edge');
366
- line.setAttribute('data-viz-role', 'edge-line');
367
- group.appendChild(line);
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 Line
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('x1', String(endpoints.start.x));
422
- line.setAttribute('y1', String(endpoints.start.y));
423
- line.setAttribute('x2', String(endpoints.end.x));
424
- line.setAttribute('y2', String(endpoints.end.y));
425
- line.setAttribute('stroke', 'currentColor');
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, 'line');
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('x1', String(endpoints.start.x));
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
- // Label (Recreate vs Update)
456
- const oldLabel = group.querySelector('[data-viz-role="edge-label"]') ||
457
- group.querySelector('.viz-edge-label');
458
- if (oldLabel)
459
- oldLabel.remove();
460
- if (edge.label) {
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
- const mx = (endpoints.start.x + endpoints.end.x) / 2 + (edge.label.dx || 0);
463
- const my = (endpoints.start.y + endpoints.end.y) / 2 + (edge.label.dy || 0);
464
- text.setAttribute('x', String(mx));
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 = edge.label.text;
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
- const existingNodeGroups = Array.from(nodeLayer.children).filter((el) => el.tagName === 'g');
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
- nodes.forEach((node) => {
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
- nodeLayer.appendChild(group);
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); // Shape always at bottom
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
- // Label (Recreate for simplicity as usually just text/pos changes)
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
- const lx = x + (node.label.dx || 0);
577
- const ly = y + (node.label.dy || 0);
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
- // Remove stale nodes
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 (Arrow Marker)
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 === 'arrow' ? 'marker-end="url(#viz-arrow)"' : '';
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
- svgContent += `<line x1="${endpoints.start.x}" y1="${endpoints.start.y}" x2="${endpoints.end.x}" y2="${endpoints.end.y}" class="viz-edge" data-viz-role="edge-line" ${markerEnd} stroke="currentColor" style="${lineRuntimeStyle}"${lineRuntimeAttrs} />`;
722
- // Edge Label
723
- if (edge.label) {
724
- const mx = (endpoints.start.x + endpoints.end.x) / 2 + (edge.label.dx || 0);
725
- const my = (endpoints.start.y + endpoints.end.y) / 2 + (edge.label.dy || 0);
726
- const labelClass = `viz-edge-label ${edge.label.className || ''}`;
727
- svgContent += `<text x="${mx}" y="${my}" class="${labelClass}" data-viz-role="edge-label" text-anchor="middle" dominant-baseline="middle">${edge.label.text}</text>`;
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
- // Render Nodes
733
- svgContent += '<g class="viz-layer-nodes" data-viz-layer="nodes">';
734
- nodes.forEach((node) => {
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 className = `viz-node-group ${node.className || ''} ${animClasses}`;
769
- svgContent += `<g data-id="${node.id}" data-viz-role="node-group" class="${className}" style="${animStyleStr}">`;
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
- svgContent += shapeSvgMarkup(shape, { x, y }, shapeStyleAttrs);
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
- const lx = x + (node.label.dx || 0);
781
- const ly = y + (node.label.dy || 0);
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
- svgContent += `<text x="${lx}" y="${ly}" class="${labelClass}" data-viz-role="node-label"${labelAttrs}>${node.label.text}</text>`;
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({ spec, nodesById, edgesById, scene });
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
- parent;
1232
+ _builder;
812
1233
  nodeDef;
813
1234
  constructor(parent, nodeDef) {
814
- this.parent = parent;
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.parent._getGridConfig();
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.parent._getViewBox();
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.parent.animate((anim) => {
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.parent;
1442
+ return this._builder;
927
1443
  }
928
1444
  // Chaining
929
1445
  node(id) {
930
- return this.parent.node(id);
1446
+ return this._builder.node(id);
931
1447
  }
932
1448
  edge(from, to, id) {
933
- return this.parent.edge(from, to, id);
1449
+ return this._builder.edge(from, to, id);
934
1450
  }
935
- overlay(id, params, key) {
936
- return this.parent.overlay(id, params, key);
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.parent.build();
1457
+ return this._builder.build();
940
1458
  }
941
1459
  svg() {
942
- return this.parent.svg();
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
- // No-op for now as it is default
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
- this.edgeDef.label = { position: 'mid', text, dy: -10, ...opts };
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
- this.edgeDef.markerEnd = enabled ? 'arrow' : 'none';
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(id, params, key) {
1030
- return this.parent.overlay(id, params, key);
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();