@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/X6Shapes.ts CHANGED
@@ -13,6 +13,7 @@ import { Graph, Shape, Node, Edge } from '@antv/x6';
13
13
  import type {
14
14
  AimOntologyKind,
15
15
  AimEdgeKind,
16
+ AimRoutingMode,
16
17
  RaidNodeData,
17
18
  RaidEdgeData,
18
19
  OrthogonalPortId,
@@ -36,6 +37,74 @@ export const CascaisPalette = {
36
37
  TextSecondary: '#4B5563',
37
38
  } as const;
38
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
+
39
108
  /**
40
109
  * Port configuration generating 4 orthogonal snap anchors.
41
110
  */
@@ -132,7 +201,7 @@ export function registerAimShapes(): void {
132
201
  ports: createOrthogonalPorts(),
133
202
  });
134
203
 
135
- // 2. AimActivityNode ('act') — Rounded rectangle with Heraldic Green border
204
+ // 2. AimActivityNode ('act') — Rounded rectangle with Heraldic Green border & underlined label
136
205
  Shape.Rect.define({
137
206
  shape: 'aim-act',
138
207
  overwrite: true,
@@ -155,6 +224,11 @@ export function registerAimShapes(): void {
155
224
  fontFamily: 'Inter, system-ui, -apple-system, sans-serif',
156
225
  textAnchor: 'middle',
157
226
  textVerticalAnchor: 'middle',
227
+ textDecoration: 'underline',
228
+ textWrap: {
229
+ width: -16,
230
+ breakWord: true,
231
+ },
158
232
  },
159
233
  },
160
234
  ports: createOrthogonalPorts(),
@@ -277,34 +351,77 @@ export function registerAimShapes(): void {
277
351
  fontFamily: 'Inter, system-ui, -apple-system, sans-serif',
278
352
  textAnchor: 'middle',
279
353
  textVerticalAnchor: 'middle',
354
+ textDecoration: 'underline',
355
+ textWrap: {
356
+ width: -16,
357
+ breakWord: true,
358
+ },
280
359
  },
281
360
  },
282
361
  ports: createOrthogonalPorts(),
283
362
  });
284
363
 
285
- // 5. AimPersonNode ('per') — Person / Actor role card
364
+ // 5. AimPersonNode ('per') — Person / Actor glyph
286
365
  Shape.Rect.define({
287
366
  shape: 'aim-per',
288
367
  overwrite: true,
289
- width: 120,
290
- 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
+ ],
291
388
  attrs: {
292
389
  body: {
293
- fill: CascaisPalette.CanvasCream,
294
- stroke: CascaisPalette.WarmGraphite,
295
- strokeWidth: 1.5,
296
- rx: 6,
297
- ry: 6,
390
+ fill: 'transparent',
391
+ stroke: 'transparent',
392
+ strokeWidth: 0,
298
393
  class: 'aim-node aim-per',
299
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
+ },
300
411
  label: {
301
- text: '«actor»\nOperator',
412
+ text: 'Actor',
302
413
  fill: CascaisPalette.TextPrimary,
303
414
  fontSize: 12,
304
415
  fontWeight: '500',
305
416
  fontFamily: 'Inter, system-ui, -apple-system, sans-serif',
306
417
  textAnchor: 'middle',
307
- textVerticalAnchor: 'middle',
418
+ textVerticalAnchor: 'top',
419
+ refX: 0.5,
420
+ refY: 62,
421
+ textWrap: {
422
+ width: -10,
423
+ breakWord: true,
424
+ },
308
425
  },
309
426
  },
310
427
  ports: createOrthogonalPorts(),
@@ -390,25 +507,30 @@ export function createAimNode(data: RaidNodeData): Node.Metadata {
390
507
 
391
508
  // Archetype-specific customization
392
509
  switch (data.kind) {
393
- case 'uc':
510
+ case 'uc': {
511
+ const wrappedName = wrapAimText(data.displayName);
394
512
  return {
395
513
  ...baseMetadata,
396
514
  attrs: {
397
515
  label: {
398
- text: data.stereotype ? `${data.stereotype}\n${data.displayName}` : data.displayName,
516
+ text: data.stereotype ? `${data.stereotype}\n${wrappedName}` : wrappedName,
399
517
  },
400
518
  },
401
519
  };
520
+ }
402
521
 
403
- case 'act':
522
+ case 'act': {
523
+ const wrappedName = wrapAimText(data.displayName);
404
524
  return {
405
525
  ...baseMetadata,
406
526
  attrs: {
407
527
  label: {
408
- text: data.stereotype ? `${data.stereotype}\n${data.displayName}` : data.displayName,
528
+ text: data.stereotype ? `${data.stereotype}\n${wrappedName}` : wrappedName,
529
+ textDecoration: 'underline',
409
530
  },
410
531
  },
411
532
  };
533
+ }
412
534
 
413
535
  case 'cls':
414
536
  return {
@@ -426,27 +548,35 @@ export function createAimNode(data: RaidNodeData): Node.Metadata {
426
548
  },
427
549
  };
428
550
 
429
- case 'obj':
551
+ case 'obj': {
552
+ const wrappedName = wrapAimText(data.displayName);
430
553
  return {
431
554
  ...baseMetadata,
432
555
  attrs: {
433
556
  label: {
434
- text: data.displayName,
557
+ text: data.stereotype ? `${data.stereotype}\n${wrappedName}` : wrappedName,
558
+ textDecoration: 'underline',
435
559
  },
436
560
  },
437
561
  };
562
+ }
438
563
 
439
564
  case 'per': {
440
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;
441
569
  return {
442
570
  ...baseMetadata,
443
571
  attrs: {
444
- body: {
445
- stroke: isInitiating ? CascaisPalette.NetGold : CascaisPalette.WarmGraphite,
446
- strokeWidth: isInitiating ? 2 : 1.5,
572
+ torso: {
573
+ stroke: strokeColor,
574
+ },
575
+ head: {
576
+ stroke: strokeColor,
447
577
  },
448
578
  label: {
449
- text: data.stereotype ? `${data.stereotype}\n${data.displayName}` : data.displayName,
579
+ text,
450
580
  },
451
581
  },
452
582
  };
@@ -457,6 +587,34 @@ export function createAimNode(data: RaidNodeData): Node.Metadata {
457
587
  }
458
588
  }
459
589
 
590
+ /**
591
+ * Configures the router and connector for an X6 Edge based on AimRoutingMode.
592
+ * - 'manhattan': Obstacle-avoiding 90° orthogonal router with rounded corners (radius: 8).
593
+ * - 'normal': Direct straight line point-to-point connection.
594
+ * - 'smooth': Curved cubic bezier spline between ports.
595
+ */
596
+ export function applyEdgeRouting(edge: Edge, routing: AimRoutingMode = 'manhattan'): void {
597
+ switch (routing) {
598
+ case 'normal':
599
+ edge.setRouter('normal');
600
+ edge.setConnector('normal');
601
+ break;
602
+ case 'smooth':
603
+ edge.setRouter('normal');
604
+ edge.setConnector('smooth');
605
+ break;
606
+ case 'manhattan':
607
+ default:
608
+ edge.setRouter('manhattan', {
609
+ padding: 20,
610
+ startDirections: ['top', 'right', 'bottom', 'left'],
611
+ endDirections: ['top', 'right', 'bottom', 'left'],
612
+ });
613
+ edge.setConnector('rounded', { radius: 8 });
614
+ break;
615
+ }
616
+ }
617
+
460
618
  /**
461
619
  * Factory creating an AntV X6 Edge model from a RaidEdgeData specification.
462
620
  */
@@ -464,10 +622,34 @@ export function createAimEdge(data: RaidEdgeData): Edge.Metadata {
464
622
  registerAimShapes();
465
623
 
466
624
  const edgeAttrs = getEdgeStyling(data.kind);
625
+ const routing = data.routing ?? 'manhattan';
626
+
627
+ let routerConfig: Edge.Metadata['router'] = {
628
+ name: 'manhattan',
629
+ args: {
630
+ padding: 20,
631
+ startDirections: ['top', 'right', 'bottom', 'left'],
632
+ endDirections: ['top', 'right', 'bottom', 'left'],
633
+ },
634
+ };
635
+ let connectorConfig: Edge.Metadata['connector'] = {
636
+ name: 'rounded',
637
+ args: { radius: 8 },
638
+ };
639
+
640
+ if (routing === 'normal') {
641
+ routerConfig = { name: 'normal' };
642
+ connectorConfig = { name: 'normal' };
643
+ } else if (routing === 'smooth') {
644
+ routerConfig = { name: 'normal' };
645
+ connectorConfig = { name: 'smooth' };
646
+ }
467
647
 
468
648
  return {
469
649
  id: data.id,
470
650
  shape: 'aim-edge',
651
+ router: routerConfig,
652
+ connector: connectorConfig,
471
653
  source: {
472
654
  cell: data.sourceId,
473
655
  ...(data.sourcePort !== undefined ? { port: data.sourcePort } : {}),
@@ -595,7 +777,7 @@ export function getDefaultNodeBounds(
595
777
  case 'obj':
596
778
  return { x, y, width: 160, height: 80 };
597
779
  case 'per':
598
- return { x, y, width: 120, height: 70 };
780
+ return { x, y, width: 90, height: 90 };
599
781
  default:
600
782
  return { x, y, width: 140, height: 60 };
601
783
  }
package/src/index.ts CHANGED
@@ -15,6 +15,7 @@
15
15
  export {
16
16
  AimSvgContract,
17
17
  type AimOntologyKind,
18
+ type AimRoutingMode,
18
19
  type AimEdgeKind,
19
20
  type Point,
20
21
  type SvgBendPoint,
@@ -35,8 +36,10 @@ export {
35
36
  configureAimGraph,
36
37
  createAimNode,
37
38
  createAimEdge,
39
+ applyEdgeRouting,
38
40
  getDefaultNodeBounds,
39
41
  getDefaultNodeName,
42
+ wrapAimText,
40
43
  } from './X6Shapes.js';
41
44
 
42
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
@@ -16,7 +16,15 @@
16
16
  * - 'obj' : Object / Instance (runtime instance card with underlined title)
17
17
  * - 'per' : Person / Actor (Initiating or Defined role stick-figure/card)
18
18
  */
19
- export type AimOntologyKind = 'uc' | 'act' | 'cls' | 'obj' | 'per';
19
+ export type AimOntologyKind = 'act' | 'uc' | 'cls' | 'obj' | 'per';
20
+
21
+ /**
22
+ * Routing strategy for diagram edges.
23
+ * - 'manhattan': Obstacle-avoiding 90° orthogonal routing with rounded corners (default).
24
+ * - 'normal': Direct straight line point-to-point connection.
25
+ * - 'smooth': Curved cubic bezier spline between ports.
26
+ */
27
+ export type AimRoutingMode = 'manhattan' | 'normal' | 'smooth';
20
28
 
21
29
  /**
22
30
  * Ontological relationship classifications in AOAIM.
@@ -126,8 +134,14 @@ export interface RaidEdgeData {
126
134
  /** Multiplicity / Cardinality at the target end (e.g., '0..1', '*'). */
127
135
  readonly targetCardinality?: string;
128
136
 
137
+ /** Routing strategy for this edge ('manhattan', 'normal', 'smooth'). */
138
+ readonly routing?: AimRoutingMode;
139
+
129
140
  /** User-editable or router-computed Manhattan bend points. */
130
141
  readonly bendPoints: readonly SvgBendPoint[];
142
+
143
+ /** Precomputed or live SVG path data ('M ... L ...') for standalone vector rendering. */
144
+ readonly pathData?: string;
131
145
  }
132
146
 
133
147
  /**
@@ -149,6 +163,9 @@ export interface RaidMetamodel {
149
163
  /** Relationships connecting projected nodes. */
150
164
  readonly edges: readonly RaidEdgeData[];
151
165
 
166
+ /** Diagram-level edge routing mode ('manhattan', 'normal', 'smooth'). */
167
+ readonly routing?: AimRoutingMode;
168
+
152
169
  /** Additional diagram-level metadata. */
153
170
  readonly metadata?: Readonly<Record<string, unknown>>;
154
171
  }
@@ -172,6 +189,7 @@ export const AimSvgContract = {
172
189
  ATTR_TARGET: 'aim-target',
173
190
  ATTR_SOURCE_PORT: 'aim-source-port',
174
191
  ATTR_TARGET_PORT: 'aim-target-port',
192
+ ATTR_ROUTING: 'aim-routing',
175
193
  ATTR_BENDS: 'aim-bends',
176
194
 
177
195
  // Selectors for DOM queries
@@ -189,6 +207,13 @@ export interface HydrationOptions {
189
207
  /** If true, automatically executes Manhattan routing if bend points are missing. Default: true. */
190
208
  readonly autoRouteEdges?: boolean;
191
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
+
192
217
  /** Default fallback dimensions when width/height are unspecified in SVG. */
193
218
  readonly defaultNodeSize?: { readonly width: number; readonly height: number };
194
219
  }
@@ -205,4 +230,7 @@ export interface SerializationOptions {
205
230
 
206
231
  /** Inject Cascais design token CSS variables into `<defs><style>`. Default: true. */
207
232
  readonly embedStyles?: boolean;
233
+
234
+ /** Optional diagram-level routing mode to serialize onto root `<svg>` tag. */
235
+ readonly routingMode?: AimRoutingMode;
208
236
  }