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/overlays.js CHANGED
@@ -64,6 +64,7 @@ export const coreGridLabelsOverlay = {
64
64
  return output;
65
65
  },
66
66
  };
67
+ // ... (OverlayRegistry and other exports remain unchanged) ...
67
68
  // Built-in Overlay: Data Points
68
69
  export const coreDataPointOverlay = {
69
70
  render: ({ spec, nodesById }) => {
@@ -133,7 +134,324 @@ export const coreDataPointOverlay = {
133
134
  });
134
135
  },
135
136
  };
137
+ // Generic Overlay: Rect
138
+ export const coreRectOverlay = {
139
+ render: ({ spec }) => {
140
+ const { x, y, w, h, rx, ry, opacity, fill, stroke, strokeWidth } = spec.params;
141
+ const cls = spec.className ?? 'viz-overlay-rect';
142
+ const rxAttr = rx !== undefined ? ` rx="${rx}"` : '';
143
+ const ryAttr = ry !== undefined ? ` ry="${ry}"` : '';
144
+ const opAttr = opacity !== undefined ? ` opacity="${opacity}"` : '';
145
+ const usingDefaultFill = fill === undefined;
146
+ const usingDefaultStroke = stroke === undefined;
147
+ const resolvedFill = fill ?? '#3b82f6';
148
+ const resolvedStroke = stroke ?? '#3b82f6';
149
+ const resolvedStrokeWidth = strokeWidth ?? 3;
150
+ const fillOpacityAttr = usingDefaultFill ? ' fill-opacity="0.12"' : '';
151
+ const strokeOpacityAttr = usingDefaultStroke ? ' stroke-opacity="0.9"' : '';
152
+ return `<rect x="${x}" y="${y}" width="${w}" height="${h}" fill="${resolvedFill}"${fillOpacityAttr} stroke="${resolvedStroke}"${strokeOpacityAttr} stroke-width="${resolvedStrokeWidth}"${rxAttr}${ryAttr}${opAttr} class="${cls}" />`;
153
+ },
154
+ update: ({ spec }, container) => {
155
+ const svgNS = 'http://www.w3.org/2000/svg';
156
+ const { x, y, w, h, rx, ry, opacity, fill, stroke, strokeWidth } = spec.params;
157
+ const cls = spec.className ?? 'viz-overlay-rect';
158
+ let rect = container.querySelector('rect');
159
+ if (!rect) {
160
+ rect = document.createElementNS(svgNS, 'rect');
161
+ container.appendChild(rect);
162
+ }
163
+ rect.setAttribute('x', String(x));
164
+ rect.setAttribute('y', String(y));
165
+ rect.setAttribute('width', String(w));
166
+ rect.setAttribute('height', String(h));
167
+ if (fill === undefined) {
168
+ rect.setAttribute('fill', '#3b82f6');
169
+ rect.setAttribute('fill-opacity', '0.12');
170
+ }
171
+ else {
172
+ rect.setAttribute('fill', fill);
173
+ rect.removeAttribute('fill-opacity');
174
+ }
175
+ if (stroke === undefined) {
176
+ rect.setAttribute('stroke', '#3b82f6');
177
+ rect.setAttribute('stroke-opacity', '0.9');
178
+ }
179
+ else {
180
+ rect.setAttribute('stroke', stroke);
181
+ rect.removeAttribute('stroke-opacity');
182
+ }
183
+ rect.setAttribute('stroke-width', String(strokeWidth ?? 3));
184
+ if (rx !== undefined)
185
+ rect.setAttribute('rx', String(rx));
186
+ else
187
+ rect.removeAttribute('rx');
188
+ if (ry !== undefined)
189
+ rect.setAttribute('ry', String(ry));
190
+ else
191
+ rect.removeAttribute('ry');
192
+ if (opacity !== undefined)
193
+ rect.setAttribute('opacity', String(opacity));
194
+ else
195
+ rect.removeAttribute('opacity');
196
+ rect.setAttribute('class', cls);
197
+ },
198
+ };
199
+ // Generic Overlay: Circle
200
+ export const coreCircleOverlay = {
201
+ render: ({ spec }) => {
202
+ const { x, y, r, opacity, fill, stroke, strokeWidth } = spec.params;
203
+ const cls = spec.className ?? 'viz-overlay-circle';
204
+ const opAttr = opacity !== undefined ? ` opacity="${opacity}"` : '';
205
+ const usingDefaultFill = fill === undefined;
206
+ const usingDefaultStroke = stroke === undefined;
207
+ const resolvedFill = fill ?? '#3b82f6';
208
+ const resolvedStroke = stroke ?? '#3b82f6';
209
+ const resolvedStrokeWidth = strokeWidth ?? 3;
210
+ const fillOpacityAttr = usingDefaultFill ? ' fill-opacity="0.12"' : '';
211
+ const strokeOpacityAttr = usingDefaultStroke ? ' stroke-opacity="0.9"' : '';
212
+ return `<circle cx="${x}" cy="${y}" r="${r}" fill="${resolvedFill}"${fillOpacityAttr} stroke="${resolvedStroke}"${strokeOpacityAttr} stroke-width="${resolvedStrokeWidth}"${opAttr} class="${cls}" />`;
213
+ },
214
+ update: ({ spec }, container) => {
215
+ const svgNS = 'http://www.w3.org/2000/svg';
216
+ const { x, y, r, opacity, fill, stroke, strokeWidth } = spec.params;
217
+ const cls = spec.className ?? 'viz-overlay-circle';
218
+ let circle = container.querySelector('circle');
219
+ if (!circle) {
220
+ circle = document.createElementNS(svgNS, 'circle');
221
+ container.appendChild(circle);
222
+ }
223
+ circle.setAttribute('cx', String(x));
224
+ circle.setAttribute('cy', String(y));
225
+ circle.setAttribute('r', String(r));
226
+ if (fill === undefined) {
227
+ circle.setAttribute('fill', '#3b82f6');
228
+ circle.setAttribute('fill-opacity', '0.12');
229
+ }
230
+ else {
231
+ circle.setAttribute('fill', fill);
232
+ circle.removeAttribute('fill-opacity');
233
+ }
234
+ if (stroke === undefined) {
235
+ circle.setAttribute('stroke', '#3b82f6');
236
+ circle.setAttribute('stroke-opacity', '0.9');
237
+ }
238
+ else {
239
+ circle.setAttribute('stroke', stroke);
240
+ circle.removeAttribute('stroke-opacity');
241
+ }
242
+ circle.setAttribute('stroke-width', String(strokeWidth ?? 3));
243
+ if (opacity !== undefined)
244
+ circle.setAttribute('opacity', String(opacity));
245
+ else
246
+ circle.removeAttribute('opacity');
247
+ circle.setAttribute('class', cls);
248
+ },
249
+ };
250
+ // Generic Overlay: Text
251
+ export const coreTextOverlay = {
252
+ render: ({ spec }) => {
253
+ const { x, y, text, opacity, fill, fontSize, fontWeight, textAnchor, dominantBaseline, } = spec.params;
254
+ const cls = spec.className ?? 'viz-overlay-text';
255
+ const opAttr = opacity !== undefined ? ` opacity="${opacity}"` : '';
256
+ const fsAttr = fontSize !== undefined ? ` font-size="${fontSize}"` : '';
257
+ const fwAttr = fontWeight !== undefined ? ` font-weight="${fontWeight}"` : '';
258
+ const taAttr = textAnchor !== undefined ? ` text-anchor="${textAnchor}"` : '';
259
+ const dbAttr = dominantBaseline !== undefined
260
+ ? ` dominant-baseline="${dominantBaseline}"`
261
+ : '';
262
+ // Basic text rendering; users should avoid untrusted HTML here.
263
+ const resolvedFill = fill ?? '#111';
264
+ return `<text x="${x}" y="${y}" fill="${resolvedFill}"${opAttr}${fsAttr}${fwAttr}${taAttr}${dbAttr} class="${cls}">${text}</text>`;
265
+ },
266
+ update: ({ spec }, container) => {
267
+ const svgNS = 'http://www.w3.org/2000/svg';
268
+ const { x, y, text, opacity, fill, fontSize, fontWeight, textAnchor, dominantBaseline, } = spec.params;
269
+ const cls = spec.className ?? 'viz-overlay-text';
270
+ let el = container.querySelector('text');
271
+ if (!el) {
272
+ el = document.createElementNS(svgNS, 'text');
273
+ container.appendChild(el);
274
+ }
275
+ el.setAttribute('x', String(x));
276
+ el.setAttribute('y', String(y));
277
+ el.setAttribute('fill', fill ?? '#111');
278
+ if (opacity !== undefined)
279
+ el.setAttribute('opacity', String(opacity));
280
+ else
281
+ el.removeAttribute('opacity');
282
+ if (fontSize !== undefined)
283
+ el.setAttribute('font-size', String(fontSize));
284
+ else
285
+ el.removeAttribute('font-size');
286
+ if (fontWeight !== undefined)
287
+ el.setAttribute('font-weight', String(fontWeight));
288
+ else
289
+ el.removeAttribute('font-weight');
290
+ if (textAnchor !== undefined)
291
+ el.setAttribute('text-anchor', textAnchor);
292
+ else
293
+ el.removeAttribute('text-anchor');
294
+ if (dominantBaseline !== undefined)
295
+ el.setAttribute('dominant-baseline', dominantBaseline);
296
+ else
297
+ el.removeAttribute('dominant-baseline');
298
+ el.setAttribute('class', cls);
299
+ el.textContent = text;
300
+ },
301
+ };
302
+ function groupTransform(params) {
303
+ const tx = params.x ?? 0;
304
+ const ty = params.y ?? 0;
305
+ const s = params.scale ?? 1;
306
+ const r = params.rotation ?? 0;
307
+ // translate first so scale/rotation occur around the group origin.
308
+ const parts = [`translate(${tx}, ${ty})`];
309
+ if (r)
310
+ parts.push(`rotate(${r})`);
311
+ if (s !== 1)
312
+ parts.push(`scale(${s})`);
313
+ return parts.join(' ');
314
+ }
315
+ function clamp01(v) {
316
+ if (v < 0)
317
+ return 0;
318
+ if (v > 1)
319
+ return 1;
320
+ return v;
321
+ }
322
+ function effectiveNodePos(node) {
323
+ return {
324
+ x: node.runtime?.x ?? node.pos.x,
325
+ y: node.runtime?.y ?? node.pos.y,
326
+ };
327
+ }
328
+ function resolveGroupTransformInputs(params, nodesById) {
329
+ const baseX = params.x ?? 0;
330
+ const baseY = params.y ?? 0;
331
+ let x = baseX;
332
+ let y = baseY;
333
+ if (params.from && params.to) {
334
+ const start = nodesById.get(params.from);
335
+ const end = nodesById.get(params.to);
336
+ if (start && end) {
337
+ const p = clamp01(params.progress ?? 0);
338
+ const a = effectiveNodePos(start);
339
+ const b = effectiveNodePos(end);
340
+ x = a.x + (b.x - a.x) * p + baseX;
341
+ y = a.y + (b.y - a.y) * p + baseY;
342
+ }
343
+ }
344
+ const userScale = params.scale ?? 1;
345
+ const m = params.magnitude;
346
+ const magScale = m === undefined ? 1 : 0.85 + 0.3 * clamp01(Math.abs(m));
347
+ const scale = userScale * magScale;
348
+ return {
349
+ x,
350
+ y,
351
+ scale,
352
+ rotation: params.rotation ?? 0,
353
+ };
354
+ }
355
+ // Composite Overlay: Group
356
+ export const coreGroupOverlay = {
357
+ render: ({ spec, nodesById, edgesById, scene, registry }) => {
358
+ const { children, opacity } = spec.params;
359
+ const inputs = resolveGroupTransformInputs(spec.params, nodesById);
360
+ const tr = groupTransform(inputs);
361
+ const opAttr = opacity !== undefined ? ` opacity="${opacity}"` : '';
362
+ const reg = registry;
363
+ if (!reg) {
364
+ // Best-effort render even if registry is missing.
365
+ return `<g transform="${tr}"${opAttr}></g>`;
366
+ }
367
+ let output = `<g transform="${tr}"${opAttr}>`;
368
+ children.forEach((childSpec, idx) => {
369
+ const renderer = reg.get(childSpec.id);
370
+ if (!renderer)
371
+ return;
372
+ const childCtx = {
373
+ spec: childSpec,
374
+ nodesById,
375
+ edgesById,
376
+ scene,
377
+ registry: reg,
378
+ };
379
+ // Wrap children in their own <g> so update() has stable containers.
380
+ const key = childSpec.key
381
+ ? `key:${childSpec.key}`
382
+ : `idx:${idx}:${childSpec.id}`;
383
+ output += `<g data-viz-role="overlay-child" data-overlay-child-id="${key}">`;
384
+ output += renderer.render(childCtx);
385
+ output += '</g>';
386
+ });
387
+ output += '</g>';
388
+ return output;
389
+ },
390
+ update: ({ spec, nodesById, edgesById, scene, registry }, container) => {
391
+ const reg = registry;
392
+ if (!reg)
393
+ return;
394
+ const { children, opacity } = spec.params;
395
+ const inputs = resolveGroupTransformInputs(spec.params, nodesById);
396
+ container.setAttribute('transform', groupTransform(inputs));
397
+ if (opacity !== undefined) {
398
+ container.setAttribute('opacity', String(opacity));
399
+ }
400
+ else {
401
+ container.removeAttribute('opacity');
402
+ }
403
+ const svgNS = 'http://www.w3.org/2000/svg';
404
+ const existing = new Map();
405
+ Array.from(container.children).forEach((child) => {
406
+ if (child instanceof SVGGElement) {
407
+ const id = child.getAttribute('data-overlay-child-id');
408
+ if (id)
409
+ existing.set(id, child);
410
+ }
411
+ });
412
+ const keep = new Set();
413
+ children.forEach((childSpec, idx) => {
414
+ const renderer = reg.get(childSpec.id);
415
+ if (!renderer)
416
+ return;
417
+ const key = childSpec.key
418
+ ? `key:${childSpec.key}`
419
+ : `idx:${idx}:${childSpec.id}`;
420
+ keep.add(key);
421
+ let childGroup = existing.get(key);
422
+ if (!childGroup) {
423
+ childGroup = document.createElementNS(svgNS, 'g');
424
+ childGroup.setAttribute('data-viz-role', 'overlay-child');
425
+ childGroup.setAttribute('data-overlay-child-id', key);
426
+ container.appendChild(childGroup);
427
+ }
428
+ const childCtx = {
429
+ spec: childSpec,
430
+ nodesById,
431
+ edgesById,
432
+ scene,
433
+ registry: reg,
434
+ };
435
+ if (renderer.update) {
436
+ renderer.update(childCtx, childGroup);
437
+ }
438
+ else {
439
+ childGroup.innerHTML = renderer.render(childCtx);
440
+ }
441
+ });
442
+ existing.forEach((el, id) => {
443
+ if (!keep.has(id))
444
+ el.remove();
445
+ });
446
+ },
447
+ };
136
448
  export const defaultCoreOverlayRegistry = new CoreOverlayRegistry()
137
449
  .register('signal', coreSignalOverlay)
138
450
  .register('grid-labels', coreGridLabelsOverlay)
139
- .register('data-points', coreDataPointOverlay);
451
+ .register('data-points', coreDataPointOverlay)
452
+ // Generic primitives
453
+ .register('rect', coreRectOverlay)
454
+ .register('circle', coreCircleOverlay)
455
+ .register('text', coreTextOverlay)
456
+ // Composite overlays
457
+ .register('group', coreGroupOverlay);
@@ -5,9 +5,9 @@ export interface RuntimePatchCtx {
5
5
  nodeShapesById: Map<string, SVGElement>;
6
6
  nodeLabelsById: Map<string, SVGTextElement>;
7
7
  edgeGroupsById: Map<string, SVGGElement>;
8
- edgeLinesById: Map<string, SVGLineElement>;
9
- edgeHitsById: Map<string, SVGLineElement>;
10
- edgeLabelsById: Map<string, SVGTextElement>;
8
+ edgeLinesById: Map<string, SVGPathElement>;
9
+ edgeHitsById: Map<string, SVGPathElement>;
10
+ edgeLabelsById: Map<string, SVGTextElement[]>;
11
11
  }
12
12
  export declare function createRuntimePatchCtx(svg: SVGSVGElement): RuntimePatchCtx;
13
13
  export declare function patchRuntime(scene: VizScene, ctx: RuntimePatchCtx): void;
@@ -1,11 +1,130 @@
1
- import { applyShapeGeometry, computeNodeAnchor, effectivePos } from './shapes';
2
- function computeEdgeEndpoints(start, end, edge) {
3
- const anchor = edge.anchor ?? 'boundary';
4
- const startPos = effectivePos(start);
5
- const endPos = effectivePos(end);
6
- const startAnchor = computeNodeAnchor(start, endPos, anchor);
7
- const endAnchor = computeNodeAnchor(end, startPos, anchor);
8
- return { start: startAnchor, end: endAnchor };
1
+ import { applyShapeGeometry, effectivePos } from './shapes';
2
+ import { computeEdgePath, computeEdgeEndpoints } from './edgePaths';
3
+ import { resolveEdgeLabelPosition, collectEdgeLabels } from './edgeLabels';
4
+ const svgNS = 'http://www.w3.org/2000/svg';
5
+ /** Sanitise a CSS color for use as a marker ID suffix. */
6
+ function colorToMarkerSuffix(color) {
7
+ return color.replace(/[^a-zA-Z0-9]/g, '_');
8
+ }
9
+ /** Return the marker id to use for a marker type with an optional custom stroke and position. */
10
+ function markerIdFor(markerType, stroke, position = 'end') {
11
+ if (markerType === 'none')
12
+ return '';
13
+ const base = `viz-${markerType}`;
14
+ const suffix = position === 'start' ? '-start' : '';
15
+ return stroke
16
+ ? `${base}${suffix}-${colorToMarkerSuffix(stroke)}`
17
+ : `${base}${suffix}`;
18
+ }
19
+ /**
20
+ * Create the SVG content element(s) for a marker type.
21
+ */
22
+ function createMarkerContent(markerType, color) {
23
+ switch (markerType) {
24
+ case 'arrow': {
25
+ const p = document.createElementNS(svgNS, 'polygon');
26
+ p.setAttribute('points', '0,2 10,5 0,8');
27
+ p.setAttribute('fill', color);
28
+ return p;
29
+ }
30
+ case 'arrowOpen': {
31
+ const p = document.createElementNS(svgNS, 'polyline');
32
+ p.setAttribute('points', '0,2 10,5 0,8');
33
+ p.setAttribute('fill', 'white');
34
+ p.setAttribute('stroke', color);
35
+ p.setAttribute('stroke-width', '1.5');
36
+ p.setAttribute('stroke-linejoin', 'miter');
37
+ return p;
38
+ }
39
+ case 'diamond': {
40
+ const p = document.createElementNS(svgNS, 'polygon');
41
+ p.setAttribute('points', '0,5 5,2 10,5 5,8');
42
+ p.setAttribute('fill', color);
43
+ return p;
44
+ }
45
+ case 'diamondOpen': {
46
+ const p = document.createElementNS(svgNS, 'polygon');
47
+ p.setAttribute('points', '0,5 5,2 10,5 5,8');
48
+ p.setAttribute('fill', 'white');
49
+ p.setAttribute('stroke', color);
50
+ p.setAttribute('stroke-width', '1.5');
51
+ return p;
52
+ }
53
+ case 'circle': {
54
+ const c = document.createElementNS(svgNS, 'circle');
55
+ c.setAttribute('cx', '5');
56
+ c.setAttribute('cy', '5');
57
+ c.setAttribute('r', '3');
58
+ c.setAttribute('fill', color);
59
+ return c;
60
+ }
61
+ case 'circleOpen': {
62
+ const c = document.createElementNS(svgNS, 'circle');
63
+ c.setAttribute('cx', '5');
64
+ c.setAttribute('cy', '5');
65
+ c.setAttribute('r', '3');
66
+ c.setAttribute('fill', 'white');
67
+ c.setAttribute('stroke', color);
68
+ c.setAttribute('stroke-width', '1.5');
69
+ return c;
70
+ }
71
+ case 'square': {
72
+ const r = document.createElementNS(svgNS, 'rect');
73
+ r.setAttribute('x', '2');
74
+ r.setAttribute('y', '2');
75
+ r.setAttribute('width', '6');
76
+ r.setAttribute('height', '6');
77
+ r.setAttribute('fill', color);
78
+ return r;
79
+ }
80
+ case 'bar': {
81
+ const l = document.createElementNS(svgNS, 'line');
82
+ l.setAttribute('x1', '5');
83
+ l.setAttribute('y1', '1');
84
+ l.setAttribute('x2', '5');
85
+ l.setAttribute('y2', '9');
86
+ l.setAttribute('stroke', color);
87
+ l.setAttribute('stroke-width', '2');
88
+ l.setAttribute('stroke-linecap', 'round');
89
+ return l;
90
+ }
91
+ case 'halfArrow': {
92
+ const p = document.createElementNS(svgNS, 'polygon');
93
+ p.setAttribute('points', '0,2 10,5 0,5');
94
+ p.setAttribute('fill', color);
95
+ return p;
96
+ }
97
+ default:
98
+ return null;
99
+ }
100
+ }
101
+ /**
102
+ * Ensure a `<marker>` for the given color and type exists inside `<defs>`.
103
+ * Creates one on the fly when the RuntimePatcher encounters a new stroke color or marker type.
104
+ */
105
+ function ensureColoredMarker(svg, color, markerType = 'arrow', position = 'end') {
106
+ const mid = markerIdFor(markerType, color, position);
107
+ if (!mid)
108
+ return '';
109
+ if (!svg.querySelector(`#${CSS.escape(mid)}`)) {
110
+ const defs = svg.querySelector('defs');
111
+ if (defs) {
112
+ const m = document.createElementNS(svgNS, 'marker');
113
+ m.setAttribute('id', mid);
114
+ m.setAttribute('viewBox', '0 0 10 10');
115
+ m.setAttribute('markerWidth', '10');
116
+ m.setAttribute('markerHeight', '10');
117
+ m.setAttribute('refX', '9');
118
+ m.setAttribute('refY', '5');
119
+ m.setAttribute('orient', position === 'start' ? 'auto-start-reverse' : 'auto');
120
+ const content = createMarkerContent(markerType, color);
121
+ if (content) {
122
+ m.appendChild(content);
123
+ }
124
+ defs.appendChild(m);
125
+ }
126
+ }
127
+ return mid;
9
128
  }
10
129
  export function createRuntimePatchCtx(svg) {
11
130
  const nodeGroupsById = new Map();
@@ -51,10 +170,9 @@ export function createRuntimePatchCtx(svg) {
51
170
  group.querySelector('.viz-edge-hit');
52
171
  if (hit)
53
172
  edgeHitsById.set(id, hit);
54
- const label = group.querySelector('[data-viz-role="edge-label"]') ||
55
- group.querySelector('.viz-edge-label');
56
- if (label)
57
- edgeLabelsById.set(id, label);
173
+ const labels = Array.from(group.querySelectorAll('[data-viz-role="edge-label"],.viz-edge-label'));
174
+ if (labels.length > 0)
175
+ edgeLabelsById.set(id, labels);
58
176
  }
59
177
  }
60
178
  return {
@@ -70,20 +188,61 @@ export function createRuntimePatchCtx(svg) {
70
188
  }
71
189
  export function patchRuntime(scene, ctx) {
72
190
  const nodesById = new Map(scene.nodes.map((n) => [n.id, n]));
191
+ // Pre-compute parent position deltas for container propagation.
192
+ // When a container node moves via runtime, children should follow.
193
+ const parentDeltas = new Map();
194
+ for (const node of scene.nodes) {
195
+ if (node.container) {
196
+ const dx = (node.runtime?.x ?? node.pos.x) - node.pos.x;
197
+ const dy = (node.runtime?.y ?? node.pos.y) - node.pos.y;
198
+ if (dx !== 0 || dy !== 0) {
199
+ parentDeltas.set(node.id, { dx, dy });
200
+ }
201
+ }
202
+ }
73
203
  // Nodes: patch geometry + label position + runtime transforms/opacity.
74
204
  for (const node of scene.nodes) {
75
205
  const group = ctx.nodeGroupsById.get(node.id);
76
206
  const shape = ctx.nodeShapesById.get(node.id);
77
207
  if (!group || !shape)
78
208
  continue;
79
- const { x, y } = effectivePos(node);
209
+ let { x, y } = effectivePos(node);
210
+ // Apply parent container offset so children follow the container
211
+ if (node.parentId) {
212
+ const delta = parentDeltas.get(node.parentId);
213
+ if (delta) {
214
+ x += delta.dx;
215
+ y += delta.dy;
216
+ }
217
+ }
80
218
  // Geometry
81
219
  applyShapeGeometry(shape, node.shape, { x, y });
220
+ // Container header line (update position if present)
221
+ if (node.container?.headerHeight &&
222
+ 'w' in node.shape &&
223
+ 'h' in node.shape) {
224
+ const headerLine = group.querySelector('[data-viz-role="container-header"]');
225
+ if (headerLine) {
226
+ const sw = node.shape.w;
227
+ const sh = node.shape.h;
228
+ const headerY = y - sh / 2 + node.container.headerHeight;
229
+ headerLine.setAttribute('x1', String(x - sw / 2));
230
+ headerLine.setAttribute('y1', String(headerY));
231
+ headerLine.setAttribute('x2', String(x + sw / 2));
232
+ headerLine.setAttribute('y2', String(headerY));
233
+ }
234
+ }
82
235
  // Label position
83
236
  const label = ctx.nodeLabelsById.get(node.id);
84
237
  if (label && node.label) {
85
- const lx = x + (node.label.dx || 0);
86
- const ly = y + (node.label.dy || 0);
238
+ let lx = x + (node.label.dx || 0);
239
+ let ly = y + (node.label.dy || 0);
240
+ // Container header label centering
241
+ if (node.container?.headerHeight && 'h' in node.shape && !node.label.dy) {
242
+ const sh = node.shape.h;
243
+ ly = y - sh / 2 + node.container.headerHeight / 2;
244
+ lx = x + (node.label.dx || 0);
245
+ }
87
246
  label.setAttribute('x', String(lx));
88
247
  label.setAttribute('y', String(ly));
89
248
  }
@@ -112,6 +271,18 @@ export function patchRuntime(scene, ctx) {
112
271
  else {
113
272
  group.removeAttribute('transform');
114
273
  }
274
+ // Port positions follow the node
275
+ if (node.ports) {
276
+ const portEls = group.querySelectorAll('[data-viz-role="port"]');
277
+ portEls.forEach((portEl) => {
278
+ const portId = portEl.getAttribute('data-port');
279
+ const port = node.ports.find((p) => p.id === portId);
280
+ if (port) {
281
+ portEl.setAttribute('cx', String(x + port.offset.x));
282
+ portEl.setAttribute('cy', String(y + port.offset.y));
283
+ }
284
+ });
285
+ }
115
286
  }
116
287
  // Edges: patch endpoints + runtime props (opacity, strokeDashoffset) + label + hit.
117
288
  for (const edge of scene.edges) {
@@ -124,24 +295,53 @@ export function patchRuntime(scene, ctx) {
124
295
  if (!start || !end)
125
296
  continue;
126
297
  const endpoints = computeEdgeEndpoints(start, end, edge);
127
- // Endpoints
128
- line.setAttribute('x1', String(endpoints.start.x));
129
- line.setAttribute('y1', String(endpoints.start.y));
130
- line.setAttribute('x2', String(endpoints.end.x));
131
- line.setAttribute('y2', String(endpoints.end.y));
298
+ const edgePath = computeEdgePath(endpoints.start, endpoints.end, edge.routing, edge.waypoints);
299
+ // Path
300
+ line.setAttribute('d', edgePath.d);
301
+ // Per-edge style overrides (inline style wins over CSS class defaults)
302
+ if (edge.style?.stroke !== undefined) {
303
+ line.style.stroke = edge.style.stroke;
304
+ }
305
+ if (edge.style?.strokeWidth !== undefined)
306
+ line.style.strokeWidth = String(edge.style.strokeWidth);
307
+ if (edge.style?.fill !== undefined)
308
+ line.style.fill = edge.style.fill;
309
+ if (edge.style?.opacity !== undefined)
310
+ line.style.opacity = String(edge.style.opacity);
311
+ // Update marker-end and marker-start to match edge stroke color
312
+ if (edge.markerEnd && edge.markerEnd !== 'none') {
313
+ const mid = edge.style?.stroke
314
+ ? ensureColoredMarker(ctx.svg, edge.style.stroke, edge.markerEnd, 'end')
315
+ : markerIdFor(edge.markerEnd, undefined, 'end');
316
+ line.setAttribute('marker-end', `url(#${mid})`);
317
+ }
318
+ else {
319
+ line.removeAttribute('marker-end');
320
+ }
321
+ if (edge.markerStart && edge.markerStart !== 'none') {
322
+ const mid = edge.style?.stroke
323
+ ? ensureColoredMarker(ctx.svg, edge.style.stroke, edge.markerStart, 'start')
324
+ : markerIdFor(edge.markerStart, undefined, 'start');
325
+ line.setAttribute('marker-start', `url(#${mid})`);
326
+ }
327
+ else {
328
+ line.removeAttribute('marker-start');
329
+ }
132
330
  const hit = ctx.edgeHitsById.get(edge.id);
133
331
  if (hit) {
134
- hit.setAttribute('x1', String(endpoints.start.x));
135
- hit.setAttribute('y1', String(endpoints.start.y));
136
- hit.setAttribute('x2', String(endpoints.end.x));
137
- hit.setAttribute('y2', String(endpoints.end.y));
138
- }
139
- const label = ctx.edgeLabelsById.get(edge.id);
140
- if (label && edge.label) {
141
- const mx = (endpoints.start.x + endpoints.end.x) / 2 + (edge.label.dx || 0);
142
- const my = (endpoints.start.y + endpoints.end.y) / 2 + (edge.label.dy || 0);
143
- label.setAttribute('x', String(mx));
144
- label.setAttribute('y', String(my));
332
+ hit.setAttribute('d', edgePath.d);
333
+ }
334
+ const labelEls = ctx.edgeLabelsById.get(edge.id);
335
+ if (labelEls) {
336
+ const allLabels = collectEdgeLabels(edge);
337
+ labelEls.forEach((el, idx) => {
338
+ const lbl = allLabels[idx];
339
+ if (!lbl)
340
+ return;
341
+ const pos = resolveEdgeLabelPosition(lbl, edgePath);
342
+ el.setAttribute('x', String(pos.x));
343
+ el.setAttribute('y', String(pos.y));
344
+ });
145
345
  }
146
346
  // Runtime overrides
147
347
  if (edge.runtime?.opacity !== undefined) {