@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.
@@ -10,7 +10,7 @@
10
10
  * the X6 graph, serializing them back into the SVG document while preserving semantic fidelity.
11
11
  */
12
12
  import { Graph } from '@antv/x6';
13
- import { type RaidMetamodel, type SvgBendPoint, type HydrationOptions, type SerializationOptions } from './types.js';
13
+ import { type AimRoutingMode, type RaidNodeData, type RaidMetamodel, type SvgBendPoint, type HydrationOptions, type SerializationOptions } from './types.js';
14
14
  export declare class RaiBridge {
15
15
  /**
16
16
  * Hydrates an AntV X6 graph from an SVG source string or DOM Element.
@@ -42,11 +42,23 @@ export declare class RaiBridge {
42
42
  * Formats bend points into the canonical aim-bends format: "x1,y1; x2,y2"
43
43
  */
44
44
  formatBendPoints(points: readonly SvgBendPoint[]): string;
45
+ /**
46
+ * Computes a clean fallback SVG path connecting source and target nodes
47
+ * when running in headless environments (e.g. CLI, tests) without an active DOM.
48
+ */
49
+ computeFallbackEdgePath(sourceNode: RaidNodeData, targetNode: RaidNodeData, bendPoints?: readonly SvgBendPoint[], routingMode?: AimRoutingMode): string;
45
50
  private resolveSvgElement;
46
51
  private extractMetamodel;
47
52
  private extractBounds;
48
- private updateExistingSvg;
49
- private generateFreshSvg;
53
+ updateExistingSvg(baseSvg: string, model: RaidMetamodel, _options: SerializationOptions): string;
54
+ private renderSvgText;
55
+ /**
56
+ * Generates the canonical inner SVG elements (shapes and styled text)
57
+ * for a given AOAIM node archetype.
58
+ */
59
+ renderNodeInnerSvg(node: RaidNodeData): string;
60
+ generateFreshSvg(model: RaidMetamodel, options: SerializationOptions): string;
61
+ normalizeRouting(raw: string): AimRoutingMode;
50
62
  private normalizeKind;
51
63
  private normalizeEdgeKind;
52
64
  private kindFromShape;
@@ -1 +1 @@
1
- {"version":3,"file":"RaiBridge.d.ts","sourceRoot":"","sources":["../src/RaiBridge.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,OAAO,EAAE,KAAK,EAAE,MAAM,UAAU,CAAC;AACjC,OAAO,EAOL,KAAK,aAAa,EAClB,KAAK,YAAY,EAEjB,KAAK,gBAAgB,EACrB,KAAK,oBAAoB,EAC1B,MAAM,YAAY,CAAC;AAGpB,qBAAa,SAAS;IACpB;;;;;;;OAOG;IACI,cAAc,CACnB,SAAS,EAAE,MAAM,GAAG,OAAO,EAC3B,KAAK,EAAE,KAAK,EACZ,OAAO,GAAE,gBAAqB,GAC7B,aAAa,CAuBf;IAED;;;;;;;OAOG;IACI,cAAc,CACnB,KAAK,EAAE,KAAK,EACZ,OAAO,CAAC,EAAE,MAAM,EAChB,OAAO,GAAE,oBAAyB,GACjC,MAAM,CAQR;IAED;;OAEG;IACI,kBAAkB,CAAC,KAAK,EAAE,KAAK,GAAG,aAAa,CAyFrD;IAED;;OAEG;IACI,eAAe,CAAC,QAAQ,EAAE,MAAM,GAAG,YAAY,EAAE,CAevD;IAED;;OAEG;IACI,gBAAgB,CAAC,MAAM,EAAE,SAAS,YAAY,EAAE,GAAG,MAAM,CAE/D;IAMD,OAAO,CAAC,iBAAiB;IAkBzB,OAAO,CAAC,gBAAgB;IAoJxB,OAAO,CAAC,aAAa;IA8CrB,OAAO,CAAC,iBAAiB;IAkEzB,OAAO,CAAC,gBAAgB;IAkFxB,OAAO,CAAC,aAAa;IAUrB,OAAO,CAAC,iBAAiB;IAUzB,OAAO,CAAC,aAAa;CAQtB"}
1
+ {"version":3,"file":"RaiBridge.d.ts","sourceRoot":"","sources":["../src/RaiBridge.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,OAAO,EAAE,KAAK,EAAE,MAAM,UAAU,CAAC;AACjC,OAAO,EAIL,KAAK,cAAc,EACnB,KAAK,YAAY,EAEjB,KAAK,aAAa,EAClB,KAAK,YAAY,EAEjB,KAAK,gBAAgB,EACrB,KAAK,oBAAoB,EAC1B,MAAM,YAAY,CAAC;AASpB,qBAAa,SAAS;IACpB;;;;;;;OAOG;IACI,cAAc,CACnB,SAAS,EAAE,MAAM,GAAG,OAAO,EAC3B,KAAK,EAAE,KAAK,EACZ,OAAO,GAAE,gBAAqB,GAC7B,aAAa,CAuBf;IAED;;;;;;;OAOG;IACI,cAAc,CACnB,KAAK,EAAE,KAAK,EACZ,OAAO,CAAC,EAAE,MAAM,EAChB,OAAO,GAAE,oBAAyB,GACjC,MAAM,CAQR;IAED;;OAEG;IACI,kBAAkB,CAAC,KAAK,EAAE,KAAK,GAAG,aAAa,CAyHrD;IAED;;OAEG;IACI,eAAe,CAAC,QAAQ,EAAE,MAAM,GAAG,YAAY,EAAE,CAevD;IAED;;OAEG;IACI,gBAAgB,CAAC,MAAM,EAAE,SAAS,YAAY,EAAE,GAAG,MAAM,CAE/D;IAED;;;OAGG;IACI,uBAAuB,CAC5B,UAAU,EAAE,YAAY,EACxB,UAAU,EAAE,YAAY,EACxB,UAAU,GAAE,SAAS,YAAY,EAAO,EACxC,WAAW,GAAE,cAA4B,GACxC,MAAM,CA8DR;IAMD,OAAO,CAAC,iBAAiB;IAkBzB,OAAO,CAAC,gBAAgB;IAwJxB,OAAO,CAAC,aAAa;IA8Cd,iBAAiB,CACtB,OAAO,EAAE,MAAM,EACf,KAAK,EAAE,aAAa,EACpB,QAAQ,EAAE,oBAAoB,GAC7B,MAAM,CAmPR;IAED,OAAO,CAAC,aAAa;IA8BrB;;;OAGG;IACI,kBAAkB,CAAC,IAAI,EAAE,YAAY,GAAG,MAAM,CA0BpD;IAEM,gBAAgB,CAAC,KAAK,EAAE,aAAa,EAAE,OAAO,EAAE,oBAAoB,GAAG,MAAM,CA2FnF;IAEM,gBAAgB,CAAC,GAAG,EAAE,MAAM,GAAG,cAAc,CAMnD;IAED,OAAO,CAAC,aAAa;IAUrB,OAAO,CAAC,iBAAiB;IAUzB,OAAO,CAAC,aAAa;CAQtB"}
package/dist/RaiBridge.js CHANGED
@@ -10,7 +10,7 @@
10
10
  * the X6 graph, serializing them back into the SVG document while preserving semantic fidelity.
11
11
  */
12
12
  import { AimSvgContract, } from './types.js';
13
- import { createAimNode, createAimEdge, configureAimGraph, CascaisPalette } from './X6Shapes.js';
13
+ import { createAimNode, createAimEdge, configureAimGraph, CascaisPalette, wrapAimText, } from './X6Shapes.js';
14
14
  export class RaiBridge {
15
15
  /**
16
16
  * Hydrates an AntV X6 graph from an SVG source string or DOM Element.
@@ -116,6 +116,26 @@ export class RaiBridge {
116
116
  routing = 'manhattan';
117
117
  }
118
118
  }
119
+ // Extract live rendered SVG path data from X6 EdgeView if available
120
+ let pathData = customData.pathData;
121
+ if (!pathData) {
122
+ const edgeView = typeof graph.findViewByCell === 'function'
123
+ ? graph.findViewByCell(edge)
124
+ : null;
125
+ if (edgeView) {
126
+ if (typeof edgeView.getConnectionPathData === 'function') {
127
+ const d = edgeView.getConnectionPathData();
128
+ if (d && d.trim().length > 0)
129
+ pathData = d;
130
+ }
131
+ if (!pathData && edgeView.container) {
132
+ const pathEl = edgeView.container.querySelector('path[d]');
133
+ const d = pathEl?.getAttribute('d');
134
+ if (d && d.trim().length > 0)
135
+ pathData = d;
136
+ }
137
+ }
138
+ }
119
139
  const edgeData = {
120
140
  id: edge.id,
121
141
  kind: customData.kind ?? 'association',
@@ -129,14 +149,17 @@ export class RaiBridge {
129
149
  ...(customData.sourceCardinality !== undefined ? { sourceCardinality: customData.sourceCardinality } : {}),
130
150
  ...(customData.targetCardinality !== undefined ? { targetCardinality: customData.targetCardinality } : {}),
131
151
  bendPoints,
152
+ ...(pathData !== undefined ? { pathData } : {}),
132
153
  };
133
154
  edges.push(edgeData);
134
155
  }
156
+ const diagramRouting = graph._aimRoutingMode;
135
157
  return {
136
158
  diagramId: 'RaidDiagram',
137
159
  archetype: 'InteractiveCanvas',
138
160
  nodes,
139
161
  edges,
162
+ ...(diagramRouting !== undefined ? { routing: diagramRouting } : {}),
140
163
  };
141
164
  }
142
165
  /**
@@ -163,6 +186,70 @@ export class RaiBridge {
163
186
  formatBendPoints(points) {
164
187
  return points.map((p) => `${Math.round(p.x)},${Math.round(p.y)}`).join('; ');
165
188
  }
189
+ /**
190
+ * Computes a clean fallback SVG path connecting source and target nodes
191
+ * when running in headless environments (e.g. CLI, tests) without an active DOM.
192
+ */
193
+ computeFallbackEdgePath(sourceNode, targetNode, bendPoints = [], routingMode = 'manhattan') {
194
+ const scx = Math.round(sourceNode.bounds.x + sourceNode.bounds.width / 2);
195
+ const scy = Math.round(sourceNode.bounds.y + sourceNode.bounds.height / 2);
196
+ const tcx = Math.round(targetNode.bounds.x + targetNode.bounds.width / 2);
197
+ const tcy = Math.round(targetNode.bounds.y + targetNode.bounds.height / 2);
198
+ let sx = scx;
199
+ let sy = scy;
200
+ let tx = tcx;
201
+ let ty = tcy;
202
+ const dx = tcx - scx;
203
+ const dy = tcy - scy;
204
+ if (Math.abs(dx) >= Math.abs(dy)) {
205
+ if (dx > 0) {
206
+ sx = Math.round(sourceNode.bounds.x + sourceNode.bounds.width);
207
+ sy = scy;
208
+ tx = Math.round(targetNode.bounds.x);
209
+ ty = tcy;
210
+ }
211
+ else {
212
+ sx = Math.round(sourceNode.bounds.x);
213
+ sy = scy;
214
+ tx = Math.round(targetNode.bounds.x + targetNode.bounds.width);
215
+ ty = tcy;
216
+ }
217
+ }
218
+ else {
219
+ if (dy > 0) {
220
+ sx = scx;
221
+ sy = Math.round(sourceNode.bounds.y + sourceNode.bounds.height);
222
+ tx = tcx;
223
+ ty = Math.round(targetNode.bounds.y);
224
+ }
225
+ else {
226
+ sx = scx;
227
+ sy = Math.round(sourceNode.bounds.y);
228
+ tx = tcx;
229
+ ty = Math.round(targetNode.bounds.y + targetNode.bounds.height);
230
+ }
231
+ }
232
+ if (bendPoints.length > 0) {
233
+ return `M ${sx} ${sy} ` + bendPoints.map((p) => `L ${Math.round(p.x)} ${Math.round(p.y)}`).join(' ') + ` L ${tx} ${ty}`;
234
+ }
235
+ if (routingMode === 'normal') {
236
+ return `M ${sx} ${sy} L ${tx} ${ty}`;
237
+ }
238
+ if (routingMode === 'smooth') {
239
+ const midX = Math.round((sx + tx) / 2);
240
+ const midY = Math.round((sy + ty) / 2);
241
+ return `M ${sx} ${sy} Q ${midX} ${sy} ${midX} ${midY} T ${tx} ${ty}`;
242
+ }
243
+ // Manhattan orthogonal default
244
+ if (Math.abs(dx) >= Math.abs(dy)) {
245
+ const midX = Math.round((sx + tx) / 2);
246
+ return `M ${sx} ${sy} L ${midX} ${sy} L ${midX} ${ty} L ${tx} ${ty}`;
247
+ }
248
+ else {
249
+ const midY = Math.round((sy + ty) / 2);
250
+ return `M ${sx} ${sy} L ${sx} ${midY} L ${tx} ${midY} L ${tx} ${ty}`;
251
+ }
252
+ }
166
253
  // --------------------------------------------------------------------------
167
254
  // Private Helper Implementation
168
255
  // --------------------------------------------------------------------------
@@ -227,8 +314,8 @@ export class RaiBridge {
227
314
  let targetPort = el.getAttribute(AimSvgContract.ATTR_TARGET_PORT) ?? undefined;
228
315
  const sourceNode = nodes.find((n) => n.id === sourceId);
229
316
  const targetNode = nodes.find((n) => n.id === targetId);
230
- // Infer optimal orthogonal docking ports if not explicitly declared
231
- if (sourceNode && targetNode) {
317
+ // Infer optimal orthogonal docking ports only if explicitly requested in options
318
+ if (options.inferPorts === true && sourceNode && targetNode) {
232
319
  const dx = targetNode.bounds.x +
233
320
  targetNode.bounds.width / 2 -
234
321
  (sourceNode.bounds.x + sourceNode.bounds.width / 2);
@@ -271,9 +358,7 @@ export class RaiBridge {
271
358
  const rawRouting = el.getAttribute(AimSvgContract.ATTR_ROUTING) ??
272
359
  el.getAttribute('aim-routing') ??
273
360
  undefined;
274
- const routing = rawRouting === 'normal' || rawRouting === 'smooth' || rawRouting === 'manhattan'
275
- ? rawRouting
276
- : undefined;
361
+ const routing = rawRouting ? this.normalizeRouting(rawRouting) : undefined;
277
362
  edges.push({
278
363
  id,
279
364
  kind,
@@ -288,11 +373,16 @@ export class RaiBridge {
288
373
  bendPoints: effectiveBendPoints,
289
374
  });
290
375
  }
376
+ const rawDiagramRouting = svgRoot.getAttribute(AimSvgContract.ATTR_ROUTING) ??
377
+ svgRoot.getAttribute('aim-routing') ??
378
+ undefined;
379
+ const diagramRouting = rawDiagramRouting ? this.normalizeRouting(rawDiagramRouting) : undefined;
291
380
  return {
292
381
  diagramId: svgRoot.getAttribute('id') ?? 'ImportedDiagram',
293
382
  archetype: svgRoot.getAttribute('aim-archetype') ?? 'AOAIMDiagram',
294
383
  nodes,
295
384
  edges,
385
+ ...(diagramRouting !== undefined ? { routing: diagramRouting } : {}),
296
386
  };
297
387
  }
298
388
  extractBounds(el, defaultSize = { width: 140, height: 60 }) {
@@ -310,7 +400,7 @@ export class RaiBridge {
310
400
  }
311
401
  }
312
402
  // Check direct geometry attributes (rect, ellipse, circle)
313
- const rect = el.querySelector('rect') ?? (el.tagName.toLowerCase() === 'rect' ? el : null);
403
+ const rect = el.querySelector?.('rect') ?? (el.tagName?.toLowerCase() === 'rect' ? el : null);
314
404
  if (rect) {
315
405
  if (!transform && rect.getAttribute('x'))
316
406
  x = parseFloat(rect.getAttribute('x'));
@@ -321,7 +411,7 @@ export class RaiBridge {
321
411
  if (rect.getAttribute('height'))
322
412
  height = parseFloat(rect.getAttribute('height'));
323
413
  }
324
- const ellipse = el.querySelector('ellipse') ?? (el.tagName.toLowerCase() === 'ellipse' ? el : null);
414
+ const ellipse = el.querySelector?.('ellipse') ?? (el.tagName?.toLowerCase() === 'ellipse' ? el : null);
325
415
  if (ellipse) {
326
416
  const rx = parseFloat(ellipse.getAttribute('rx') ?? `${width / 2}`);
327
417
  const ry = parseFloat(ellipse.getAttribute('ry') ?? `${height / 2}`);
@@ -342,60 +432,281 @@ export class RaiBridge {
342
432
  }
343
433
  const parser = new DOMParser();
344
434
  const doc = parser.parseFromString(baseSvg, 'image/svg+xml');
345
- // Update node positions and transforms
435
+ // Ensure defs and arrow markers exist for external vector viewers (Preview, Chrome, Safari)
436
+ let defs = doc.querySelector('defs');
437
+ if (!defs) {
438
+ defs = doc.createElementNS('http://www.w3.org/2000/svg', 'defs');
439
+ doc.documentElement.insertBefore(defs, doc.documentElement.firstChild);
440
+ }
441
+ if (!doc.querySelector('#arrow-classic')) {
442
+ const marker = doc.createElementNS('http://www.w3.org/2000/svg', 'marker');
443
+ marker.setAttribute('id', 'arrow-classic');
444
+ marker.setAttribute('viewBox', '0 0 10 10');
445
+ marker.setAttribute('refX', '10');
446
+ marker.setAttribute('refY', '5');
447
+ marker.setAttribute('markerWidth', '7');
448
+ marker.setAttribute('markerHeight', '7');
449
+ marker.setAttribute('orient', 'auto-start-reverse');
450
+ const path = doc.createElementNS('http://www.w3.org/2000/svg', 'path');
451
+ path.setAttribute('d', 'M 0 0 L 10 5 L 0 10 z');
452
+ path.setAttribute('fill', CascaisPalette.WarmGraphite);
453
+ marker.appendChild(path);
454
+ defs.appendChild(marker);
455
+ }
456
+ if (!doc.querySelector('#arrow-hollow')) {
457
+ const marker = doc.createElementNS('http://www.w3.org/2000/svg', 'marker');
458
+ marker.setAttribute('id', 'arrow-hollow');
459
+ marker.setAttribute('viewBox', '0 0 12 12');
460
+ marker.setAttribute('refX', '12');
461
+ marker.setAttribute('refY', '6');
462
+ marker.setAttribute('markerWidth', '9');
463
+ marker.setAttribute('markerHeight', '9');
464
+ marker.setAttribute('orient', 'auto-start-reverse');
465
+ const polygon = doc.createElementNS('http://www.w3.org/2000/svg', 'polygon');
466
+ polygon.setAttribute('points', '0 0, 12 6, 0 12');
467
+ polygon.setAttribute('fill', CascaisPalette.ChalkWhite);
468
+ polygon.setAttribute('stroke', CascaisPalette.WarmGraphite);
469
+ polygon.setAttribute('stroke-width', '1.5');
470
+ marker.appendChild(polygon);
471
+ defs.appendChild(marker);
472
+ }
473
+ // Ensure AOAIM style rules are present
474
+ let style = doc.querySelector('style');
475
+ if (!style) {
476
+ style = doc.createElementNS('http://www.w3.org/2000/svg', 'style');
477
+ defs.appendChild(style);
478
+ }
479
+ if (style && !style.textContent?.includes('.aim-act text')) {
480
+ style.textContent = (style.textContent ? style.textContent + '\n' : '') + `
481
+ .aim-node { cursor: pointer; transition: filter 0.15s ease; }
482
+ .aim-node:hover { filter: drop-shadow(0 4px 6px rgba(0,0,0,0.1)); }
483
+ .aim-edge { fill: none; stroke: ${CascaisPalette.WarmGraphite}; stroke-width: 1.5; }
484
+ text { font-family: Inter, system-ui, sans-serif; }
485
+ .aim-act text, .aim-obj text { text-decoration: underline; }
486
+ `;
487
+ }
488
+ // Update diagram-level routing mode on root <svg>
489
+ const diagramRouting = _options.routingMode ?? model.routing;
490
+ if (diagramRouting) {
491
+ doc.documentElement.setAttribute(AimSvgContract.ATTR_ROUTING, diagramRouting);
492
+ }
493
+ // Ensure layers exist
494
+ let nodesLayer = doc.querySelector('.aim-nodes-layer');
495
+ if (!nodesLayer) {
496
+ nodesLayer = doc.documentElement;
497
+ }
498
+ // Remove deleted nodes
499
+ const existingNodeEls = Array.from(doc.querySelectorAll(AimSvgContract.SELECTOR_NODE));
500
+ for (const nodeEl of existingNodeEls) {
501
+ const id = nodeEl.getAttribute(AimSvgContract.ATTR_ID) ?? nodeEl.getAttribute('id');
502
+ if (id && !model.nodes.some((n) => n.id === id)) {
503
+ nodeEl.remove();
504
+ }
505
+ }
506
+ // Update or insert nodes with canonical archetype shape markup
346
507
  for (const node of model.nodes) {
347
- const el = doc.querySelector(`[${AimSvgContract.ATTR_ID}="${node.id}"], [id="${node.id}"]`);
348
- if (el) {
349
- el.setAttribute('transform', `translate(${node.bounds.x}, ${node.bounds.y})`);
350
- el.setAttribute(AimSvgContract.ATTR_NODE, 'true');
351
- el.setAttribute(AimSvgContract.ATTR_KIND, node.kind);
352
- const rect = el.querySelector('rect');
353
- if (rect) {
354
- rect.setAttribute('width', `${node.bounds.width}`);
355
- rect.setAttribute('height', `${node.bounds.height}`);
356
- }
508
+ let el = doc.querySelector(`[${AimSvgContract.ATTR_ID}="${node.id}"], [id="${node.id}"]`);
509
+ if (!el) {
510
+ el = doc.createElementNS('http://www.w3.org/2000/svg', 'g');
511
+ nodesLayer.appendChild(el);
512
+ }
513
+ el.setAttribute('transform', `translate(${node.bounds.x}, ${node.bounds.y})`);
514
+ el.setAttribute(AimSvgContract.ATTR_NODE, 'true');
515
+ el.setAttribute(AimSvgContract.ATTR_ID, node.id);
516
+ el.setAttribute(AimSvgContract.ATTR_KIND, node.kind);
517
+ el.setAttribute(AimSvgContract.ATTR_DISPLAY_NAME, node.displayName);
518
+ if (node.stereotype) {
519
+ el.setAttribute(AimSvgContract.ATTR_STEREOTYPE, node.stereotype);
520
+ }
521
+ else {
522
+ el.removeAttribute(AimSvgContract.ATTR_STEREOTYPE);
523
+ }
524
+ // Replace inner content with canonical archetype shape markup
525
+ const innerSvg = this.renderNodeInnerSvg(node);
526
+ const fragmentDoc = parser.parseFromString(`<g xmlns="http://www.w3.org/2000/svg">${innerSvg}</g>`, 'image/svg+xml');
527
+ while (el.firstChild) {
528
+ el.removeChild(el.firstChild);
529
+ }
530
+ for (const child of Array.from(fragmentDoc.documentElement.childNodes)) {
531
+ el.appendChild(doc.importNode(child, true));
532
+ }
533
+ }
534
+ let edgesLayer = doc.querySelector('.aim-edges-layer');
535
+ if (!edgesLayer) {
536
+ edgesLayer = doc.documentElement;
537
+ }
538
+ // Remove deleted edges
539
+ const existingEdgeEls = Array.from(doc.querySelectorAll(AimSvgContract.SELECTOR_EDGE));
540
+ for (const edgeEl of existingEdgeEls) {
541
+ const id = edgeEl.getAttribute(AimSvgContract.ATTR_ID) ?? edgeEl.getAttribute('id');
542
+ if (id && !model.edges.some((e) => e.id === id)) {
543
+ edgeEl.remove();
357
544
  }
358
545
  }
359
- // Update edge bend points and aim-bends attributes
546
+ // Update or insert edges with live path geometry
360
547
  for (const edge of model.edges) {
361
- const el = doc.querySelector(`[${AimSvgContract.ATTR_ID}="${edge.id}"], [id="${edge.id}"]`);
362
- if (el) {
363
- const bendsString = this.formatBendPoints(edge.bendPoints);
364
- el.setAttribute(AimSvgContract.ATTR_BENDS, bendsString);
365
- el.setAttribute(AimSvgContract.ATTR_EDGE, 'true');
366
- el.setAttribute(AimSvgContract.ATTR_EDGE_KIND, edge.kind);
367
- if (edge.sourceId !== undefined) {
368
- el.setAttribute(AimSvgContract.ATTR_SOURCE, edge.sourceId);
369
- }
370
- if (edge.targetId !== undefined) {
371
- el.setAttribute(AimSvgContract.ATTR_TARGET, edge.targetId);
372
- }
373
- if (edge.sourcePort !== undefined) {
374
- el.setAttribute(AimSvgContract.ATTR_SOURCE_PORT, edge.sourcePort);
375
- }
376
- if (edge.targetPort !== undefined) {
377
- el.setAttribute(AimSvgContract.ATTR_TARGET_PORT, edge.targetPort);
378
- }
379
- if (edge.routing !== undefined) {
380
- el.setAttribute(AimSvgContract.ATTR_ROUTING, edge.routing);
548
+ let el = doc.querySelector(`[${AimSvgContract.ATTR_ID}="${edge.id}"], [id="${edge.id}"]`);
549
+ if (!el) {
550
+ el = doc.createElementNS('http://www.w3.org/2000/svg', 'g');
551
+ if (nodesLayer && nodesLayer.parentNode === doc.documentElement) {
552
+ doc.documentElement.insertBefore(el, nodesLayer);
381
553
  }
382
- if (edge.stereotype !== undefined) {
383
- el.setAttribute(AimSvgContract.ATTR_STEREOTYPE, edge.stereotype);
554
+ else {
555
+ edgesLayer.appendChild(el);
384
556
  }
385
- if (edge.sourceCardinality !== undefined) {
386
- el.setAttribute('aim-source-cardinality', edge.sourceCardinality);
557
+ }
558
+ const bendsString = this.formatBendPoints(edge.bendPoints);
559
+ el.setAttribute(AimSvgContract.ATTR_BENDS, bendsString);
560
+ el.setAttribute(AimSvgContract.ATTR_EDGE, 'true');
561
+ el.setAttribute(AimSvgContract.ATTR_ID, edge.id);
562
+ el.setAttribute(AimSvgContract.ATTR_EDGE_KIND, edge.kind);
563
+ if (edge.sourceId !== undefined) {
564
+ el.setAttribute(AimSvgContract.ATTR_SOURCE, edge.sourceId);
565
+ }
566
+ if (edge.targetId !== undefined) {
567
+ el.setAttribute(AimSvgContract.ATTR_TARGET, edge.targetId);
568
+ }
569
+ if (edge.sourcePort !== undefined && edge.sourcePort !== '' && edge.sourcePort !== 'auto') {
570
+ el.setAttribute(AimSvgContract.ATTR_SOURCE_PORT, edge.sourcePort);
571
+ }
572
+ else {
573
+ el.removeAttribute(AimSvgContract.ATTR_SOURCE_PORT);
574
+ }
575
+ if (edge.targetPort !== undefined && edge.targetPort !== '' && edge.targetPort !== 'auto') {
576
+ el.setAttribute(AimSvgContract.ATTR_TARGET_PORT, edge.targetPort);
577
+ }
578
+ else {
579
+ el.removeAttribute(AimSvgContract.ATTR_TARGET_PORT);
580
+ }
581
+ if (edge.routing !== undefined) {
582
+ el.setAttribute(AimSvgContract.ATTR_ROUTING, edge.routing);
583
+ }
584
+ if (edge.stereotype !== undefined) {
585
+ el.setAttribute(AimSvgContract.ATTR_STEREOTYPE, edge.stereotype);
586
+ }
587
+ if (edge.sourceCardinality !== undefined) {
588
+ el.setAttribute('aim-source-cardinality', edge.sourceCardinality);
589
+ }
590
+ if (edge.targetCardinality !== undefined) {
591
+ el.setAttribute('aim-target-cardinality', edge.targetCardinality);
592
+ }
593
+ // Update or inject <path class="aim-edge"> with rendered path geometry
594
+ let pathD = edge.pathData;
595
+ if (!pathD) {
596
+ const sourceNode = model.nodes.find((n) => n.id === edge.sourceId);
597
+ const targetNode = model.nodes.find((n) => n.id === edge.targetId);
598
+ if (sourceNode && targetNode) {
599
+ pathD = this.computeFallbackEdgePath(sourceNode, targetNode, edge.bendPoints, edge.routing ?? model.routing);
387
600
  }
388
- if (edge.targetCardinality !== undefined) {
389
- el.setAttribute('aim-target-cardinality', edge.targetCardinality);
601
+ }
602
+ let pathEl = el.querySelector('path.aim-edge') ?? el.querySelector('path');
603
+ if (!pathEl) {
604
+ pathEl = doc.createElementNS('http://www.w3.org/2000/svg', 'path');
605
+ pathEl.setAttribute('class', 'aim-edge');
606
+ el.appendChild(pathEl);
607
+ }
608
+ if (pathD) {
609
+ pathEl.setAttribute('d', pathD);
610
+ }
611
+ pathEl.setAttribute('fill', 'none');
612
+ pathEl.setAttribute('stroke', CascaisPalette.WarmGraphite);
613
+ pathEl.setAttribute('stroke-width', '1.5');
614
+ if (edge.kind === 'dependency') {
615
+ pathEl.setAttribute('stroke-dasharray', '5,5');
616
+ }
617
+ else {
618
+ pathEl.removeAttribute('stroke-dasharray');
619
+ }
620
+ const markerEnd = edge.kind === 'generalization' ? 'url(#arrow-hollow)' : 'url(#arrow-classic)';
621
+ pathEl.setAttribute('marker-end', markerEnd);
622
+ // Update or inject edge label
623
+ if (edge.label) {
624
+ let textEl = el.querySelector('text');
625
+ if (!textEl) {
626
+ textEl = doc.createElementNS('http://www.w3.org/2000/svg', 'text');
627
+ el.appendChild(textEl);
390
628
  }
629
+ const midPoint = edge.bendPoints.length > 0
630
+ ? (edge.bendPoints[Math.floor(edge.bendPoints.length / 2)] ?? { x: 50, y: 50 })
631
+ : (() => {
632
+ const s = model.nodes.find((n) => n.id === edge.sourceId);
633
+ const t = model.nodes.find((n) => n.id === edge.targetId);
634
+ if (s && t) {
635
+ return {
636
+ x: Math.round((s.bounds.x + s.bounds.width / 2 + t.bounds.x + t.bounds.width / 2) / 2),
637
+ y: Math.round((s.bounds.y + s.bounds.height / 2 + t.bounds.y + t.bounds.height / 2) / 2),
638
+ };
639
+ }
640
+ return { x: 50, y: 50 };
641
+ })();
642
+ textEl.setAttribute('x', `${midPoint.x}`);
643
+ textEl.setAttribute('y', `${midPoint.y - 8}`);
644
+ textEl.setAttribute('font-size', '11');
645
+ textEl.setAttribute('fill', CascaisPalette.TextSecondary);
646
+ textEl.setAttribute('text-anchor', 'middle');
647
+ textEl.textContent = edge.label;
391
648
  }
392
649
  }
393
650
  return new XMLSerializer().serializeToString(doc);
394
651
  }
652
+ renderSvgText(text, cx, cy, fontSize, fontWeight, fill, underline = false) {
653
+ const wrapped = wrapAimText(text);
654
+ const lines = wrapped.split('\n');
655
+ const underlineAttr = underline ? ' text-decoration="underline"' : '';
656
+ const weightAttr = fontWeight !== 'normal' ? ` font-weight="${fontWeight}"` : '';
657
+ if (lines.length <= 1) {
658
+ return ` <text x="${cx}" y="${cy}" font-size="${fontSize}"${weightAttr} fill="${fill}" text-anchor="middle" dominant-baseline="central"${underlineAttr}>${lines[0] ?? ''}</text>\n`;
659
+ }
660
+ const lineHeight = fontSize * 1.25;
661
+ const startY = cy - ((lines.length - 1) * lineHeight) / 2;
662
+ let tspans = '';
663
+ lines.forEach((line, idx) => {
664
+ const y = Math.round(startY + idx * lineHeight);
665
+ tspans += `<tspan x="${cx}" y="${y}">${line}</tspan>`;
666
+ });
667
+ return ` <text font-size="${fontSize}"${weightAttr} fill="${fill}" text-anchor="middle" dominant-baseline="central"${underlineAttr}>${tspans}</text>\n`;
668
+ }
669
+ /**
670
+ * Generates the canonical inner SVG elements (shapes and styled text)
671
+ * for a given AOAIM node archetype.
672
+ */
673
+ renderNodeInnerSvg(node) {
674
+ let svg = '';
675
+ if (node.kind === 'uc') {
676
+ const rx = node.bounds.width / 2;
677
+ const ry = node.bounds.height / 2;
678
+ svg += ` <ellipse cx="${rx}" cy="${ry}" rx="${rx}" ry="${ry}" fill="${CascaisPalette.ChalkWhite}" stroke="${CascaisPalette.NetGold}" stroke-width="2" />\n`;
679
+ svg += this.renderSvgText(node.displayName, rx, ry, 13, 'bold', CascaisPalette.TextPrimary, false);
680
+ }
681
+ else if (node.kind === 'act') {
682
+ svg += ` <rect width="${node.bounds.width}" height="${node.bounds.height}" rx="12" ry="12" fill="${CascaisPalette.CanvasCream}" stroke="${CascaisPalette.HeraldicGreen}" stroke-width="2" />\n`;
683
+ svg += this.renderSvgText(node.displayName, node.bounds.width / 2, node.bounds.height / 2, 13, '600', CascaisPalette.TextPrimary, true);
684
+ }
685
+ else if (node.kind === 'obj') {
686
+ svg += ` <rect width="${node.bounds.width}" height="${node.bounds.height}" fill="${CascaisPalette.ChalkWhite}" stroke="${CascaisPalette.SilverLineDark}" stroke-width="1.5" />\n`;
687
+ svg += this.renderSvgText(node.displayName, node.bounds.width / 2, node.bounds.height / 2, 12, 'normal', CascaisPalette.TextPrimary, true);
688
+ }
689
+ else if (node.kind === 'per') {
690
+ const isInitiating = node.stereotype?.toLowerCase().includes('initiates') ?? false;
691
+ const strokeColor = isInitiating ? CascaisPalette.NetGold : CascaisPalette.WarmGraphite;
692
+ const cx = Math.round(node.bounds.width / 2);
693
+ svg += ` <rect width="${node.bounds.width}" height="${node.bounds.height}" fill="none" stroke="none" />\n`;
694
+ 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`;
695
+ svg += ` <circle cx="45" cy="22" r="8" fill="${CascaisPalette.ChalkWhite}" stroke="${strokeColor}" stroke-width="2" />\n`;
696
+ svg += this.renderSvgText(node.displayName, cx, 68, 12, '500', CascaisPalette.TextPrimary, false);
697
+ }
698
+ else {
699
+ svg += ` <rect width="${node.bounds.width}" height="${node.bounds.height}" fill="${CascaisPalette.ChalkWhite}" stroke="${CascaisPalette.WarmGraphite}" stroke-width="1.5" />\n`;
700
+ svg += this.renderSvgText(node.displayName, node.bounds.width / 2, node.bounds.height / 2, 12, 'normal', CascaisPalette.TextPrimary, false);
701
+ }
702
+ return svg;
703
+ }
395
704
  generateFreshSvg(model, options) {
396
705
  const width = Math.max(800, ...model.nodes.map((n) => n.bounds.x + n.bounds.width + 100));
397
706
  const height = Math.max(600, ...model.nodes.map((n) => n.bounds.y + n.bounds.height + 100));
398
- let svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" id="${model.diagramId}" aim-archetype="${model.archetype}">\n`;
707
+ const diagramRouting = options.routingMode ?? model.routing;
708
+ const routingAttr = diagramRouting ? ` ${AimSvgContract.ATTR_ROUTING}="${diagramRouting}"` : '';
709
+ let svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" id="${model.diagramId}" aim-archetype="${model.archetype}"${routingAttr}>\n`;
399
710
  // Definitions & Markers
400
711
  svg += ` <defs>\n`;
401
712
  svg += ` <marker id="arrow-classic" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">\n`;
@@ -410,6 +721,7 @@ export class RaiBridge {
410
721
  svg += ` .aim-node:hover { filter: drop-shadow(0 4px 6px rgba(0,0,0,0.1)); }\n`;
411
722
  svg += ` .aim-edge { fill: none; stroke: ${CascaisPalette.WarmGraphite}; stroke-width: 1.5; }\n`;
412
723
  svg += ` text { font-family: Inter, system-ui, sans-serif; }\n`;
724
+ svg += ` .aim-act text, .aim-obj text { text-decoration: underline; }\n`;
413
725
  svg += ` </style>\n`;
414
726
  }
415
727
  svg += ` </defs>\n\n`;
@@ -421,20 +733,39 @@ export class RaiBridge {
421
733
  const strokeDash = edge.kind === 'dependency' ? ' stroke-dasharray="5,5"' : '';
422
734
  const markerEnd = edge.kind === 'generalization' ? ' marker-end="url(#arrow-hollow)"' : ' marker-end="url(#arrow-classic)"';
423
735
  // Path data construction
424
- let pathD = '';
425
- if (edge.bendPoints.length > 0) {
426
- const first = edge.bendPoints[0];
427
- pathD = `M ${first.x} ${first.y} ` + edge.bendPoints.slice(1).map((p) => `L ${p.x} ${p.y}`).join(' ');
736
+ let pathD = edge.pathData ?? '';
737
+ if (!pathD) {
738
+ const sourceNode = model.nodes.find((n) => n.id === edge.sourceId);
739
+ const targetNode = model.nodes.find((n) => n.id === edge.targetId);
740
+ if (sourceNode && targetNode) {
741
+ pathD = this.computeFallbackEdgePath(sourceNode, targetNode, edge.bendPoints, edge.routing ?? model.routing);
742
+ }
743
+ else if (edge.bendPoints.length > 0) {
744
+ const first = edge.bendPoints[0];
745
+ pathD = `M ${first.x} ${first.y} ` + edge.bendPoints.slice(1).map((p) => `L ${p.x} ${p.y}`).join(' ');
746
+ }
428
747
  }
429
- const sourcePortAttr = edge.sourcePort ? ` ${AimSvgContract.ATTR_SOURCE_PORT}="${edge.sourcePort}"` : '';
430
- const targetPortAttr = edge.targetPort ? ` ${AimSvgContract.ATTR_TARGET_PORT}="${edge.targetPort}"` : '';
748
+ const sourcePortAttr = edge.sourcePort && edge.sourcePort !== 'auto' ? ` ${AimSvgContract.ATTR_SOURCE_PORT}="${edge.sourcePort}"` : '';
749
+ const targetPortAttr = edge.targetPort && edge.targetPort !== 'auto' ? ` ${AimSvgContract.ATTR_TARGET_PORT}="${edge.targetPort}"` : '';
431
750
  const routingAttr = edge.routing ? ` ${AimSvgContract.ATTR_ROUTING}="${edge.routing}"` : '';
432
751
  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`;
433
752
  if (pathD) {
434
- svg += ` <path d="${pathD}" class="aim-edge"${strokeDash}${markerEnd} />\n`;
753
+ svg += ` <path d="${pathD}" class="aim-edge" fill="none" stroke="${CascaisPalette.WarmGraphite}" stroke-width="1.5"${strokeDash}${markerEnd} />\n`;
435
754
  }
436
755
  if (edge.label) {
437
- const midPoint = edge.bendPoints[Math.floor(edge.bendPoints.length / 2)] ?? { x: 50, y: 50 };
756
+ const midPoint = edge.bendPoints.length > 0
757
+ ? (edge.bendPoints[Math.floor(edge.bendPoints.length / 2)] ?? { x: 50, y: 50 })
758
+ : (() => {
759
+ const s = model.nodes.find((n) => n.id === edge.sourceId);
760
+ const t = model.nodes.find((n) => n.id === edge.targetId);
761
+ if (s && t) {
762
+ return {
763
+ x: Math.round((s.bounds.x + s.bounds.width / 2 + t.bounds.x + t.bounds.width / 2) / 2),
764
+ y: Math.round((s.bounds.y + s.bounds.height / 2 + t.bounds.y + t.bounds.height / 2) / 2),
765
+ };
766
+ }
767
+ return { x: 50, y: 50 };
768
+ })();
438
769
  svg += ` <text x="${midPoint.x}" y="${midPoint.y - 8}" font-size="11" fill="${CascaisPalette.TextSecondary}" text-anchor="middle">${edge.label}</text>\n`;
439
770
  }
440
771
  svg += ` </g>\n`;
@@ -444,27 +775,25 @@ export class RaiBridge {
444
775
  svg += ` <!-- Nodes -->\n`;
445
776
  svg += ` <g class="aim-nodes-layer">\n`;
446
777
  for (const node of model.nodes) {
447
- 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`;
448
- if (node.kind === 'uc') {
449
- const rx = node.bounds.width / 2;
450
- const ry = node.bounds.height / 2;
451
- svg += ` <ellipse cx="${rx}" cy="${ry}" rx="${rx}" ry="${ry}" fill="${CascaisPalette.ChalkWhite}" stroke="${CascaisPalette.NetGold}" stroke-width="2" />\n`;
452
- 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`;
453
- }
454
- else if (node.kind === 'act') {
455
- svg += ` <rect width="${node.bounds.width}" height="${node.bounds.height}" rx="12" ry="12" fill="${CascaisPalette.CanvasCream}" stroke="${CascaisPalette.HeraldicGreen}" stroke-width="2" />\n`;
456
- 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`;
457
- }
458
- else {
459
- svg += ` <rect width="${node.bounds.width}" height="${node.bounds.height}" fill="${CascaisPalette.ChalkWhite}" stroke="${CascaisPalette.WarmGraphite}" stroke-width="1.5" />\n`;
460
- 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`;
461
- }
778
+ const stereotypeAttr = node.stereotype ? ` ${AimSvgContract.ATTR_STEREOTYPE}="${node.stereotype}"` : '';
779
+ 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`;
780
+ svg += this.renderNodeInnerSvg(node);
462
781
  svg += ` </g>\n`;
463
782
  }
464
783
  svg += ` </g>\n`;
465
784
  svg += `</svg>\n`;
466
785
  return svg;
467
786
  }
787
+ normalizeRouting(raw) {
788
+ const lower = raw.toLowerCase().trim();
789
+ if (lower === 'orthogonal' || lower === 'manhattan')
790
+ return 'manhattan';
791
+ if (lower === 'straight' || lower === 'normal')
792
+ return 'normal';
793
+ if (lower === 'curved' || lower === 'smooth')
794
+ return 'smooth';
795
+ return 'manhattan';
796
+ }
468
797
  normalizeKind(kind) {
469
798
  const lower = kind.toLowerCase();
470
799
  if (lower === 'uc' || lower === 'usecase')