@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.
@@ -57,6 +57,8 @@ export interface RaidCanvasProps {
57
57
  onSelectionChange?: (selectedIds: string[]) => void;
58
58
  /** Callback fired when a stencil is dropped onto the canvas */
59
59
  onDropStencil?: (kind: AimOntologyKind, point: { x: number; y: number }) => void;
60
+ /** Callback fired when the active routing mode changes (e.g. hydrated from SVG or switched by user) */
61
+ onRoutingModeChange?: (mode: AimRoutingMode) => void;
60
62
  /** Whether to render built-in navigation controls (Zoom In/Out, Fit, Center, Reset). Default: true */
61
63
  showToolbar?: boolean;
62
64
  }
@@ -115,6 +117,7 @@ export const RaidCanvas = React.forwardRef<RaidCanvasHandle, RaidCanvasProps>(
115
117
  onSelect,
116
118
  onSelectionChange,
117
119
  onDropStencil,
120
+ onRoutingModeChange,
118
121
  showToolbar = true,
119
122
  },
120
123
  ref,
@@ -128,7 +131,6 @@ export const RaidCanvas = React.forwardRef<RaidCanvasHandle, RaidCanvasProps>(
128
131
  const selectedCellIdRef = useRef<string | null>(null);
129
132
  const clearEdgeToolsRef = useRef<(() => void) | null>(null);
130
133
  const routingModeRef = useRef<AimRoutingMode>(defaultRouting);
131
- routingModeRef.current = defaultRouting;
132
134
 
133
135
  const [zoomLevel, setZoomLevel] = useState<number>(100);
134
136
  const [isDragOver, setIsDragOver] = useState<boolean>(false);
@@ -152,6 +154,9 @@ export const RaidCanvas = React.forwardRef<RaidCanvasHandle, RaidCanvasProps>(
152
154
  const onDropStencilRef = useRef(onDropStencil);
153
155
  onDropStencilRef.current = onDropStencil;
154
156
 
157
+ const onRoutingModeChangeRef = useRef(onRoutingModeChange);
158
+ onRoutingModeChangeRef.current = onRoutingModeChange;
159
+
155
160
  const svgPropRef = useRef(activeSvg);
156
161
  svgPropRef.current = activeSvg;
157
162
 
@@ -169,6 +174,7 @@ export const RaidCanvas = React.forwardRef<RaidCanvasHandle, RaidCanvasProps>(
169
174
  const updatedSvg = bridgeRef.current.serializeToSvg(
170
175
  graphRef.current,
171
176
  currentBaseSvg,
177
+ { routingMode: routingModeRef.current },
172
178
  );
173
179
  lastSerializedSvgRef.current = updatedSvg;
174
180
 
@@ -227,6 +233,7 @@ export const RaidCanvas = React.forwardRef<RaidCanvasHandle, RaidCanvasProps>(
227
233
  return bridgeRef.current.serializeToSvg(
228
234
  graphRef.current,
229
235
  svgPropRef.current,
236
+ { routingMode: routingModeRef.current },
230
237
  );
231
238
  },
232
239
  addNode: (kind, x, y, customData) => {
@@ -310,8 +317,7 @@ export const RaidCanvas = React.forwardRef<RaidCanvasHandle, RaidCanvasProps>(
310
317
  if (!edge || !edge.isEdge()) return;
311
318
 
312
319
  const currentData = (edge.getData() ?? {}) as RaidEdgeData;
313
- const nextData = { ...currentData, ...updates };
314
- edge.setData(nextData);
320
+ const mutableData: Record<string, unknown> = { ...currentData, ...updates };
315
321
 
316
322
  if (updates.routing !== undefined) {
317
323
  applyEdgeRouting(edge, updates.routing);
@@ -320,17 +326,31 @@ export const RaidCanvas = React.forwardRef<RaidCanvasHandle, RaidCanvasProps>(
320
326
  if (updates.sourcePort !== undefined) {
321
327
  const currentSource = edge.getSource() as { cell?: string };
322
328
  if (currentSource.cell) {
323
- edge.setSource({ cell: currentSource.cell, port: updates.sourcePort });
329
+ if (updates.sourcePort === '' || updates.sourcePort === 'auto') {
330
+ edge.setSource({ cell: currentSource.cell });
331
+ delete mutableData.sourcePort;
332
+ } else {
333
+ edge.setSource({ cell: currentSource.cell, port: updates.sourcePort });
334
+ mutableData.sourcePort = updates.sourcePort;
335
+ }
324
336
  }
325
337
  }
326
338
 
327
339
  if (updates.targetPort !== undefined) {
328
340
  const currentTarget = edge.getTarget() as { cell?: string };
329
341
  if (currentTarget.cell) {
330
- edge.setTarget({ cell: currentTarget.cell, port: updates.targetPort });
342
+ if (updates.targetPort === '' || updates.targetPort === 'auto') {
343
+ edge.setTarget({ cell: currentTarget.cell });
344
+ delete mutableData.targetPort;
345
+ } else {
346
+ edge.setTarget({ cell: currentTarget.cell, port: updates.targetPort });
347
+ mutableData.targetPort = updates.targetPort;
348
+ }
331
349
  }
332
350
  }
333
351
 
352
+ edge.setData(mutableData as unknown as RaidEdgeData);
353
+
334
354
  if (updates.label !== undefined || updates.stereotype !== undefined) {
335
355
  const text = updates.label ?? updates.stereotype ?? '';
336
356
  edge.setLabels(
@@ -355,6 +375,10 @@ export const RaidCanvas = React.forwardRef<RaidCanvasHandle, RaidCanvasProps>(
355
375
  },
356
376
  setRoutingMode: (mode, applyToAll = true) => {
357
377
  routingModeRef.current = mode;
378
+ if (graphRef.current) {
379
+ (graphRef.current as unknown as { _aimRoutingMode?: AimRoutingMode })._aimRoutingMode = mode;
380
+ }
381
+ onRoutingModeChangeRef.current?.(mode);
358
382
  if (applyToAll && graphRef.current) {
359
383
  for (const edge of graphRef.current.getEdges()) {
360
384
  applyEdgeRouting(edge, mode);
@@ -723,8 +747,13 @@ export const RaidCanvas = React.forwardRef<RaidCanvasHandle, RaidCanvasProps>(
723
747
  if (svgPropRef.current) {
724
748
  isHydratingRef.current = true;
725
749
  try {
726
- bridgeRef.current.hydrateFromSvg(svgPropRef.current, graph);
750
+ const metamodel = bridgeRef.current.hydrateFromSvg(svgPropRef.current, graph, { inferPorts: false });
727
751
  lastSerializedSvgRef.current = svgPropRef.current;
752
+ if (metamodel.routing) {
753
+ routingModeRef.current = metamodel.routing;
754
+ (graph as unknown as { _aimRoutingMode?: AimRoutingMode })._aimRoutingMode = metamodel.routing;
755
+ onRoutingModeChangeRef.current?.(metamodel.routing);
756
+ }
728
757
  graph.centerContent();
729
758
  } catch (err) {
730
759
  console.error('RaidCanvas hydration error:', err);
@@ -760,8 +789,13 @@ export const RaidCanvas = React.forwardRef<RaidCanvasHandle, RaidCanvasProps>(
760
789
 
761
790
  isHydratingRef.current = true;
762
791
  try {
763
- bridgeRef.current.hydrateFromSvg(activeSvg, graph);
792
+ const metamodel = bridgeRef.current.hydrateFromSvg(activeSvg, graph, { inferPorts: false });
764
793
  lastSerializedSvgRef.current = activeSvg;
794
+ if (metamodel.routing) {
795
+ routingModeRef.current = metamodel.routing;
796
+ (graph as unknown as { _aimRoutingMode?: AimRoutingMode })._aimRoutingMode = metamodel.routing;
797
+ onRoutingModeChangeRef.current?.(metamodel.routing);
798
+ }
765
799
  graph.centerContent();
766
800
  } catch (err) {
767
801
  console.error('RaidCanvas re-hydration error:', err);
package/src/X6Shapes.ts CHANGED
@@ -37,6 +37,74 @@ export const CascaisPalette = {
37
37
  TextSecondary: '#4B5563',
38
38
  } as const;
39
39
 
40
+ /**
41
+ * Formats and wraps node label text for AOAIM entities.
42
+ * Automatically wraps on whitespace when exceeding target length,
43
+ * and treats `<wbr>` / `<wbr/>` tags and hyphens as soft word-break opportunities
44
+ * within long unbroken words or strings.
45
+ */
46
+ export function wrapAimText(rawText: string, maxLineLength: number = 18): string {
47
+ if (!rawText) return '';
48
+
49
+ const lines = rawText.split('\n');
50
+ const resultLines: string[] = [];
51
+
52
+ for (const line of lines) {
53
+ if (!line) {
54
+ resultLines.push('');
55
+ continue;
56
+ }
57
+
58
+ // Replace <wbr> / <wbr/> with zero-width break marker \u200B,
59
+ // and allow breaking after hyphens within words
60
+ const normalized = line
61
+ .replace(/<wbr\s*\/?>/gi, '\u200B')
62
+ .replace(/-(?=[a-zA-Z0-9])/g, '-\u200B');
63
+
64
+ const spaceWords = normalized.split(/\s+/).filter(Boolean);
65
+ if (spaceWords.length === 0) continue;
66
+
67
+ let currentLine = '';
68
+
69
+ for (let wordIdx = 0; wordIdx < spaceWords.length; wordIdx++) {
70
+ const spaceWord = spaceWords[wordIdx]!;
71
+ const chunks = spaceWord.split('\u200B').filter(Boolean);
72
+
73
+ for (let chunkIdx = 0; chunkIdx < chunks.length; chunkIdx++) {
74
+ const chunk = chunks[chunkIdx]!;
75
+ const isFirstChunkOfWord = chunkIdx === 0;
76
+
77
+ if (!currentLine) {
78
+ currentLine = chunk;
79
+ } else if (isFirstChunkOfWord) {
80
+ // Break or space before a new whitespace-separated word
81
+ if (currentLine.length + 1 + chunk.length <= maxLineLength) {
82
+ currentLine += ' ' + chunk;
83
+ } else {
84
+ resultLines.push(currentLine);
85
+ currentLine = chunk;
86
+ }
87
+ } else {
88
+ // Soft-break opportunity within a word (<wbr> or hyphen):
89
+ // Glues together without space if it fits; breaks without space if it overflows
90
+ if (currentLine.length + chunk.length <= maxLineLength) {
91
+ currentLine += chunk;
92
+ } else {
93
+ resultLines.push(currentLine);
94
+ currentLine = chunk;
95
+ }
96
+ }
97
+ }
98
+ }
99
+
100
+ if (currentLine) {
101
+ resultLines.push(currentLine);
102
+ }
103
+ }
104
+
105
+ return resultLines.join('\n');
106
+ }
107
+
40
108
  /**
41
109
  * Port configuration generating 4 orthogonal snap anchors.
42
110
  */
@@ -133,7 +201,7 @@ export function registerAimShapes(): void {
133
201
  ports: createOrthogonalPorts(),
134
202
  });
135
203
 
136
- // 2. AimActivityNode ('act') — Rounded rectangle with Heraldic Green border
204
+ // 2. AimActivityNode ('act') — Rounded rectangle with Heraldic Green border & underlined label
137
205
  Shape.Rect.define({
138
206
  shape: 'aim-act',
139
207
  overwrite: true,
@@ -156,6 +224,11 @@ export function registerAimShapes(): void {
156
224
  fontFamily: 'Inter, system-ui, -apple-system, sans-serif',
157
225
  textAnchor: 'middle',
158
226
  textVerticalAnchor: 'middle',
227
+ textDecoration: 'underline',
228
+ textWrap: {
229
+ width: -16,
230
+ breakWord: true,
231
+ },
159
232
  },
160
233
  },
161
234
  ports: createOrthogonalPorts(),
@@ -278,34 +351,77 @@ export function registerAimShapes(): void {
278
351
  fontFamily: 'Inter, system-ui, -apple-system, sans-serif',
279
352
  textAnchor: 'middle',
280
353
  textVerticalAnchor: 'middle',
354
+ textDecoration: 'underline',
355
+ textWrap: {
356
+ width: -16,
357
+ breakWord: true,
358
+ },
281
359
  },
282
360
  },
283
361
  ports: createOrthogonalPorts(),
284
362
  });
285
363
 
286
- // 5. AimPersonNode ('per') — Person / Actor role card
364
+ // 5. AimPersonNode ('per') — Person / Actor glyph
287
365
  Shape.Rect.define({
288
366
  shape: 'aim-per',
289
367
  overwrite: true,
290
- width: 120,
291
- height: 70,
368
+ width: 90,
369
+ height: 90,
370
+ markup: [
371
+ {
372
+ tagName: 'rect',
373
+ selector: 'body',
374
+ },
375
+ {
376
+ tagName: 'path',
377
+ selector: 'torso',
378
+ },
379
+ {
380
+ tagName: 'circle',
381
+ selector: 'head',
382
+ },
383
+ {
384
+ tagName: 'text',
385
+ selector: 'label',
386
+ },
387
+ ],
292
388
  attrs: {
293
389
  body: {
294
- fill: CascaisPalette.CanvasCream,
295
- stroke: CascaisPalette.WarmGraphite,
296
- strokeWidth: 1.5,
297
- rx: 6,
298
- ry: 6,
390
+ fill: 'transparent',
391
+ stroke: 'transparent',
392
+ strokeWidth: 0,
299
393
  class: 'aim-node aim-per',
300
394
  },
395
+ torso: {
396
+ 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',
397
+ fill: 'none',
398
+ stroke: CascaisPalette.WarmGraphite,
399
+ strokeWidth: 2,
400
+ strokeLinecap: 'round',
401
+ strokeLinejoin: 'round',
402
+ },
403
+ head: {
404
+ cx: 45,
405
+ cy: 22,
406
+ r: 8,
407
+ fill: CascaisPalette.ChalkWhite,
408
+ stroke: CascaisPalette.WarmGraphite,
409
+ strokeWidth: 2,
410
+ },
301
411
  label: {
302
- text: '«actor»\nOperator',
412
+ text: 'Actor',
303
413
  fill: CascaisPalette.TextPrimary,
304
414
  fontSize: 12,
305
415
  fontWeight: '500',
306
416
  fontFamily: 'Inter, system-ui, -apple-system, sans-serif',
307
417
  textAnchor: 'middle',
308
- textVerticalAnchor: 'middle',
418
+ textVerticalAnchor: 'top',
419
+ refX: 0.5,
420
+ refY: 62,
421
+ textWrap: {
422
+ width: -10,
423
+ breakWord: true,
424
+ },
309
425
  },
310
426
  },
311
427
  ports: createOrthogonalPorts(),
@@ -391,25 +507,30 @@ export function createAimNode(data: RaidNodeData): Node.Metadata {
391
507
 
392
508
  // Archetype-specific customization
393
509
  switch (data.kind) {
394
- case 'uc':
510
+ case 'uc': {
511
+ const wrappedName = wrapAimText(data.displayName);
395
512
  return {
396
513
  ...baseMetadata,
397
514
  attrs: {
398
515
  label: {
399
- text: data.stereotype ? `${data.stereotype}\n${data.displayName}` : data.displayName,
516
+ text: data.stereotype ? `${data.stereotype}\n${wrappedName}` : wrappedName,
400
517
  },
401
518
  },
402
519
  };
520
+ }
403
521
 
404
- case 'act':
522
+ case 'act': {
523
+ const wrappedName = wrapAimText(data.displayName);
405
524
  return {
406
525
  ...baseMetadata,
407
526
  attrs: {
408
527
  label: {
409
- text: data.stereotype ? `${data.stereotype}\n${data.displayName}` : data.displayName,
528
+ text: data.stereotype ? `${data.stereotype}\n${wrappedName}` : wrappedName,
529
+ textDecoration: 'underline',
410
530
  },
411
531
  },
412
532
  };
533
+ }
413
534
 
414
535
  case 'cls':
415
536
  return {
@@ -427,27 +548,35 @@ export function createAimNode(data: RaidNodeData): Node.Metadata {
427
548
  },
428
549
  };
429
550
 
430
- case 'obj':
551
+ case 'obj': {
552
+ const wrappedName = wrapAimText(data.displayName);
431
553
  return {
432
554
  ...baseMetadata,
433
555
  attrs: {
434
556
  label: {
435
- text: data.displayName,
557
+ text: data.stereotype ? `${data.stereotype}\n${wrappedName}` : wrappedName,
558
+ textDecoration: 'underline',
436
559
  },
437
560
  },
438
561
  };
562
+ }
439
563
 
440
564
  case 'per': {
441
565
  const isInitiating = data.stereotype?.toLowerCase().includes('initiates') ?? false;
566
+ const strokeColor = isInitiating ? CascaisPalette.NetGold : CascaisPalette.WarmGraphite;
567
+ const wrappedName = wrapAimText(data.displayName, 14);
568
+ const text = data.stereotype ? `${data.stereotype}\n${wrappedName}` : wrappedName;
442
569
  return {
443
570
  ...baseMetadata,
444
571
  attrs: {
445
- body: {
446
- stroke: isInitiating ? CascaisPalette.NetGold : CascaisPalette.WarmGraphite,
447
- strokeWidth: isInitiating ? 2 : 1.5,
572
+ torso: {
573
+ stroke: strokeColor,
574
+ },
575
+ head: {
576
+ stroke: strokeColor,
448
577
  },
449
578
  label: {
450
- text: data.stereotype ? `${data.stereotype}\n${data.displayName}` : data.displayName,
579
+ text,
451
580
  },
452
581
  },
453
582
  };
@@ -648,7 +777,7 @@ export function getDefaultNodeBounds(
648
777
  case 'obj':
649
778
  return { x, y, width: 160, height: 80 };
650
779
  case 'per':
651
- return { x, y, width: 120, height: 70 };
780
+ return { x, y, width: 90, height: 90 };
652
781
  default:
653
782
  return { x, y, width: 140, height: 60 };
654
783
  }
package/src/index.ts CHANGED
@@ -39,6 +39,7 @@ export {
39
39
  applyEdgeRouting,
40
40
  getDefaultNodeBounds,
41
41
  getDefaultNodeName,
42
+ wrapAimText,
42
43
  } from './X6Shapes.js';
43
44
 
44
45
  // Anti-Entropy Semantic Connection Rules
@@ -83,6 +83,12 @@
83
83
  ry: var(--aim-radius-act);
84
84
  }
85
85
 
86
+ .aim-node[aim-act] text,
87
+ .aim-node[aim-kind="act"] text,
88
+ .aim-act text {
89
+ text-decoration: underline;
90
+ }
91
+
86
92
  .aim-node[aim-act]:hover,
87
93
  .aim-node[aim-kind="act"]:hover {
88
94
  filter: drop-shadow(0 0 6px rgba(16, 185, 129, 0.4));
@@ -115,20 +121,28 @@
115
121
  stroke-width: 1.5px;
116
122
  }
117
123
 
124
+ .aim-node[aim-obj] text,
125
+ .aim-node[aim-kind="obj"] text,
126
+ .aim-obj text {
127
+ text-decoration: underline;
128
+ }
129
+
118
130
  /* 5. Person / Actor Nodes ('per') */
119
131
  .aim-node[aim-per],
120
132
  .aim-node[aim-kind="per"],
121
133
  .aim-per {
122
- fill: var(--aim-canvas-cream);
123
- stroke: var(--aim-warm-graphite);
124
- stroke-width: 1.5px;
125
- rx: var(--aim-radius-card);
126
- ry: var(--aim-radius-card);
134
+ fill: transparent;
135
+ stroke: none;
127
136
  }
128
137
 
129
- .aim-node[aim-per][aim-role="initiating"] {
138
+ .aim-node[aim-per] path,
139
+ .aim-node[aim-per] circle {
140
+ transition: stroke 0.15s ease;
141
+ }
142
+
143
+ .aim-node[aim-per][aim-role="initiating"] path,
144
+ .aim-node[aim-per][aim-role="initiating"] circle {
130
145
  stroke: var(--aim-net-gold);
131
- stroke-width: 2px;
132
146
  }
133
147
 
134
148
  /* Relationship Edges */
package/src/types.ts CHANGED
@@ -139,6 +139,9 @@ export interface RaidEdgeData {
139
139
 
140
140
  /** User-editable or router-computed Manhattan bend points. */
141
141
  readonly bendPoints: readonly SvgBendPoint[];
142
+
143
+ /** Precomputed or live SVG path data ('M ... L ...') for standalone vector rendering. */
144
+ readonly pathData?: string;
142
145
  }
143
146
 
144
147
  /**
@@ -160,6 +163,9 @@ export interface RaidMetamodel {
160
163
  /** Relationships connecting projected nodes. */
161
164
  readonly edges: readonly RaidEdgeData[];
162
165
 
166
+ /** Diagram-level edge routing mode ('manhattan', 'normal', 'smooth'). */
167
+ readonly routing?: AimRoutingMode;
168
+
163
169
  /** Additional diagram-level metadata. */
164
170
  readonly metadata?: Readonly<Record<string, unknown>>;
165
171
  }
@@ -201,6 +207,13 @@ export interface HydrationOptions {
201
207
  /** If true, automatically executes Manhattan routing if bend points are missing. Default: true. */
202
208
  readonly autoRouteEdges?: boolean;
203
209
 
210
+ /**
211
+ * If true, infers docking ports (e.g. port-right, port-left) based on node geometry when ports
212
+ * are omitted in the SVG. When false (default), edges bind directly to node cells without ports,
213
+ * enabling dynamic Manhattan center-aiming routing. Default: false.
214
+ */
215
+ readonly inferPorts?: boolean;
216
+
204
217
  /** Default fallback dimensions when width/height are unspecified in SVG. */
205
218
  readonly defaultNodeSize?: { readonly width: number; readonly height: number };
206
219
  }
@@ -217,4 +230,7 @@ export interface SerializationOptions {
217
230
 
218
231
  /** Inject Cascais design token CSS variables into `<defs><style>`. Default: true. */
219
232
  readonly embedStyles?: boolean;
233
+
234
+ /** Optional diagram-level routing mode to serialize onto root `<svg>` tag. */
235
+ readonly routingMode?: AimRoutingMode;
220
236
  }