@dr2rai/raid-canvas 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/RaiBridge.ts CHANGED
@@ -15,6 +15,7 @@ import {
15
15
  AimSvgContract,
16
16
  type AimOntologyKind,
17
17
  type AimEdgeKind,
18
+ type AimRoutingMode,
18
19
  type RaidNodeData,
19
20
  type RaidEdgeData,
20
21
  type RaidMetamodel,
@@ -23,7 +24,13 @@ import {
23
24
  type HydrationOptions,
24
25
  type SerializationOptions,
25
26
  } from './types.js';
26
- import { createAimNode, createAimEdge, configureAimGraph, CascaisPalette } from './X6Shapes.js';
27
+ import {
28
+ createAimNode,
29
+ createAimEdge,
30
+ configureAimGraph,
31
+ CascaisPalette,
32
+ wrapAimText,
33
+ } from './X6Shapes.js';
27
34
 
28
35
  export class RaiBridge {
29
36
  /**
@@ -138,6 +145,49 @@ export class RaiBridge {
138
145
  const targetPort = edge.getTargetPortId();
139
146
  const label = (edge.getLabels()?.[0]?.attrs?.['text']?.['text'] as string | undefined) ?? customData.label;
140
147
 
148
+ let routing: AimRoutingMode | undefined = customData.routing;
149
+ if (!routing) {
150
+ const router = edge.getRouter();
151
+ const routerName = typeof router === 'string' ? router : router?.name;
152
+ const connector = edge.getConnector();
153
+ const connectorName = typeof connector === 'string' ? connector : connector?.name;
154
+ if (connectorName === 'smooth') {
155
+ routing = 'smooth';
156
+ } else if (routerName === 'normal') {
157
+ routing = 'normal';
158
+ } else if (routerName === 'manhattan') {
159
+ routing = 'manhattan';
160
+ }
161
+ }
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
+
141
191
  const edgeData: RaidEdgeData = {
142
192
  id: edge.id,
143
193
  kind: customData.kind ?? 'association',
@@ -145,21 +195,26 @@ export class RaiBridge {
145
195
  targetId: target.id,
146
196
  ...(sourcePort !== undefined ? { sourcePort } : {}),
147
197
  ...(targetPort !== undefined ? { targetPort } : {}),
198
+ ...(routing !== undefined ? { routing } : {}),
148
199
  ...(label !== undefined ? { label } : {}),
149
200
  ...(customData.stereotype !== undefined ? { stereotype: customData.stereotype } : {}),
150
201
  ...(customData.sourceCardinality !== undefined ? { sourceCardinality: customData.sourceCardinality } : {}),
151
202
  ...(customData.targetCardinality !== undefined ? { targetCardinality: customData.targetCardinality } : {}),
152
203
  bendPoints,
204
+ ...(pathData !== undefined ? { pathData } : {}),
153
205
  };
154
206
 
155
207
  edges.push(edgeData);
156
208
  }
157
209
 
210
+ const diagramRouting = (graph as unknown as { _aimRoutingMode?: AimRoutingMode })._aimRoutingMode;
211
+
158
212
  return {
159
213
  diagramId: 'RaidDiagram',
160
214
  archetype: 'InteractiveCanvas',
161
215
  nodes,
162
216
  edges,
217
+ ...(diagramRouting !== undefined ? { routing: diagramRouting } : {}),
163
218
  };
164
219
  }
165
220
 
@@ -190,6 +245,79 @@ export class RaiBridge {
190
245
  return points.map((p) => `${Math.round(p.x)},${Math.round(p.y)}`).join('; ');
191
246
  }
192
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
+
193
321
  // --------------------------------------------------------------------------
194
322
  // Private Helper Implementation
195
323
  // --------------------------------------------------------------------------
@@ -279,8 +407,8 @@ export class RaiBridge {
279
407
  const sourceNode = nodes.find((n) => n.id === sourceId);
280
408
  const targetNode = nodes.find((n) => n.id === targetId);
281
409
 
282
- // Infer optimal orthogonal docking ports if not explicitly declared
283
- if (sourceNode && targetNode) {
410
+ // Infer optimal orthogonal docking ports only if explicitly requested in options
411
+ if (options.inferPorts === true && sourceNode && targetNode) {
284
412
  const dx =
285
413
  targetNode.bounds.x +
286
414
  targetNode.bounds.width / 2 -
@@ -328,6 +456,11 @@ export class RaiBridge {
328
456
  el.getAttribute('aim-source-cardinality') ?? undefined;
329
457
  const targetCardinality =
330
458
  el.getAttribute('aim-target-cardinality') ?? undefined;
459
+ const rawRouting =
460
+ el.getAttribute(AimSvgContract.ATTR_ROUTING) ??
461
+ el.getAttribute('aim-routing') ??
462
+ undefined;
463
+ const routing = rawRouting ? this.normalizeRouting(rawRouting) : undefined;
331
464
 
332
465
  edges.push({
333
466
  id,
@@ -336,6 +469,7 @@ export class RaiBridge {
336
469
  targetId,
337
470
  ...(sourcePort !== undefined ? { sourcePort } : {}),
338
471
  ...(targetPort !== undefined ? { targetPort } : {}),
472
+ ...(routing !== undefined ? { routing } : {}),
339
473
  ...(label !== undefined ? { label } : {}),
340
474
  ...(sourceCardinality !== undefined ? { sourceCardinality } : {}),
341
475
  ...(targetCardinality !== undefined ? { targetCardinality } : {}),
@@ -343,11 +477,18 @@ export class RaiBridge {
343
477
  });
344
478
  }
345
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
+
346
486
  return {
347
487
  diagramId: svgRoot.getAttribute('id') ?? 'ImportedDiagram',
348
488
  archetype: svgRoot.getAttribute('aim-archetype') ?? 'AOAIMDiagram',
349
489
  nodes,
350
490
  edges,
491
+ ...(diagramRouting !== undefined ? { routing: diagramRouting } : {}),
351
492
  };
352
493
  }
353
494
 
@@ -371,7 +512,7 @@ export class RaiBridge {
371
512
  }
372
513
 
373
514
  // Check direct geometry attributes (rect, ellipse, circle)
374
- const rect = el.querySelector('rect') ?? (el.tagName.toLowerCase() === 'rect' ? el : null);
515
+ const rect = el.querySelector?.('rect') ?? (el.tagName?.toLowerCase() === 'rect' ? el : null);
375
516
  if (rect) {
376
517
  if (!transform && rect.getAttribute('x')) x = parseFloat(rect.getAttribute('x')!);
377
518
  if (!transform && rect.getAttribute('y')) y = parseFloat(rect.getAttribute('y')!);
@@ -380,7 +521,7 @@ export class RaiBridge {
380
521
  }
381
522
 
382
523
  const ellipse =
383
- el.querySelector('ellipse') ?? (el.tagName.toLowerCase() === 'ellipse' ? el : null);
524
+ el.querySelector?.('ellipse') ?? (el.tagName?.toLowerCase() === 'ellipse' ? el : null);
384
525
  if (ellipse) {
385
526
  const rx = parseFloat(ellipse.getAttribute('rx') ?? `${width / 2}`);
386
527
  const ry = parseFloat(ellipse.getAttribute('ry') ?? `${height / 2}`);
@@ -397,7 +538,7 @@ export class RaiBridge {
397
538
  return { x, y, width, height };
398
539
  }
399
540
 
400
- private updateExistingSvg(
541
+ public updateExistingSvg(
401
542
  baseSvg: string,
402
543
  model: RaidMetamodel,
403
544
  _options: SerializationOptions,
@@ -409,6 +550,51 @@ export class RaiBridge {
409
550
  const parser = new DOMParser();
410
551
  const doc = parser.parseFromString(baseSvg, 'image/svg+xml');
411
552
 
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
+ // Update diagram-level routing mode on root <svg>
593
+ const diagramRouting = _options.routingMode ?? model.routing;
594
+ if (diagramRouting) {
595
+ doc.documentElement.setAttribute(AimSvgContract.ATTR_ROUTING, diagramRouting);
596
+ }
597
+
412
598
  // Update node positions and transforms
413
599
  for (const node of model.nodes) {
414
600
  const el = doc.querySelector(`[${AimSvgContract.ATTR_ID}="${node.id}"], [id="${node.id}"]`);
@@ -433,7 +619,25 @@ export class RaiBridge {
433
619
  el.setAttribute(AimSvgContract.ATTR_BENDS, bendsString);
434
620
  el.setAttribute(AimSvgContract.ATTR_EDGE, 'true');
435
621
  el.setAttribute(AimSvgContract.ATTR_EDGE_KIND, edge.kind);
436
-
622
+ if (edge.sourceId !== undefined) {
623
+ el.setAttribute(AimSvgContract.ATTR_SOURCE, edge.sourceId);
624
+ }
625
+ if (edge.targetId !== undefined) {
626
+ el.setAttribute(AimSvgContract.ATTR_TARGET, edge.targetId);
627
+ }
628
+ if (edge.sourcePort !== undefined && edge.sourcePort !== '' && edge.sourcePort !== 'auto') {
629
+ el.setAttribute(AimSvgContract.ATTR_SOURCE_PORT, edge.sourcePort);
630
+ } else {
631
+ el.removeAttribute(AimSvgContract.ATTR_SOURCE_PORT);
632
+ }
633
+ if (edge.targetPort !== undefined && edge.targetPort !== '' && edge.targetPort !== 'auto') {
634
+ el.setAttribute(AimSvgContract.ATTR_TARGET_PORT, edge.targetPort);
635
+ } else {
636
+ el.removeAttribute(AimSvgContract.ATTR_TARGET_PORT);
637
+ }
638
+ if (edge.routing !== undefined) {
639
+ el.setAttribute(AimSvgContract.ATTR_ROUTING, edge.routing);
640
+ }
437
641
  if (edge.stereotype !== undefined) {
438
642
  el.setAttribute(AimSvgContract.ATTR_STEREOTYPE, edge.stereotype);
439
643
  }
@@ -443,17 +647,83 @@ export class RaiBridge {
443
647
  if (edge.targetCardinality !== undefined) {
444
648
  el.setAttribute('aim-target-cardinality', edge.targetCardinality);
445
649
  }
650
+
651
+ // Update or inject <path class="aim-edge"> with rendered path geometry
652
+ let pathD = edge.pathData;
653
+ if (!pathD) {
654
+ const sourceNode = model.nodes.find((n) => n.id === edge.sourceId);
655
+ const targetNode = model.nodes.find((n) => n.id === edge.targetId);
656
+ if (sourceNode && targetNode) {
657
+ pathD = this.computeFallbackEdgePath(sourceNode, targetNode, edge.bendPoints, edge.routing ?? model.routing);
658
+ }
659
+ }
660
+
661
+ let pathEl = el.querySelector('path.aim-edge') ?? el.querySelector('path');
662
+ if (!pathEl) {
663
+ pathEl = doc.createElementNS('http://www.w3.org/2000/svg', 'path');
664
+ pathEl.setAttribute('class', 'aim-edge');
665
+ el.appendChild(pathEl);
666
+ }
667
+
668
+ if (pathD) {
669
+ pathEl.setAttribute('d', pathD);
670
+ }
671
+ pathEl.setAttribute('fill', 'none');
672
+ pathEl.setAttribute('stroke', CascaisPalette.WarmGraphite);
673
+ pathEl.setAttribute('stroke-width', '1.5');
674
+
675
+ if (edge.kind === 'dependency') {
676
+ pathEl.setAttribute('stroke-dasharray', '5,5');
677
+ } else {
678
+ pathEl.removeAttribute('stroke-dasharray');
679
+ }
680
+
681
+ const markerEnd = edge.kind === 'generalization' ? 'url(#arrow-hollow)' : 'url(#arrow-classic)';
682
+ pathEl.setAttribute('marker-end', markerEnd);
446
683
  }
447
684
  }
448
685
 
449
686
  return new XMLSerializer().serializeToString(doc);
450
687
  }
451
688
 
452
- private generateFreshSvg(model: RaidMetamodel, options: SerializationOptions): string {
689
+ private renderSvgText(
690
+ text: string,
691
+ cx: number,
692
+ cy: number,
693
+ fontSize: number,
694
+ fontWeight: string,
695
+ fill: string,
696
+ underline: boolean = false,
697
+ ): string {
698
+ const wrapped = wrapAimText(text);
699
+ const lines = wrapped.split('\n');
700
+ const underlineAttr = underline ? ' text-decoration="underline"' : '';
701
+ const weightAttr = fontWeight !== 'normal' ? ` font-weight="${fontWeight}"` : '';
702
+
703
+ if (lines.length <= 1) {
704
+ return ` <text x="${cx}" y="${cy}" font-size="${fontSize}"${weightAttr} fill="${fill}" text-anchor="middle" dominant-baseline="central"${underlineAttr}>${lines[0] ?? ''}</text>\n`;
705
+ }
706
+
707
+ const lineHeight = fontSize * 1.25;
708
+ const startY = cy - ((lines.length - 1) * lineHeight) / 2;
709
+
710
+ let tspans = '';
711
+ lines.forEach((line, idx) => {
712
+ const y = Math.round(startY + idx * lineHeight);
713
+ tspans += `<tspan x="${cx}" y="${y}">${line}</tspan>`;
714
+ });
715
+
716
+ return ` <text font-size="${fontSize}"${weightAttr} fill="${fill}" text-anchor="middle" dominant-baseline="central"${underlineAttr}>${tspans}</text>\n`;
717
+ }
718
+
719
+ public generateFreshSvg(model: RaidMetamodel, options: SerializationOptions): string {
453
720
  const width = Math.max(800, ...model.nodes.map((n) => n.bounds.x + n.bounds.width + 100));
454
721
  const height = Math.max(600, ...model.nodes.map((n) => n.bounds.y + n.bounds.height + 100));
455
722
 
456
- let svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" id="${model.diagramId}" aim-archetype="${model.archetype}">\n`;
723
+ const diagramRouting = options.routingMode ?? model.routing;
724
+ const routingAttr = diagramRouting ? ` ${AimSvgContract.ATTR_ROUTING}="${diagramRouting}"` : '';
725
+
726
+ let svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" id="${model.diagramId}" aim-archetype="${model.archetype}"${routingAttr}>\n`;
457
727
 
458
728
  // Definitions & Markers
459
729
  svg += ` <defs>\n`;
@@ -469,6 +739,7 @@ export class RaiBridge {
469
739
  svg += ` .aim-node:hover { filter: drop-shadow(0 4px 6px rgba(0,0,0,0.1)); }\n`;
470
740
  svg += ` .aim-edge { fill: none; stroke: ${CascaisPalette.WarmGraphite}; stroke-width: 1.5; }\n`;
471
741
  svg += ` text { font-family: Inter, system-ui, sans-serif; }\n`;
742
+ svg += ` .aim-act text, .aim-obj text { text-decoration: underline; }\n`;
472
743
  svg += ` </style>\n`;
473
744
  }
474
745
  svg += ` </defs>\n\n`;
@@ -482,18 +753,41 @@ export class RaiBridge {
482
753
  const markerEnd = edge.kind === 'generalization' ? ' marker-end="url(#arrow-hollow)"' : ' marker-end="url(#arrow-classic)"';
483
754
 
484
755
  // Path data construction
485
- let pathD = '';
486
- if (edge.bendPoints.length > 0) {
487
- const first = edge.bendPoints[0]!;
488
- pathD = `M ${first.x} ${first.y} ` + edge.bendPoints.slice(1).map((p) => `L ${p.x} ${p.y}`).join(' ');
756
+ let pathD = edge.pathData ?? '';
757
+ if (!pathD) {
758
+ const sourceNode = model.nodes.find((n) => n.id === edge.sourceId);
759
+ const targetNode = model.nodes.find((n) => n.id === edge.targetId);
760
+ if (sourceNode && targetNode) {
761
+ pathD = this.computeFallbackEdgePath(sourceNode, targetNode, edge.bendPoints, edge.routing ?? model.routing);
762
+ } else if (edge.bendPoints.length > 0) {
763
+ const first = edge.bendPoints[0]!;
764
+ pathD = `M ${first.x} ${first.y} ` + edge.bendPoints.slice(1).map((p) => `L ${p.x} ${p.y}`).join(' ');
765
+ }
489
766
  }
490
767
 
491
- 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}" ${AimSvgContract.ATTR_BENDS}="${bendsFormatted}">\n`;
768
+ const sourcePortAttr = edge.sourcePort && edge.sourcePort !== 'auto' ? ` ${AimSvgContract.ATTR_SOURCE_PORT}="${edge.sourcePort}"` : '';
769
+ const targetPortAttr = edge.targetPort && edge.targetPort !== 'auto' ? ` ${AimSvgContract.ATTR_TARGET_PORT}="${edge.targetPort}"` : '';
770
+ const routingAttr = edge.routing ? ` ${AimSvgContract.ATTR_ROUTING}="${edge.routing}"` : '';
771
+
772
+ 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`;
492
773
  if (pathD) {
493
- svg += ` <path d="${pathD}" class="aim-edge"${strokeDash}${markerEnd} />\n`;
774
+ svg += ` <path d="${pathD}" class="aim-edge" fill="none" stroke="${CascaisPalette.WarmGraphite}" stroke-width="1.5"${strokeDash}${markerEnd} />\n`;
494
775
  }
495
776
  if (edge.label) {
496
- const midPoint = edge.bendPoints[Math.floor(edge.bendPoints.length / 2)] ?? { x: 50, y: 50 };
777
+ const midPoint =
778
+ edge.bendPoints.length > 0
779
+ ? (edge.bendPoints[Math.floor(edge.bendPoints.length / 2)] ?? { x: 50, y: 50 })
780
+ : (() => {
781
+ const s = model.nodes.find((n) => n.id === edge.sourceId);
782
+ const t = model.nodes.find((n) => n.id === edge.targetId);
783
+ if (s && t) {
784
+ return {
785
+ x: Math.round((s.bounds.x + s.bounds.width / 2 + t.bounds.x + t.bounds.width / 2) / 2),
786
+ y: Math.round((s.bounds.y + s.bounds.height / 2 + t.bounds.y + t.bounds.height / 2) / 2),
787
+ };
788
+ }
789
+ return { x: 50, y: 50 };
790
+ })();
497
791
  svg += ` <text x="${midPoint.x}" y="${midPoint.y - 8}" font-size="11" fill="${CascaisPalette.TextSecondary}" text-anchor="middle">${edge.label}</text>\n`;
498
792
  }
499
793
  svg += ` </g>\n`;
@@ -510,13 +804,24 @@ export class RaiBridge {
510
804
  const rx = node.bounds.width / 2;
511
805
  const ry = node.bounds.height / 2;
512
806
  svg += ` <ellipse cx="${rx}" cy="${ry}" rx="${rx}" ry="${ry}" fill="${CascaisPalette.ChalkWhite}" stroke="${CascaisPalette.NetGold}" stroke-width="2" />\n`;
513
- 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`;
807
+ svg += this.renderSvgText(node.displayName, rx, ry, 13, 'bold', CascaisPalette.TextPrimary, false);
514
808
  } else if (node.kind === 'act') {
515
809
  svg += ` <rect width="${node.bounds.width}" height="${node.bounds.height}" rx="12" ry="12" fill="${CascaisPalette.CanvasCream}" stroke="${CascaisPalette.HeraldicGreen}" stroke-width="2" />\n`;
516
- 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`;
810
+ svg += this.renderSvgText(node.displayName, node.bounds.width / 2, node.bounds.height / 2, 13, '600', CascaisPalette.TextPrimary, true);
811
+ } else if (node.kind === 'obj') {
812
+ svg += ` <rect width="${node.bounds.width}" height="${node.bounds.height}" fill="${CascaisPalette.ChalkWhite}" stroke="${CascaisPalette.SilverLineDark}" stroke-width="1.5" />\n`;
813
+ svg += this.renderSvgText(node.displayName, node.bounds.width / 2, node.bounds.height / 2, 12, 'normal', CascaisPalette.TextPrimary, true);
814
+ } else if (node.kind === 'per') {
815
+ const isInitiating = node.stereotype?.toLowerCase().includes('initiates') ?? false;
816
+ const strokeColor = isInitiating ? CascaisPalette.NetGold : CascaisPalette.WarmGraphite;
817
+ const cx = Math.round(node.bounds.width / 2);
818
+ svg += ` <rect width="${node.bounds.width}" height="${node.bounds.height}" fill="none" stroke="none" />\n`;
819
+ 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`;
820
+ svg += ` <circle cx="45" cy="22" r="8" fill="${CascaisPalette.ChalkWhite}" stroke="${strokeColor}" stroke-width="2" />\n`;
821
+ svg += this.renderSvgText(node.displayName, cx, 68, 12, '500', CascaisPalette.TextPrimary, false);
517
822
  } else {
518
823
  svg += ` <rect width="${node.bounds.width}" height="${node.bounds.height}" fill="${CascaisPalette.ChalkWhite}" stroke="${CascaisPalette.WarmGraphite}" stroke-width="1.5" />\n`;
519
- 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`;
824
+ svg += this.renderSvgText(node.displayName, node.bounds.width / 2, node.bounds.height / 2, 12, 'normal', CascaisPalette.TextPrimary, false);
520
825
  }
521
826
 
522
827
  svg += ` </g>\n`;
@@ -527,6 +832,14 @@ export class RaiBridge {
527
832
  return svg;
528
833
  }
529
834
 
835
+ public normalizeRouting(raw: string): AimRoutingMode {
836
+ const lower = raw.toLowerCase().trim();
837
+ if (lower === 'orthogonal' || lower === 'manhattan') return 'manhattan';
838
+ if (lower === 'straight' || lower === 'normal') return 'normal';
839
+ if (lower === 'curved' || lower === 'smooth') return 'smooth';
840
+ return 'manhattan';
841
+ }
842
+
530
843
  private normalizeKind(kind: string): AimOntologyKind {
531
844
  const lower = kind.toLowerCase();
532
845
  if (lower === 'uc' || lower === 'usecase') return 'uc';