@dr2rai/raid-canvas 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/NOTICE +2 -0
- package/dist/RaiBridge.d.ts +54 -0
- package/dist/RaiBridge.d.ts.map +1 -0
- package/dist/RaiBridge.js +408 -0
- package/dist/RaiBridge.js.map +1 -0
- package/dist/X6Shapes.d.ts +134 -0
- package/dist/X6Shapes.d.ts.map +1 -0
- package/dist/X6Shapes.js +536 -0
- package/dist/X6Shapes.js.map +1 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +17 -0
- package/dist/index.js.map +1 -0
- package/dist/types.d.ts +162 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +32 -0
- package/dist/types.js.map +1 -0
- package/package.json +42 -0
- package/src/RaiBridge.ts +485 -0
- package/src/X6Shapes.ts +575 -0
- package/src/index.ts +39 -0
- package/src/styles/aoaim-theme.css +205 -0
- package/src/types.ts +208 -0
package/src/RaiBridge.ts
ADDED
|
@@ -0,0 +1,485 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file RaiBridge.ts
|
|
3
|
+
* @description Bidirectional synchronization bridge between the SVG `aim-*` ontological
|
|
4
|
+
* contract and AntV X6 Graph models.
|
|
5
|
+
*
|
|
6
|
+
* Capabilities:
|
|
7
|
+
* - Hydrate: Ingests an SVG containing `aim-*` semantic attributes and inflates an interactive
|
|
8
|
+
* AntV X6 graph with orthogonal ports and Manhattan-routed edges.
|
|
9
|
+
* - Serialize: Extracts updated coordinates, node dimensions, and user-dragged bend points from
|
|
10
|
+
* the X6 graph, serializing them back into the SVG document while preserving semantic fidelity.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { Graph } from '@antv/x6';
|
|
14
|
+
import {
|
|
15
|
+
AimSvgContract,
|
|
16
|
+
type AimOntologyKind,
|
|
17
|
+
type AimEdgeKind,
|
|
18
|
+
type RaidNodeData,
|
|
19
|
+
type RaidEdgeData,
|
|
20
|
+
type RaidMetamodel,
|
|
21
|
+
type SvgBendPoint,
|
|
22
|
+
type Bounds,
|
|
23
|
+
type HydrationOptions,
|
|
24
|
+
type SerializationOptions,
|
|
25
|
+
} from './types.js';
|
|
26
|
+
import { createAimNode, createAimEdge, configureAimGraph, CascaisPalette } from './X6Shapes.js';
|
|
27
|
+
|
|
28
|
+
export class RaiBridge {
|
|
29
|
+
/**
|
|
30
|
+
* Hydrates an AntV X6 graph from an SVG source string or DOM Element.
|
|
31
|
+
*
|
|
32
|
+
* @param svgSource Raw SVG markup string or SVGSVGElement DOM node.
|
|
33
|
+
* @param graph The AntV X6 Graph instance to populate.
|
|
34
|
+
* @param options Hydration options.
|
|
35
|
+
* @returns The extracted RaidMetamodel representation.
|
|
36
|
+
*/
|
|
37
|
+
public hydrateFromSvg(
|
|
38
|
+
svgSource: string | Element,
|
|
39
|
+
graph: Graph,
|
|
40
|
+
options: HydrationOptions = {},
|
|
41
|
+
): RaidMetamodel {
|
|
42
|
+
configureAimGraph(graph);
|
|
43
|
+
|
|
44
|
+
if (options.clearGraph !== false) {
|
|
45
|
+
graph.clearCells();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const doc = this.resolveSvgElement(svgSource);
|
|
49
|
+
const metamodel = this.extractMetamodel(doc, options);
|
|
50
|
+
|
|
51
|
+
// 1. Add all nodes to graph
|
|
52
|
+
for (const nodeData of metamodel.nodes) {
|
|
53
|
+
const nodeMeta = createAimNode(nodeData);
|
|
54
|
+
graph.addNode(nodeMeta);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// 2. Add all edges to graph
|
|
58
|
+
for (const edgeData of metamodel.edges) {
|
|
59
|
+
const edgeMeta = createAimEdge(edgeData);
|
|
60
|
+
graph.addEdge(edgeMeta);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return metamodel;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Serializes current X6 graph layout (positions, bounds, bend points) back into an SVG document.
|
|
68
|
+
*
|
|
69
|
+
* @param graph The AntV X6 Graph instance.
|
|
70
|
+
* @param baseSvg Optional base SVG string to update in-place.
|
|
71
|
+
* @param options Serialization options.
|
|
72
|
+
* @returns The updated SVG markup string adhering to the aim-* contract.
|
|
73
|
+
*/
|
|
74
|
+
public serializeToSvg(
|
|
75
|
+
graph: Graph,
|
|
76
|
+
baseSvg?: string,
|
|
77
|
+
options: SerializationOptions = {},
|
|
78
|
+
): string {
|
|
79
|
+
const metamodel = this.metamodelFromGraph(graph);
|
|
80
|
+
|
|
81
|
+
if (baseSvg) {
|
|
82
|
+
return this.updateExistingSvg(baseSvg, metamodel, options);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return this.generateFreshSvg(metamodel, options);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Extracts a pure RaidMetamodel from an active AntV X6 Graph.
|
|
90
|
+
*/
|
|
91
|
+
public metamodelFromGraph(graph: Graph): RaidMetamodel {
|
|
92
|
+
const nodes: RaidNodeData[] = [];
|
|
93
|
+
const edges: RaidEdgeData[] = [];
|
|
94
|
+
|
|
95
|
+
// Extract nodes
|
|
96
|
+
const x6Nodes = graph.getNodes();
|
|
97
|
+
for (const node of x6Nodes) {
|
|
98
|
+
const pos = node.getPosition();
|
|
99
|
+
const size = node.getSize();
|
|
100
|
+
const customData = (node.getData() ?? {}) as Partial<RaidNodeData>;
|
|
101
|
+
|
|
102
|
+
const id = node.id;
|
|
103
|
+
const kind = (customData.kind ?? this.kindFromShape(node.shape)) as AimOntologyKind;
|
|
104
|
+
const displayName = customData.displayName ?? (node.getAttrByPath('label/text') as string) ?? id;
|
|
105
|
+
|
|
106
|
+
const nodeData: RaidNodeData = {
|
|
107
|
+
id,
|
|
108
|
+
kind,
|
|
109
|
+
displayName,
|
|
110
|
+
...(customData.stereotype !== undefined ? { stereotype: customData.stereotype } : {}),
|
|
111
|
+
...(customData.namespace !== undefined ? { namespace: customData.namespace } : {}),
|
|
112
|
+
...(customData.attributes !== undefined ? { attributes: customData.attributes } : {}),
|
|
113
|
+
...(customData.methods !== undefined ? { methods: customData.methods } : {}),
|
|
114
|
+
bounds: {
|
|
115
|
+
x: pos.x,
|
|
116
|
+
y: pos.y,
|
|
117
|
+
width: size.width,
|
|
118
|
+
height: size.height,
|
|
119
|
+
},
|
|
120
|
+
...(customData.properties !== undefined ? { properties: customData.properties } : {}),
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
nodes.push(nodeData);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Extract edges
|
|
127
|
+
const x6Edges = graph.getEdges();
|
|
128
|
+
for (const edge of x6Edges) {
|
|
129
|
+
const source = edge.getSourceCell();
|
|
130
|
+
const target = edge.getTargetCell();
|
|
131
|
+
if (!source || !target) continue;
|
|
132
|
+
|
|
133
|
+
const vertices = edge.getVertices();
|
|
134
|
+
const bendPoints: SvgBendPoint[] = vertices.map((v) => ({ x: v.x, y: v.y }));
|
|
135
|
+
const customData = (edge.getData() ?? {}) as Partial<RaidEdgeData>;
|
|
136
|
+
|
|
137
|
+
const sourcePort = edge.getSourcePortId();
|
|
138
|
+
const targetPort = edge.getTargetPortId();
|
|
139
|
+
const label = (edge.getLabels()?.[0]?.attrs?.['text']?.['text'] as string | undefined) ?? customData.label;
|
|
140
|
+
|
|
141
|
+
const edgeData: RaidEdgeData = {
|
|
142
|
+
id: edge.id,
|
|
143
|
+
kind: customData.kind ?? 'association',
|
|
144
|
+
sourceId: source.id,
|
|
145
|
+
targetId: target.id,
|
|
146
|
+
...(sourcePort !== undefined ? { sourcePort } : {}),
|
|
147
|
+
...(targetPort !== undefined ? { targetPort } : {}),
|
|
148
|
+
...(label !== undefined ? { label } : {}),
|
|
149
|
+
...(customData.stereotype !== undefined ? { stereotype: customData.stereotype } : {}),
|
|
150
|
+
bendPoints,
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
edges.push(edgeData);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
diagramId: 'RaidDiagram',
|
|
158
|
+
archetype: 'InteractiveCanvas',
|
|
159
|
+
nodes,
|
|
160
|
+
edges,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Parses semicolon-separated bend points: "100,50; 200,50; 200,150"
|
|
166
|
+
*/
|
|
167
|
+
public parseBendPoints(bendsStr: string): SvgBendPoint[] {
|
|
168
|
+
if (!bendsStr || bendsStr.trim().length === 0) {
|
|
169
|
+
return [];
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return bendsStr
|
|
173
|
+
.split(';')
|
|
174
|
+
.map((part) => part.trim())
|
|
175
|
+
.filter((part) => part.length > 0)
|
|
176
|
+
.map((part) => {
|
|
177
|
+
const [xStr, yStr] = part.split(',');
|
|
178
|
+
const x = parseFloat(xStr?.trim() ?? '0');
|
|
179
|
+
const y = parseFloat(yStr?.trim() ?? '0');
|
|
180
|
+
return { x: Number.isNaN(x) ? 0 : x, y: Number.isNaN(y) ? 0 : y };
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Formats bend points into the canonical aim-bends format: "x1,y1; x2,y2"
|
|
186
|
+
*/
|
|
187
|
+
public formatBendPoints(points: readonly SvgBendPoint[]): string {
|
|
188
|
+
return points.map((p) => `${Math.round(p.x)},${Math.round(p.y)}`).join('; ');
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// --------------------------------------------------------------------------
|
|
192
|
+
// Private Helper Implementation
|
|
193
|
+
// --------------------------------------------------------------------------
|
|
194
|
+
|
|
195
|
+
private resolveSvgElement(source: string | Element): Element {
|
|
196
|
+
if (typeof source !== 'string') {
|
|
197
|
+
return source;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (typeof DOMParser !== 'undefined') {
|
|
201
|
+
const parser = new DOMParser();
|
|
202
|
+
const parsed = parser.parseFromString(source, 'image/svg+xml');
|
|
203
|
+
const root = parsed.documentElement;
|
|
204
|
+
if (root.tagName.toLowerCase() === 'parsererror') {
|
|
205
|
+
throw new Error('Failed to parse SVG: XML Parser Error');
|
|
206
|
+
}
|
|
207
|
+
return root;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
throw new Error('DOMParser unavailable in current execution environment');
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
private extractMetamodel(svgRoot: Element, options: HydrationOptions): RaidMetamodel {
|
|
214
|
+
const nodes: RaidNodeData[] = [];
|
|
215
|
+
const edges: RaidEdgeData[] = [];
|
|
216
|
+
|
|
217
|
+
// 1. Locate all node elements
|
|
218
|
+
const nodeElements = Array.from(
|
|
219
|
+
svgRoot.querySelectorAll(AimSvgContract.SELECTOR_NODE),
|
|
220
|
+
);
|
|
221
|
+
|
|
222
|
+
for (const el of nodeElements) {
|
|
223
|
+
const id =
|
|
224
|
+
el.getAttribute(AimSvgContract.ATTR_ID) ??
|
|
225
|
+
el.getAttribute('data-node') ??
|
|
226
|
+
el.getAttribute('data-element-id') ??
|
|
227
|
+
el.getAttribute('id');
|
|
228
|
+
|
|
229
|
+
if (!id) continue;
|
|
230
|
+
|
|
231
|
+
const rawKind = el.getAttribute(AimSvgContract.ATTR_KIND) ?? 'act';
|
|
232
|
+
const kind = this.normalizeKind(rawKind);
|
|
233
|
+
|
|
234
|
+
const displayName =
|
|
235
|
+
el.getAttribute(AimSvgContract.ATTR_DISPLAY_NAME) ??
|
|
236
|
+
el.querySelector('text')?.textContent?.trim() ??
|
|
237
|
+
id;
|
|
238
|
+
|
|
239
|
+
const stereotype = el.getAttribute(AimSvgContract.ATTR_STEREOTYPE) ?? undefined;
|
|
240
|
+
const bounds = this.extractBounds(el, options.defaultNodeSize);
|
|
241
|
+
|
|
242
|
+
nodes.push({
|
|
243
|
+
id,
|
|
244
|
+
kind,
|
|
245
|
+
displayName,
|
|
246
|
+
...(stereotype !== undefined ? { stereotype } : {}),
|
|
247
|
+
bounds,
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// 2. Locate all edge elements
|
|
252
|
+
const edgeElements = Array.from(
|
|
253
|
+
svgRoot.querySelectorAll(AimSvgContract.SELECTOR_EDGE),
|
|
254
|
+
);
|
|
255
|
+
|
|
256
|
+
for (const el of edgeElements) {
|
|
257
|
+
const id = el.getAttribute(AimSvgContract.ATTR_ID) ?? el.getAttribute('id') ?? `edge-${edges.length + 1}`;
|
|
258
|
+
const sourceId = el.getAttribute(AimSvgContract.ATTR_SOURCE);
|
|
259
|
+
const targetId = el.getAttribute(AimSvgContract.ATTR_TARGET);
|
|
260
|
+
|
|
261
|
+
if (!sourceId || !targetId) continue;
|
|
262
|
+
|
|
263
|
+
const rawEdgeKind = el.getAttribute(AimSvgContract.ATTR_EDGE_KIND) ?? 'association';
|
|
264
|
+
const kind = this.normalizeEdgeKind(rawEdgeKind);
|
|
265
|
+
|
|
266
|
+
const bendsAttr = el.getAttribute(AimSvgContract.ATTR_BENDS) ?? '';
|
|
267
|
+
const bendPoints = this.parseBendPoints(bendsAttr);
|
|
268
|
+
|
|
269
|
+
const sourcePort = el.getAttribute(AimSvgContract.ATTR_SOURCE_PORT) ?? undefined;
|
|
270
|
+
const targetPort = el.getAttribute(AimSvgContract.ATTR_TARGET_PORT) ?? undefined;
|
|
271
|
+
|
|
272
|
+
edges.push({
|
|
273
|
+
id,
|
|
274
|
+
kind,
|
|
275
|
+
sourceId,
|
|
276
|
+
targetId,
|
|
277
|
+
...(sourcePort !== undefined ? { sourcePort } : {}),
|
|
278
|
+
...(targetPort !== undefined ? { targetPort } : {}),
|
|
279
|
+
bendPoints,
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
return {
|
|
284
|
+
diagramId: svgRoot.getAttribute('id') ?? 'ImportedDiagram',
|
|
285
|
+
archetype: svgRoot.getAttribute('aim-archetype') ?? 'AOAIMDiagram',
|
|
286
|
+
nodes,
|
|
287
|
+
edges,
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
private extractBounds(
|
|
292
|
+
el: Element,
|
|
293
|
+
defaultSize: { width: number; height: number } = { width: 140, height: 60 },
|
|
294
|
+
): Bounds {
|
|
295
|
+
let x = 0;
|
|
296
|
+
let y = 0;
|
|
297
|
+
let width = defaultSize.width;
|
|
298
|
+
let height = defaultSize.height;
|
|
299
|
+
|
|
300
|
+
// Check transform matrix or translate
|
|
301
|
+
const transform = el.getAttribute('transform');
|
|
302
|
+
if (transform) {
|
|
303
|
+
const match = /translate\(\s*([-\d.]+)[,\s]+([-\d.]+)\s*\)/.exec(transform);
|
|
304
|
+
if (match && match[1] && match[2]) {
|
|
305
|
+
x = parseFloat(match[1]);
|
|
306
|
+
y = parseFloat(match[2]);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// Check direct geometry attributes (rect, ellipse, circle)
|
|
311
|
+
const rect = el.querySelector('rect') ?? (el.tagName.toLowerCase() === 'rect' ? el : null);
|
|
312
|
+
if (rect) {
|
|
313
|
+
if (!transform && rect.getAttribute('x')) x = parseFloat(rect.getAttribute('x')!);
|
|
314
|
+
if (!transform && rect.getAttribute('y')) y = parseFloat(rect.getAttribute('y')!);
|
|
315
|
+
if (rect.getAttribute('width')) width = parseFloat(rect.getAttribute('width')!);
|
|
316
|
+
if (rect.getAttribute('height')) height = parseFloat(rect.getAttribute('height')!);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const ellipse =
|
|
320
|
+
el.querySelector('ellipse') ?? (el.tagName.toLowerCase() === 'ellipse' ? el : null);
|
|
321
|
+
if (ellipse) {
|
|
322
|
+
const rx = parseFloat(ellipse.getAttribute('rx') ?? `${width / 2}`);
|
|
323
|
+
const ry = parseFloat(ellipse.getAttribute('ry') ?? `${height / 2}`);
|
|
324
|
+
width = rx * 2;
|
|
325
|
+
height = ry * 2;
|
|
326
|
+
if (!transform) {
|
|
327
|
+
const cx = parseFloat(ellipse.getAttribute('cx') ?? '0');
|
|
328
|
+
const cy = parseFloat(ellipse.getAttribute('cy') ?? '0');
|
|
329
|
+
x = cx - rx;
|
|
330
|
+
y = cy - ry;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
return { x, y, width, height };
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
private updateExistingSvg(
|
|
338
|
+
baseSvg: string,
|
|
339
|
+
model: RaidMetamodel,
|
|
340
|
+
_options: SerializationOptions,
|
|
341
|
+
): string {
|
|
342
|
+
if (typeof DOMParser === 'undefined' || typeof XMLSerializer === 'undefined') {
|
|
343
|
+
return this.generateFreshSvg(model, _options);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const parser = new DOMParser();
|
|
347
|
+
const doc = parser.parseFromString(baseSvg, 'image/svg+xml');
|
|
348
|
+
|
|
349
|
+
// Update node positions and transforms
|
|
350
|
+
for (const node of model.nodes) {
|
|
351
|
+
const el = doc.querySelector(`[${AimSvgContract.ATTR_ID}="${node.id}"], [id="${node.id}"]`);
|
|
352
|
+
if (el) {
|
|
353
|
+
el.setAttribute('transform', `translate(${node.bounds.x}, ${node.bounds.y})`);
|
|
354
|
+
el.setAttribute(AimSvgContract.ATTR_NODE, 'true');
|
|
355
|
+
el.setAttribute(AimSvgContract.ATTR_KIND, node.kind);
|
|
356
|
+
|
|
357
|
+
const rect = el.querySelector('rect');
|
|
358
|
+
if (rect) {
|
|
359
|
+
rect.setAttribute('width', `${node.bounds.width}`);
|
|
360
|
+
rect.setAttribute('height', `${node.bounds.height}`);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// Update edge bend points and aim-bends attributes
|
|
366
|
+
for (const edge of model.edges) {
|
|
367
|
+
const el = doc.querySelector(`[${AimSvgContract.ATTR_ID}="${edge.id}"], [id="${edge.id}"]`);
|
|
368
|
+
if (el) {
|
|
369
|
+
const bendsString = this.formatBendPoints(edge.bendPoints);
|
|
370
|
+
el.setAttribute(AimSvgContract.ATTR_BENDS, bendsString);
|
|
371
|
+
el.setAttribute(AimSvgContract.ATTR_EDGE, 'true');
|
|
372
|
+
el.setAttribute(AimSvgContract.ATTR_EDGE_KIND, edge.kind);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
return new XMLSerializer().serializeToString(doc);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
private generateFreshSvg(model: RaidMetamodel, options: SerializationOptions): string {
|
|
380
|
+
const width = Math.max(800, ...model.nodes.map((n) => n.bounds.x + n.bounds.width + 100));
|
|
381
|
+
const height = Math.max(600, ...model.nodes.map((n) => n.bounds.y + n.bounds.height + 100));
|
|
382
|
+
|
|
383
|
+
let svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" id="${model.diagramId}" aim-archetype="${model.archetype}">\n`;
|
|
384
|
+
|
|
385
|
+
// Definitions & Markers
|
|
386
|
+
svg += ` <defs>\n`;
|
|
387
|
+
svg += ` <marker id="arrow-classic" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">\n`;
|
|
388
|
+
svg += ` <path d="M 0 0 L 10 5 L 0 10 z" fill="${CascaisPalette.WarmGraphite}" />\n`;
|
|
389
|
+
svg += ` </marker>\n`;
|
|
390
|
+
svg += ` <marker id="arrow-hollow" viewBox="0 0 12 12" refX="12" refY="6" markerWidth="9" markerHeight="9" orient="auto-start-reverse">\n`;
|
|
391
|
+
svg += ` <polygon points="0 0, 12 6, 0 12" fill="${CascaisPalette.ChalkWhite}" stroke="${CascaisPalette.WarmGraphite}" stroke-width="1.5" />\n`;
|
|
392
|
+
svg += ` </marker>\n`;
|
|
393
|
+
if (options.embedStyles !== false) {
|
|
394
|
+
svg += ` <style>\n`;
|
|
395
|
+
svg += ` .aim-node { cursor: pointer; transition: filter 0.15s ease; }\n`;
|
|
396
|
+
svg += ` .aim-node:hover { filter: drop-shadow(0 4px 6px rgba(0,0,0,0.1)); }\n`;
|
|
397
|
+
svg += ` .aim-edge { fill: none; stroke: ${CascaisPalette.WarmGraphite}; stroke-width: 1.5; }\n`;
|
|
398
|
+
svg += ` text { font-family: Inter, system-ui, sans-serif; }\n`;
|
|
399
|
+
svg += ` </style>\n`;
|
|
400
|
+
}
|
|
401
|
+
svg += ` </defs>\n\n`;
|
|
402
|
+
|
|
403
|
+
// Render Edges
|
|
404
|
+
svg += ` <!-- Edges -->\n`;
|
|
405
|
+
svg += ` <g class="aim-edges-layer">\n`;
|
|
406
|
+
for (const edge of model.edges) {
|
|
407
|
+
const bendsFormatted = this.formatBendPoints(edge.bendPoints);
|
|
408
|
+
const strokeDash = edge.kind === 'dependency' ? ' stroke-dasharray="5,5"' : '';
|
|
409
|
+
const markerEnd = edge.kind === 'generalization' ? ' marker-end="url(#arrow-hollow)"' : ' marker-end="url(#arrow-classic)"';
|
|
410
|
+
|
|
411
|
+
// Path data construction
|
|
412
|
+
let pathD = '';
|
|
413
|
+
if (edge.bendPoints.length > 0) {
|
|
414
|
+
const first = edge.bendPoints[0]!;
|
|
415
|
+
pathD = `M ${first.x} ${first.y} ` + edge.bendPoints.slice(1).map((p) => `L ${p.x} ${p.y}`).join(' ');
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
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`;
|
|
419
|
+
if (pathD) {
|
|
420
|
+
svg += ` <path d="${pathD}" class="aim-edge"${strokeDash}${markerEnd} />\n`;
|
|
421
|
+
}
|
|
422
|
+
if (edge.label) {
|
|
423
|
+
const midPoint = edge.bendPoints[Math.floor(edge.bendPoints.length / 2)] ?? { x: 50, y: 50 };
|
|
424
|
+
svg += ` <text x="${midPoint.x}" y="${midPoint.y - 8}" font-size="11" fill="${CascaisPalette.TextSecondary}" text-anchor="middle">${edge.label}</text>\n`;
|
|
425
|
+
}
|
|
426
|
+
svg += ` </g>\n`;
|
|
427
|
+
}
|
|
428
|
+
svg += ` </g>\n\n`;
|
|
429
|
+
|
|
430
|
+
// Render Nodes
|
|
431
|
+
svg += ` <!-- Nodes -->\n`;
|
|
432
|
+
svg += ` <g class="aim-nodes-layer">\n`;
|
|
433
|
+
for (const node of model.nodes) {
|
|
434
|
+
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`;
|
|
435
|
+
|
|
436
|
+
if (node.kind === 'uc') {
|
|
437
|
+
const rx = node.bounds.width / 2;
|
|
438
|
+
const ry = node.bounds.height / 2;
|
|
439
|
+
svg += ` <ellipse cx="${rx}" cy="${ry}" rx="${rx}" ry="${ry}" fill="${CascaisPalette.ChalkWhite}" stroke="${CascaisPalette.NetGold}" stroke-width="2" />\n`;
|
|
440
|
+
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`;
|
|
441
|
+
} else if (node.kind === 'act') {
|
|
442
|
+
svg += ` <rect width="${node.bounds.width}" height="${node.bounds.height}" rx="12" ry="12" fill="${CascaisPalette.CanvasCream}" stroke="${CascaisPalette.HeraldicGreen}" stroke-width="2" />\n`;
|
|
443
|
+
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`;
|
|
444
|
+
} else {
|
|
445
|
+
svg += ` <rect width="${node.bounds.width}" height="${node.bounds.height}" fill="${CascaisPalette.ChalkWhite}" stroke="${CascaisPalette.WarmGraphite}" stroke-width="1.5" />\n`;
|
|
446
|
+
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`;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
svg += ` </g>\n`;
|
|
450
|
+
}
|
|
451
|
+
svg += ` </g>\n`;
|
|
452
|
+
svg += `</svg>\n`;
|
|
453
|
+
|
|
454
|
+
return svg;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
private normalizeKind(kind: string): AimOntologyKind {
|
|
458
|
+
const lower = kind.toLowerCase();
|
|
459
|
+
if (lower === 'uc' || lower === 'usecase') return 'uc';
|
|
460
|
+
if (lower === 'act' || lower === 'activity') return 'act';
|
|
461
|
+
if (lower === 'cls' || lower === 'class') return 'cls';
|
|
462
|
+
if (lower === 'obj' || lower === 'object') return 'obj';
|
|
463
|
+
if (lower === 'per' || lower === 'person' || lower === 'actor') return 'per';
|
|
464
|
+
return 'act';
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
private normalizeEdgeKind(kind: string): AimEdgeKind {
|
|
468
|
+
const lower = kind.toLowerCase();
|
|
469
|
+
if (lower.includes('depend')) return 'dependency';
|
|
470
|
+
if (lower.includes('general') || lower.includes('inher')) return 'generalization';
|
|
471
|
+
if (lower.includes('realiz')) return 'realization';
|
|
472
|
+
if (lower.includes('aggreg')) return 'aggregation';
|
|
473
|
+
if (lower.includes('compos')) return 'composition';
|
|
474
|
+
return 'association';
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
private kindFromShape(shape: string): AimOntologyKind {
|
|
478
|
+
if (shape.includes('uc')) return 'uc';
|
|
479
|
+
if (shape.includes('act')) return 'act';
|
|
480
|
+
if (shape.includes('cls')) return 'cls';
|
|
481
|
+
if (shape.includes('obj')) return 'obj';
|
|
482
|
+
if (shape.includes('per')) return 'per';
|
|
483
|
+
return 'act';
|
|
484
|
+
}
|
|
485
|
+
}
|