@dr2rai/raid-canvas 0.3.0 → 0.3.2

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/src/RaiBridge.ts CHANGED
@@ -24,7 +24,13 @@ import {
24
24
  type HydrationOptions,
25
25
  type SerializationOptions,
26
26
  } from './types.js';
27
- import { createAimNode, createAimEdge, configureAimGraph, CascaisPalette } from './X6Shapes.js';
27
+ import {
28
+ createAimNode,
29
+ createAimEdge,
30
+ configureAimGraph,
31
+ CascaisPalette,
32
+ wrapAimText,
33
+ } from './X6Shapes.js';
28
34
 
29
35
  export class RaiBridge {
30
36
  /**
@@ -154,6 +160,34 @@ export class RaiBridge {
154
160
  }
155
161
  }
156
162
 
163
+ // Extract live rendered SVG path data from X6 EdgeView if available
164
+ let pathData: string | undefined = customData.pathData;
165
+ if (!pathData) {
166
+ const edgeView =
167
+ typeof (graph as unknown as { findViewByCell?: (cell: unknown) => unknown }).findViewByCell === 'function'
168
+ ? (
169
+ graph as unknown as {
170
+ findViewByCell: (cell: unknown) => {
171
+ getConnectionPathData?: () => string;
172
+ container?: Element;
173
+ } | null;
174
+ }
175
+ ).findViewByCell(edge)
176
+ : null;
177
+
178
+ if (edgeView) {
179
+ if (typeof edgeView.getConnectionPathData === 'function') {
180
+ const d = edgeView.getConnectionPathData();
181
+ if (d && d.trim().length > 0) pathData = d;
182
+ }
183
+ if (!pathData && edgeView.container) {
184
+ const pathEl = edgeView.container.querySelector('path[d]');
185
+ const d = pathEl?.getAttribute('d');
186
+ if (d && d.trim().length > 0) pathData = d;
187
+ }
188
+ }
189
+ }
190
+
157
191
  const edgeData: RaidEdgeData = {
158
192
  id: edge.id,
159
193
  kind: customData.kind ?? 'association',
@@ -167,16 +201,20 @@ export class RaiBridge {
167
201
  ...(customData.sourceCardinality !== undefined ? { sourceCardinality: customData.sourceCardinality } : {}),
168
202
  ...(customData.targetCardinality !== undefined ? { targetCardinality: customData.targetCardinality } : {}),
169
203
  bendPoints,
204
+ ...(pathData !== undefined ? { pathData } : {}),
170
205
  };
171
206
 
172
207
  edges.push(edgeData);
173
208
  }
174
209
 
210
+ const diagramRouting = (graph as unknown as { _aimRoutingMode?: AimRoutingMode })._aimRoutingMode;
211
+
175
212
  return {
176
213
  diagramId: 'RaidDiagram',
177
214
  archetype: 'InteractiveCanvas',
178
215
  nodes,
179
216
  edges,
217
+ ...(diagramRouting !== undefined ? { routing: diagramRouting } : {}),
180
218
  };
181
219
  }
182
220
 
@@ -207,6 +245,79 @@ export class RaiBridge {
207
245
  return points.map((p) => `${Math.round(p.x)},${Math.round(p.y)}`).join('; ');
208
246
  }
209
247
 
248
+ /**
249
+ * Computes a clean fallback SVG path connecting source and target nodes
250
+ * when running in headless environments (e.g. CLI, tests) without an active DOM.
251
+ */
252
+ public computeFallbackEdgePath(
253
+ sourceNode: RaidNodeData,
254
+ targetNode: RaidNodeData,
255
+ bendPoints: readonly SvgBendPoint[] = [],
256
+ routingMode: AimRoutingMode = 'manhattan',
257
+ ): string {
258
+ const scx = Math.round(sourceNode.bounds.x + sourceNode.bounds.width / 2);
259
+ const scy = Math.round(sourceNode.bounds.y + sourceNode.bounds.height / 2);
260
+ const tcx = Math.round(targetNode.bounds.x + targetNode.bounds.width / 2);
261
+ const tcy = Math.round(targetNode.bounds.y + targetNode.bounds.height / 2);
262
+
263
+ let sx = scx;
264
+ let sy = scy;
265
+ let tx = tcx;
266
+ let ty = tcy;
267
+
268
+ const dx = tcx - scx;
269
+ const dy = tcy - scy;
270
+
271
+ if (Math.abs(dx) >= Math.abs(dy)) {
272
+ if (dx > 0) {
273
+ sx = Math.round(sourceNode.bounds.x + sourceNode.bounds.width);
274
+ sy = scy;
275
+ tx = Math.round(targetNode.bounds.x);
276
+ ty = tcy;
277
+ } else {
278
+ sx = Math.round(sourceNode.bounds.x);
279
+ sy = scy;
280
+ tx = Math.round(targetNode.bounds.x + targetNode.bounds.width);
281
+ ty = tcy;
282
+ }
283
+ } else {
284
+ if (dy > 0) {
285
+ sx = scx;
286
+ sy = Math.round(sourceNode.bounds.y + sourceNode.bounds.height);
287
+ tx = tcx;
288
+ ty = Math.round(targetNode.bounds.y);
289
+ } else {
290
+ sx = scx;
291
+ sy = Math.round(sourceNode.bounds.y);
292
+ tx = tcx;
293
+ ty = Math.round(targetNode.bounds.y + targetNode.bounds.height);
294
+ }
295
+ }
296
+
297
+ if (bendPoints.length > 0) {
298
+ return `M ${sx} ${sy} ` + bendPoints.map((p) => `L ${Math.round(p.x)} ${Math.round(p.y)}`).join(' ') + ` L ${tx} ${ty}`;
299
+ }
300
+
301
+ if (routingMode === 'normal') {
302
+ return `M ${sx} ${sy} L ${tx} ${ty}`;
303
+ }
304
+
305
+ if (routingMode === 'smooth') {
306
+ const midX = Math.round((sx + tx) / 2);
307
+ const midY = Math.round((sy + ty) / 2);
308
+ return `M ${sx} ${sy} Q ${midX} ${sy} ${midX} ${midY} T ${tx} ${ty}`;
309
+ }
310
+
311
+ // Manhattan orthogonal default
312
+ if (Math.abs(dx) >= Math.abs(dy)) {
313
+ const midX = Math.round((sx + tx) / 2);
314
+ return `M ${sx} ${sy} L ${midX} ${sy} L ${midX} ${ty} L ${tx} ${ty}`;
315
+ } else {
316
+ const midY = Math.round((sy + ty) / 2);
317
+ return `M ${sx} ${sy} L ${sx} ${midY} L ${tx} ${midY} L ${tx} ${ty}`;
318
+ }
319
+ }
320
+
210
321
  // --------------------------------------------------------------------------
211
322
  // Private Helper Implementation
212
323
  // --------------------------------------------------------------------------
@@ -296,8 +407,8 @@ export class RaiBridge {
296
407
  const sourceNode = nodes.find((n) => n.id === sourceId);
297
408
  const targetNode = nodes.find((n) => n.id === targetId);
298
409
 
299
- // Infer optimal orthogonal docking ports if not explicitly declared
300
- if (sourceNode && targetNode) {
410
+ // Infer optimal orthogonal docking ports only if explicitly requested in options
411
+ if (options.inferPorts === true && sourceNode && targetNode) {
301
412
  const dx =
302
413
  targetNode.bounds.x +
303
414
  targetNode.bounds.width / 2 -
@@ -349,10 +460,7 @@ export class RaiBridge {
349
460
  el.getAttribute(AimSvgContract.ATTR_ROUTING) ??
350
461
  el.getAttribute('aim-routing') ??
351
462
  undefined;
352
- const routing =
353
- rawRouting === 'normal' || rawRouting === 'smooth' || rawRouting === 'manhattan'
354
- ? (rawRouting as AimRoutingMode)
355
- : undefined;
463
+ const routing = rawRouting ? this.normalizeRouting(rawRouting) : undefined;
356
464
 
357
465
  edges.push({
358
466
  id,
@@ -369,11 +477,18 @@ export class RaiBridge {
369
477
  });
370
478
  }
371
479
 
480
+ const rawDiagramRouting =
481
+ svgRoot.getAttribute(AimSvgContract.ATTR_ROUTING) ??
482
+ svgRoot.getAttribute('aim-routing') ??
483
+ undefined;
484
+ const diagramRouting = rawDiagramRouting ? this.normalizeRouting(rawDiagramRouting) : undefined;
485
+
372
486
  return {
373
487
  diagramId: svgRoot.getAttribute('id') ?? 'ImportedDiagram',
374
488
  archetype: svgRoot.getAttribute('aim-archetype') ?? 'AOAIMDiagram',
375
489
  nodes,
376
490
  edges,
491
+ ...(diagramRouting !== undefined ? { routing: diagramRouting } : {}),
377
492
  };
378
493
  }
379
494
 
@@ -397,7 +512,7 @@ export class RaiBridge {
397
512
  }
398
513
 
399
514
  // Check direct geometry attributes (rect, ellipse, circle)
400
- const rect = el.querySelector('rect') ?? (el.tagName.toLowerCase() === 'rect' ? el : null);
515
+ const rect = el.querySelector?.('rect') ?? (el.tagName?.toLowerCase() === 'rect' ? el : null);
401
516
  if (rect) {
402
517
  if (!transform && rect.getAttribute('x')) x = parseFloat(rect.getAttribute('x')!);
403
518
  if (!transform && rect.getAttribute('y')) y = parseFloat(rect.getAttribute('y')!);
@@ -406,7 +521,7 @@ export class RaiBridge {
406
521
  }
407
522
 
408
523
  const ellipse =
409
- el.querySelector('ellipse') ?? (el.tagName.toLowerCase() === 'ellipse' ? el : null);
524
+ el.querySelector?.('ellipse') ?? (el.tagName?.toLowerCase() === 'ellipse' ? el : null);
410
525
  if (ellipse) {
411
526
  const rx = parseFloat(ellipse.getAttribute('rx') ?? `${width / 2}`);
412
527
  const ry = parseFloat(ellipse.getAttribute('ry') ?? `${height / 2}`);
@@ -423,7 +538,7 @@ export class RaiBridge {
423
538
  return { x, y, width, height };
424
539
  }
425
540
 
426
- private updateExistingSvg(
541
+ public updateExistingSvg(
427
542
  baseSvg: string,
428
543
  model: RaidMetamodel,
429
544
  _options: SerializationOptions,
@@ -435,65 +550,313 @@ export class RaiBridge {
435
550
  const parser = new DOMParser();
436
551
  const doc = parser.parseFromString(baseSvg, 'image/svg+xml');
437
552
 
438
- // Update node positions and transforms
553
+ // Ensure defs and arrow markers exist for external vector viewers (Preview, Chrome, Safari)
554
+ let defs = doc.querySelector('defs');
555
+ if (!defs) {
556
+ defs = doc.createElementNS('http://www.w3.org/2000/svg', 'defs');
557
+ doc.documentElement.insertBefore(defs, doc.documentElement.firstChild);
558
+ }
559
+ if (!doc.querySelector('#arrow-classic')) {
560
+ const marker = doc.createElementNS('http://www.w3.org/2000/svg', 'marker');
561
+ marker.setAttribute('id', 'arrow-classic');
562
+ marker.setAttribute('viewBox', '0 0 10 10');
563
+ marker.setAttribute('refX', '10');
564
+ marker.setAttribute('refY', '5');
565
+ marker.setAttribute('markerWidth', '7');
566
+ marker.setAttribute('markerHeight', '7');
567
+ marker.setAttribute('orient', 'auto-start-reverse');
568
+ const path = doc.createElementNS('http://www.w3.org/2000/svg', 'path');
569
+ path.setAttribute('d', 'M 0 0 L 10 5 L 0 10 z');
570
+ path.setAttribute('fill', CascaisPalette.WarmGraphite);
571
+ marker.appendChild(path);
572
+ defs.appendChild(marker);
573
+ }
574
+ if (!doc.querySelector('#arrow-hollow')) {
575
+ const marker = doc.createElementNS('http://www.w3.org/2000/svg', 'marker');
576
+ marker.setAttribute('id', 'arrow-hollow');
577
+ marker.setAttribute('viewBox', '0 0 12 12');
578
+ marker.setAttribute('refX', '12');
579
+ marker.setAttribute('refY', '6');
580
+ marker.setAttribute('markerWidth', '9');
581
+ marker.setAttribute('markerHeight', '9');
582
+ marker.setAttribute('orient', 'auto-start-reverse');
583
+ const polygon = doc.createElementNS('http://www.w3.org/2000/svg', 'polygon');
584
+ polygon.setAttribute('points', '0 0, 12 6, 0 12');
585
+ polygon.setAttribute('fill', CascaisPalette.ChalkWhite);
586
+ polygon.setAttribute('stroke', CascaisPalette.WarmGraphite);
587
+ polygon.setAttribute('stroke-width', '1.5');
588
+ marker.appendChild(polygon);
589
+ defs.appendChild(marker);
590
+ }
591
+
592
+ // Ensure AOAIM style rules are present
593
+ let style: Element | null = doc.querySelector('style');
594
+ if (!style) {
595
+ style = doc.createElementNS('http://www.w3.org/2000/svg', 'style');
596
+ defs.appendChild(style);
597
+ }
598
+ if (style && !style.textContent?.includes('.aim-act text')) {
599
+ style.textContent = (style.textContent ? style.textContent + '\n' : '') + `
600
+ .aim-node { cursor: pointer; transition: filter 0.15s ease; }
601
+ .aim-node:hover { filter: drop-shadow(0 4px 6px rgba(0,0,0,0.1)); }
602
+ .aim-edge { fill: none; stroke: ${CascaisPalette.WarmGraphite}; stroke-width: 1.5; }
603
+ text { font-family: Inter, system-ui, sans-serif; }
604
+ .aim-act text, .aim-obj text { text-decoration: underline; }
605
+ `;
606
+ }
607
+
608
+ // Update diagram-level routing mode on root <svg>
609
+ const diagramRouting = _options.routingMode ?? model.routing;
610
+ if (diagramRouting) {
611
+ doc.documentElement.setAttribute(AimSvgContract.ATTR_ROUTING, diagramRouting);
612
+ }
613
+
614
+ // Ensure layers exist
615
+ let nodesLayer = doc.querySelector('.aim-nodes-layer');
616
+ if (!nodesLayer) {
617
+ nodesLayer = doc.documentElement;
618
+ }
619
+
620
+ // Remove deleted nodes
621
+ const existingNodeEls = Array.from(doc.querySelectorAll(AimSvgContract.SELECTOR_NODE));
622
+ for (const nodeEl of existingNodeEls) {
623
+ const id = nodeEl.getAttribute(AimSvgContract.ATTR_ID) ?? nodeEl.getAttribute('id');
624
+ if (id && !model.nodes.some((n) => n.id === id)) {
625
+ nodeEl.remove();
626
+ }
627
+ }
628
+
629
+ // Update or insert nodes with canonical archetype shape markup
439
630
  for (const node of model.nodes) {
440
- const el = doc.querySelector(`[${AimSvgContract.ATTR_ID}="${node.id}"], [id="${node.id}"]`);
441
- if (el) {
442
- el.setAttribute('transform', `translate(${node.bounds.x}, ${node.bounds.y})`);
443
- el.setAttribute(AimSvgContract.ATTR_NODE, 'true');
444
- el.setAttribute(AimSvgContract.ATTR_KIND, node.kind);
445
-
446
- const rect = el.querySelector('rect');
447
- if (rect) {
448
- rect.setAttribute('width', `${node.bounds.width}`);
449
- rect.setAttribute('height', `${node.bounds.height}`);
450
- }
631
+ let el = doc.querySelector(`[${AimSvgContract.ATTR_ID}="${node.id}"], [id="${node.id}"]`);
632
+ if (!el) {
633
+ el = doc.createElementNS('http://www.w3.org/2000/svg', 'g');
634
+ nodesLayer.appendChild(el);
635
+ }
636
+
637
+ el.setAttribute('transform', `translate(${node.bounds.x}, ${node.bounds.y})`);
638
+ el.setAttribute(AimSvgContract.ATTR_NODE, 'true');
639
+ el.setAttribute(AimSvgContract.ATTR_ID, node.id);
640
+ el.setAttribute(AimSvgContract.ATTR_KIND, node.kind);
641
+ el.setAttribute(AimSvgContract.ATTR_DISPLAY_NAME, node.displayName);
642
+ if (node.stereotype) {
643
+ el.setAttribute(AimSvgContract.ATTR_STEREOTYPE, node.stereotype);
644
+ } else {
645
+ el.removeAttribute(AimSvgContract.ATTR_STEREOTYPE);
646
+ }
647
+
648
+ // Replace inner content with canonical archetype shape markup
649
+ const innerSvg = this.renderNodeInnerSvg(node);
650
+ const fragmentDoc = parser.parseFromString(
651
+ `<g xmlns="http://www.w3.org/2000/svg">${innerSvg}</g>`,
652
+ 'image/svg+xml',
653
+ );
654
+
655
+ while (el.firstChild) {
656
+ el.removeChild(el.firstChild);
657
+ }
658
+
659
+ for (const child of Array.from(fragmentDoc.documentElement.childNodes)) {
660
+ el.appendChild(doc.importNode(child, true));
661
+ }
662
+ }
663
+
664
+ let edgesLayer = doc.querySelector('.aim-edges-layer');
665
+ if (!edgesLayer) {
666
+ edgesLayer = doc.documentElement;
667
+ }
668
+
669
+ // Remove deleted edges
670
+ const existingEdgeEls = Array.from(doc.querySelectorAll(AimSvgContract.SELECTOR_EDGE));
671
+ for (const edgeEl of existingEdgeEls) {
672
+ const id = edgeEl.getAttribute(AimSvgContract.ATTR_ID) ?? edgeEl.getAttribute('id');
673
+ if (id && !model.edges.some((e) => e.id === id)) {
674
+ edgeEl.remove();
451
675
  }
452
676
  }
453
677
 
454
- // Update edge bend points and aim-bends attributes
678
+ // Update or insert edges with live path geometry
455
679
  for (const edge of model.edges) {
456
- const el = doc.querySelector(`[${AimSvgContract.ATTR_ID}="${edge.id}"], [id="${edge.id}"]`);
457
- if (el) {
458
- const bendsString = this.formatBendPoints(edge.bendPoints);
459
- el.setAttribute(AimSvgContract.ATTR_BENDS, bendsString);
460
- el.setAttribute(AimSvgContract.ATTR_EDGE, 'true');
461
- el.setAttribute(AimSvgContract.ATTR_EDGE_KIND, edge.kind);
462
- if (edge.sourceId !== undefined) {
463
- el.setAttribute(AimSvgContract.ATTR_SOURCE, edge.sourceId);
680
+ let el = doc.querySelector(`[${AimSvgContract.ATTR_ID}="${edge.id}"], [id="${edge.id}"]`);
681
+ if (!el) {
682
+ el = doc.createElementNS('http://www.w3.org/2000/svg', 'g');
683
+ if (nodesLayer && nodesLayer.parentNode === doc.documentElement) {
684
+ doc.documentElement.insertBefore(el, nodesLayer);
685
+ } else {
686
+ edgesLayer.appendChild(el);
464
687
  }
465
- if (edge.targetId !== undefined) {
466
- el.setAttribute(AimSvgContract.ATTR_TARGET, edge.targetId);
467
- }
468
- if (edge.sourcePort !== undefined) {
469
- el.setAttribute(AimSvgContract.ATTR_SOURCE_PORT, edge.sourcePort);
470
- }
471
- if (edge.targetPort !== undefined) {
472
- el.setAttribute(AimSvgContract.ATTR_TARGET_PORT, edge.targetPort);
473
- }
474
- if (edge.routing !== undefined) {
475
- el.setAttribute(AimSvgContract.ATTR_ROUTING, edge.routing);
476
- }
477
- if (edge.stereotype !== undefined) {
478
- el.setAttribute(AimSvgContract.ATTR_STEREOTYPE, edge.stereotype);
479
- }
480
- if (edge.sourceCardinality !== undefined) {
481
- el.setAttribute('aim-source-cardinality', edge.sourceCardinality);
688
+ }
689
+
690
+ const bendsString = this.formatBendPoints(edge.bendPoints);
691
+ el.setAttribute(AimSvgContract.ATTR_BENDS, bendsString);
692
+ el.setAttribute(AimSvgContract.ATTR_EDGE, 'true');
693
+ el.setAttribute(AimSvgContract.ATTR_ID, edge.id);
694
+ el.setAttribute(AimSvgContract.ATTR_EDGE_KIND, edge.kind);
695
+ if (edge.sourceId !== undefined) {
696
+ el.setAttribute(AimSvgContract.ATTR_SOURCE, edge.sourceId);
697
+ }
698
+ if (edge.targetId !== undefined) {
699
+ el.setAttribute(AimSvgContract.ATTR_TARGET, edge.targetId);
700
+ }
701
+ if (edge.sourcePort !== undefined && edge.sourcePort !== '' && edge.sourcePort !== 'auto') {
702
+ el.setAttribute(AimSvgContract.ATTR_SOURCE_PORT, edge.sourcePort);
703
+ } else {
704
+ el.removeAttribute(AimSvgContract.ATTR_SOURCE_PORT);
705
+ }
706
+ if (edge.targetPort !== undefined && edge.targetPort !== '' && edge.targetPort !== 'auto') {
707
+ el.setAttribute(AimSvgContract.ATTR_TARGET_PORT, edge.targetPort);
708
+ } else {
709
+ el.removeAttribute(AimSvgContract.ATTR_TARGET_PORT);
710
+ }
711
+ if (edge.routing !== undefined) {
712
+ el.setAttribute(AimSvgContract.ATTR_ROUTING, edge.routing);
713
+ }
714
+ if (edge.stereotype !== undefined) {
715
+ el.setAttribute(AimSvgContract.ATTR_STEREOTYPE, edge.stereotype);
716
+ }
717
+ if (edge.sourceCardinality !== undefined) {
718
+ el.setAttribute('aim-source-cardinality', edge.sourceCardinality);
719
+ }
720
+ if (edge.targetCardinality !== undefined) {
721
+ el.setAttribute('aim-target-cardinality', edge.targetCardinality);
722
+ }
723
+
724
+ // Update or inject <path class="aim-edge"> with rendered path geometry
725
+ let pathD = edge.pathData;
726
+ if (!pathD) {
727
+ const sourceNode = model.nodes.find((n) => n.id === edge.sourceId);
728
+ const targetNode = model.nodes.find((n) => n.id === edge.targetId);
729
+ if (sourceNode && targetNode) {
730
+ pathD = this.computeFallbackEdgePath(sourceNode, targetNode, edge.bendPoints, edge.routing ?? model.routing);
482
731
  }
483
- if (edge.targetCardinality !== undefined) {
484
- el.setAttribute('aim-target-cardinality', edge.targetCardinality);
732
+ }
733
+
734
+ let pathEl = el.querySelector('path.aim-edge') ?? el.querySelector('path');
735
+ if (!pathEl) {
736
+ pathEl = doc.createElementNS('http://www.w3.org/2000/svg', 'path');
737
+ pathEl.setAttribute('class', 'aim-edge');
738
+ el.appendChild(pathEl);
739
+ }
740
+
741
+ if (pathD) {
742
+ pathEl.setAttribute('d', pathD);
743
+ }
744
+ pathEl.setAttribute('fill', 'none');
745
+ pathEl.setAttribute('stroke', CascaisPalette.WarmGraphite);
746
+ pathEl.setAttribute('stroke-width', '1.5');
747
+
748
+ if (edge.kind === 'dependency') {
749
+ pathEl.setAttribute('stroke-dasharray', '5,5');
750
+ } else {
751
+ pathEl.removeAttribute('stroke-dasharray');
752
+ }
753
+
754
+ const markerEnd = edge.kind === 'generalization' ? 'url(#arrow-hollow)' : 'url(#arrow-classic)';
755
+ pathEl.setAttribute('marker-end', markerEnd);
756
+
757
+ // Update or inject edge label
758
+ if (edge.label) {
759
+ let textEl = el.querySelector('text');
760
+ if (!textEl) {
761
+ textEl = doc.createElementNS('http://www.w3.org/2000/svg', 'text');
762
+ el.appendChild(textEl);
485
763
  }
764
+ const midPoint =
765
+ edge.bendPoints.length > 0
766
+ ? (edge.bendPoints[Math.floor(edge.bendPoints.length / 2)] ?? { x: 50, y: 50 })
767
+ : (() => {
768
+ const s = model.nodes.find((n) => n.id === edge.sourceId);
769
+ const t = model.nodes.find((n) => n.id === edge.targetId);
770
+ if (s && t) {
771
+ return {
772
+ x: Math.round((s.bounds.x + s.bounds.width / 2 + t.bounds.x + t.bounds.width / 2) / 2),
773
+ y: Math.round((s.bounds.y + s.bounds.height / 2 + t.bounds.y + t.bounds.height / 2) / 2),
774
+ };
775
+ }
776
+ return { x: 50, y: 50 };
777
+ })();
778
+ textEl.setAttribute('x', `${midPoint.x}`);
779
+ textEl.setAttribute('y', `${midPoint.y - 8}`);
780
+ textEl.setAttribute('font-size', '11');
781
+ textEl.setAttribute('fill', CascaisPalette.TextSecondary);
782
+ textEl.setAttribute('text-anchor', 'middle');
783
+ textEl.textContent = edge.label;
486
784
  }
487
785
  }
488
786
 
489
787
  return new XMLSerializer().serializeToString(doc);
490
788
  }
491
789
 
492
- private generateFreshSvg(model: RaidMetamodel, options: SerializationOptions): string {
790
+ private renderSvgText(
791
+ text: string,
792
+ cx: number,
793
+ cy: number,
794
+ fontSize: number,
795
+ fontWeight: string,
796
+ fill: string,
797
+ underline: boolean = false,
798
+ ): string {
799
+ const wrapped = wrapAimText(text);
800
+ const lines = wrapped.split('\n');
801
+ const underlineAttr = underline ? ' text-decoration="underline"' : '';
802
+ const weightAttr = fontWeight !== 'normal' ? ` font-weight="${fontWeight}"` : '';
803
+
804
+ if (lines.length <= 1) {
805
+ return ` <text x="${cx}" y="${cy}" font-size="${fontSize}"${weightAttr} fill="${fill}" text-anchor="middle" dominant-baseline="central"${underlineAttr}>${lines[0] ?? ''}</text>\n`;
806
+ }
807
+
808
+ const lineHeight = fontSize * 1.25;
809
+ const startY = cy - ((lines.length - 1) * lineHeight) / 2;
810
+
811
+ let tspans = '';
812
+ lines.forEach((line, idx) => {
813
+ const y = Math.round(startY + idx * lineHeight);
814
+ tspans += `<tspan x="${cx}" y="${y}">${line}</tspan>`;
815
+ });
816
+
817
+ return ` <text font-size="${fontSize}"${weightAttr} fill="${fill}" text-anchor="middle" dominant-baseline="central"${underlineAttr}>${tspans}</text>\n`;
818
+ }
819
+
820
+ /**
821
+ * Generates the canonical inner SVG elements (shapes and styled text)
822
+ * for a given AOAIM node archetype.
823
+ */
824
+ public renderNodeInnerSvg(node: RaidNodeData): string {
825
+ let svg = '';
826
+ if (node.kind === 'uc') {
827
+ const rx = node.bounds.width / 2;
828
+ const ry = node.bounds.height / 2;
829
+ svg += ` <ellipse cx="${rx}" cy="${ry}" rx="${rx}" ry="${ry}" fill="${CascaisPalette.ChalkWhite}" stroke="${CascaisPalette.NetGold}" stroke-width="2" />\n`;
830
+ svg += this.renderSvgText(node.displayName, rx, ry, 13, 'bold', CascaisPalette.TextPrimary, false);
831
+ } else if (node.kind === 'act') {
832
+ svg += ` <rect width="${node.bounds.width}" height="${node.bounds.height}" rx="12" ry="12" fill="${CascaisPalette.CanvasCream}" stroke="${CascaisPalette.HeraldicGreen}" stroke-width="2" />\n`;
833
+ svg += this.renderSvgText(node.displayName, node.bounds.width / 2, node.bounds.height / 2, 13, '600', CascaisPalette.TextPrimary, true);
834
+ } else if (node.kind === 'obj') {
835
+ svg += ` <rect width="${node.bounds.width}" height="${node.bounds.height}" fill="${CascaisPalette.ChalkWhite}" stroke="${CascaisPalette.SilverLineDark}" stroke-width="1.5" />\n`;
836
+ svg += this.renderSvgText(node.displayName, node.bounds.width / 2, node.bounds.height / 2, 12, 'normal', CascaisPalette.TextPrimary, true);
837
+ } else if (node.kind === 'per') {
838
+ const isInitiating = node.stereotype?.toLowerCase().includes('initiates') ?? false;
839
+ const strokeColor = isInitiating ? CascaisPalette.NetGold : CascaisPalette.WarmGraphite;
840
+ const cx = Math.round(node.bounds.width / 2);
841
+ svg += ` <rect width="${node.bounds.width}" height="${node.bounds.height}" fill="none" stroke="none" />\n`;
842
+ svg += ` <path d="M 61 50 v -4 a 8 8 0 0 0 -8 -8 H 37 a 8 8 0 0 0 -8 8 v 4" fill="none" stroke="${strokeColor}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />\n`;
843
+ svg += ` <circle cx="45" cy="22" r="8" fill="${CascaisPalette.ChalkWhite}" stroke="${strokeColor}" stroke-width="2" />\n`;
844
+ svg += this.renderSvgText(node.displayName, cx, 68, 12, '500', CascaisPalette.TextPrimary, false);
845
+ } else {
846
+ svg += ` <rect width="${node.bounds.width}" height="${node.bounds.height}" fill="${CascaisPalette.ChalkWhite}" stroke="${CascaisPalette.WarmGraphite}" stroke-width="1.5" />\n`;
847
+ svg += this.renderSvgText(node.displayName, node.bounds.width / 2, node.bounds.height / 2, 12, 'normal', CascaisPalette.TextPrimary, false);
848
+ }
849
+ return svg;
850
+ }
851
+
852
+ public generateFreshSvg(model: RaidMetamodel, options: SerializationOptions): string {
493
853
  const width = Math.max(800, ...model.nodes.map((n) => n.bounds.x + n.bounds.width + 100));
494
854
  const height = Math.max(600, ...model.nodes.map((n) => n.bounds.y + n.bounds.height + 100));
495
855
 
496
- let svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" id="${model.diagramId}" aim-archetype="${model.archetype}">\n`;
856
+ const diagramRouting = options.routingMode ?? model.routing;
857
+ const routingAttr = diagramRouting ? ` ${AimSvgContract.ATTR_ROUTING}="${diagramRouting}"` : '';
858
+
859
+ let svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" id="${model.diagramId}" aim-archetype="${model.archetype}"${routingAttr}>\n`;
497
860
 
498
861
  // Definitions & Markers
499
862
  svg += ` <defs>\n`;
@@ -509,6 +872,7 @@ export class RaiBridge {
509
872
  svg += ` .aim-node:hover { filter: drop-shadow(0 4px 6px rgba(0,0,0,0.1)); }\n`;
510
873
  svg += ` .aim-edge { fill: none; stroke: ${CascaisPalette.WarmGraphite}; stroke-width: 1.5; }\n`;
511
874
  svg += ` text { font-family: Inter, system-ui, sans-serif; }\n`;
875
+ svg += ` .aim-act text, .aim-obj text { text-decoration: underline; }\n`;
512
876
  svg += ` </style>\n`;
513
877
  }
514
878
  svg += ` </defs>\n\n`;
@@ -522,22 +886,41 @@ export class RaiBridge {
522
886
  const markerEnd = edge.kind === 'generalization' ? ' marker-end="url(#arrow-hollow)"' : ' marker-end="url(#arrow-classic)"';
523
887
 
524
888
  // Path data construction
525
- let pathD = '';
526
- if (edge.bendPoints.length > 0) {
527
- const first = edge.bendPoints[0]!;
528
- pathD = `M ${first.x} ${first.y} ` + edge.bendPoints.slice(1).map((p) => `L ${p.x} ${p.y}`).join(' ');
889
+ let pathD = edge.pathData ?? '';
890
+ if (!pathD) {
891
+ const sourceNode = model.nodes.find((n) => n.id === edge.sourceId);
892
+ const targetNode = model.nodes.find((n) => n.id === edge.targetId);
893
+ if (sourceNode && targetNode) {
894
+ pathD = this.computeFallbackEdgePath(sourceNode, targetNode, edge.bendPoints, edge.routing ?? model.routing);
895
+ } else if (edge.bendPoints.length > 0) {
896
+ const first = edge.bendPoints[0]!;
897
+ pathD = `M ${first.x} ${first.y} ` + edge.bendPoints.slice(1).map((p) => `L ${p.x} ${p.y}`).join(' ');
898
+ }
529
899
  }
530
900
 
531
- const sourcePortAttr = edge.sourcePort ? ` ${AimSvgContract.ATTR_SOURCE_PORT}="${edge.sourcePort}"` : '';
532
- const targetPortAttr = edge.targetPort ? ` ${AimSvgContract.ATTR_TARGET_PORT}="${edge.targetPort}"` : '';
901
+ const sourcePortAttr = edge.sourcePort && edge.sourcePort !== 'auto' ? ` ${AimSvgContract.ATTR_SOURCE_PORT}="${edge.sourcePort}"` : '';
902
+ const targetPortAttr = edge.targetPort && edge.targetPort !== 'auto' ? ` ${AimSvgContract.ATTR_TARGET_PORT}="${edge.targetPort}"` : '';
533
903
  const routingAttr = edge.routing ? ` ${AimSvgContract.ATTR_ROUTING}="${edge.routing}"` : '';
534
904
 
535
905
  svg += ` <g ${AimSvgContract.ATTR_EDGE}="true" ${AimSvgContract.ATTR_ID}="${edge.id}" ${AimSvgContract.ATTR_EDGE_KIND}="${edge.kind}" ${AimSvgContract.ATTR_SOURCE}="${edge.sourceId}" ${AimSvgContract.ATTR_TARGET}="${edge.targetId}"${sourcePortAttr}${targetPortAttr}${routingAttr} ${AimSvgContract.ATTR_BENDS}="${bendsFormatted}">\n`;
536
906
  if (pathD) {
537
- svg += ` <path d="${pathD}" class="aim-edge"${strokeDash}${markerEnd} />\n`;
907
+ svg += ` <path d="${pathD}" class="aim-edge" fill="none" stroke="${CascaisPalette.WarmGraphite}" stroke-width="1.5"${strokeDash}${markerEnd} />\n`;
538
908
  }
539
909
  if (edge.label) {
540
- const midPoint = edge.bendPoints[Math.floor(edge.bendPoints.length / 2)] ?? { x: 50, y: 50 };
910
+ const midPoint =
911
+ edge.bendPoints.length > 0
912
+ ? (edge.bendPoints[Math.floor(edge.bendPoints.length / 2)] ?? { x: 50, y: 50 })
913
+ : (() => {
914
+ const s = model.nodes.find((n) => n.id === edge.sourceId);
915
+ const t = model.nodes.find((n) => n.id === edge.targetId);
916
+ if (s && t) {
917
+ return {
918
+ x: Math.round((s.bounds.x + s.bounds.width / 2 + t.bounds.x + t.bounds.width / 2) / 2),
919
+ y: Math.round((s.bounds.y + s.bounds.height / 2 + t.bounds.y + t.bounds.height / 2) / 2),
920
+ };
921
+ }
922
+ return { x: 50, y: 50 };
923
+ })();
541
924
  svg += ` <text x="${midPoint.x}" y="${midPoint.y - 8}" font-size="11" fill="${CascaisPalette.TextSecondary}" text-anchor="middle">${edge.label}</text>\n`;
542
925
  }
543
926
  svg += ` </g>\n`;
@@ -548,21 +931,9 @@ export class RaiBridge {
548
931
  svg += ` <!-- Nodes -->\n`;
549
932
  svg += ` <g class="aim-nodes-layer">\n`;
550
933
  for (const node of model.nodes) {
551
- svg += ` <g ${AimSvgContract.ATTR_NODE}="true" ${AimSvgContract.ATTR_ID}="${node.id}" ${AimSvgContract.ATTR_KIND}="${node.kind}" ${AimSvgContract.ATTR_DISPLAY_NAME}="${node.displayName}" transform="translate(${node.bounds.x}, ${node.bounds.y})">\n`;
552
-
553
- if (node.kind === 'uc') {
554
- const rx = node.bounds.width / 2;
555
- const ry = node.bounds.height / 2;
556
- svg += ` <ellipse cx="${rx}" cy="${ry}" rx="${rx}" ry="${ry}" fill="${CascaisPalette.ChalkWhite}" stroke="${CascaisPalette.NetGold}" stroke-width="2" />\n`;
557
- svg += ` <text x="${rx}" y="${ry}" font-size="13" font-weight="bold" fill="${CascaisPalette.TextPrimary}" text-anchor="middle" dominant-baseline="central">${node.displayName}</text>\n`;
558
- } else if (node.kind === 'act') {
559
- svg += ` <rect width="${node.bounds.width}" height="${node.bounds.height}" rx="12" ry="12" fill="${CascaisPalette.CanvasCream}" stroke="${CascaisPalette.HeraldicGreen}" stroke-width="2" />\n`;
560
- svg += ` <text x="${node.bounds.width / 2}" y="${node.bounds.height / 2}" font-size="13" font-weight="600" fill="${CascaisPalette.TextPrimary}" text-anchor="middle" dominant-baseline="central">${node.displayName}</text>\n`;
561
- } else {
562
- svg += ` <rect width="${node.bounds.width}" height="${node.bounds.height}" fill="${CascaisPalette.ChalkWhite}" stroke="${CascaisPalette.WarmGraphite}" stroke-width="1.5" />\n`;
563
- svg += ` <text x="${node.bounds.width / 2}" y="${node.bounds.height / 2}" font-size="12" fill="${CascaisPalette.TextPrimary}" text-anchor="middle" dominant-baseline="central">${node.displayName}</text>\n`;
564
- }
565
-
934
+ const stereotypeAttr = node.stereotype ? ` ${AimSvgContract.ATTR_STEREOTYPE}="${node.stereotype}"` : '';
935
+ svg += ` <g ${AimSvgContract.ATTR_NODE}="true" ${AimSvgContract.ATTR_ID}="${node.id}" ${AimSvgContract.ATTR_KIND}="${node.kind}" ${AimSvgContract.ATTR_DISPLAY_NAME}="${node.displayName}"${stereotypeAttr} transform="translate(${node.bounds.x}, ${node.bounds.y})">\n`;
936
+ svg += this.renderNodeInnerSvg(node);
566
937
  svg += ` </g>\n`;
567
938
  }
568
939
  svg += ` </g>\n`;
@@ -571,6 +942,14 @@ export class RaiBridge {
571
942
  return svg;
572
943
  }
573
944
 
945
+ public normalizeRouting(raw: string): AimRoutingMode {
946
+ const lower = raw.toLowerCase().trim();
947
+ if (lower === 'orthogonal' || lower === 'manhattan') return 'manhattan';
948
+ if (lower === 'straight' || lower === 'normal') return 'normal';
949
+ if (lower === 'curved' || lower === 'smooth') return 'smooth';
950
+ return 'manhattan';
951
+ }
952
+
574
953
  private normalizeKind(kind: string): AimOntologyKind {
575
954
  const lower = kind.toLowerCase();
576
955
  if (lower === 'uc' || lower === 'usecase') return 'uc';