@dr2rai/raid-canvas 0.2.0 → 0.3.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.
@@ -16,12 +16,13 @@ import React, {
16
16
  useCallback,
17
17
  useImperativeHandle,
18
18
  } from 'react';
19
- import { Graph, Shape } from '@antv/x6';
19
+ import { Graph, Shape, Edge } from '@antv/x6';
20
20
  import { RaiBridge } from './RaiBridge.js';
21
21
  import {
22
22
  registerAimShapes,
23
23
  configureAimGraph,
24
24
  createAimNode,
25
+ applyEdgeRouting,
25
26
  CascaisPalette,
26
27
  getDefaultNodeBounds,
27
28
  getDefaultNodeName,
@@ -31,7 +32,7 @@ import {
31
32
  getSemanticEdgeKind,
32
33
  getSemanticEdgeStereotype,
33
34
  } from './semanticRules.js';
34
- import type { AimOntologyKind, RaidNodeData, RaidEdgeData } from './types.js';
35
+ import type { AimOntologyKind, AimRoutingMode, RaidNodeData, RaidEdgeData } from './types.js';
35
36
 
36
37
  export interface RaidCanvasProps {
37
38
  /** The raw SVG string carrying aim-* ontological attributes (preferred) */
@@ -40,6 +41,8 @@ export interface RaidCanvasProps {
40
41
  svgContent?: string;
41
42
  /** Whether the canvas allows dragging and editing, or behaves as a pan/zoom viewer */
42
43
  readOnly?: boolean;
44
+ /** Default routing mode for edges ('manhattan', 'normal', 'smooth'). Default: 'manhattan' */
45
+ defaultRouting?: AimRoutingMode;
43
46
  /** Optional CSS class name for the outer wrapper */
44
47
  className?: string;
45
48
  /** Optional inline styles for outer wrapper */
@@ -72,8 +75,12 @@ export interface RaidCanvasHandle {
72
75
  ) => string;
73
76
  /** Update properties of an existing node (label, stereotype, dimensions, etc.) */
74
77
  updateNode: (id: string, updates: Partial<RaidNodeData>) => void;
75
- /** Update properties of an existing edge (kind, label, stereotype) */
78
+ /** Update properties of an existing edge (kind, label, stereotype, routing, ports) */
76
79
  updateEdge: (id: string, updates: Partial<RaidEdgeData>) => void;
80
+ /** Set routing mode across the canvas or for new edges */
81
+ setRoutingMode: (mode: AimRoutingMode, applyToAllEdges?: boolean) => void;
82
+ /** Get the current active canvas routing mode */
83
+ getRoutingMode: () => AimRoutingMode;
77
84
  /** Delete currently selected cell(s) */
78
85
  deleteSelection: () => void;
79
86
  /** Clear all cells on the canvas */
@@ -100,6 +107,7 @@ export const RaidCanvas = React.forwardRef<RaidCanvasHandle, RaidCanvasProps>(
100
107
  svg,
101
108
  svgContent,
102
109
  readOnly = false,
110
+ defaultRouting = 'manhattan',
103
111
  className,
104
112
  style,
105
113
  onChange,
@@ -118,6 +126,9 @@ export const RaidCanvas = React.forwardRef<RaidCanvasHandle, RaidCanvasProps>(
118
126
  const isHydratingRef = useRef<boolean>(false);
119
127
  const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
120
128
  const selectedCellIdRef = useRef<string | null>(null);
129
+ const clearEdgeToolsRef = useRef<(() => void) | null>(null);
130
+ const routingModeRef = useRef<AimRoutingMode>(defaultRouting);
131
+ routingModeRef.current = defaultRouting;
121
132
 
122
133
  const [zoomLevel, setZoomLevel] = useState<number>(100);
123
134
  const [isDragOver, setIsDragOver] = useState<boolean>(false);
@@ -302,6 +313,24 @@ export const RaidCanvas = React.forwardRef<RaidCanvasHandle, RaidCanvasProps>(
302
313
  const nextData = { ...currentData, ...updates };
303
314
  edge.setData(nextData);
304
315
 
316
+ if (updates.routing !== undefined) {
317
+ applyEdgeRouting(edge, updates.routing);
318
+ }
319
+
320
+ if (updates.sourcePort !== undefined) {
321
+ const currentSource = edge.getSource() as { cell?: string };
322
+ if (currentSource.cell) {
323
+ edge.setSource({ cell: currentSource.cell, port: updates.sourcePort });
324
+ }
325
+ }
326
+
327
+ if (updates.targetPort !== undefined) {
328
+ const currentTarget = edge.getTarget() as { cell?: string };
329
+ if (currentTarget.cell) {
330
+ edge.setTarget({ cell: currentTarget.cell, port: updates.targetPort });
331
+ }
332
+ }
333
+
305
334
  if (updates.label !== undefined || updates.stereotype !== undefined) {
306
335
  const text = updates.label ?? updates.stereotype ?? '';
307
336
  edge.setLabels(
@@ -324,12 +353,25 @@ export const RaidCanvas = React.forwardRef<RaidCanvasHandle, RaidCanvasProps>(
324
353
 
325
354
  triggerDebouncedChange();
326
355
  },
356
+ setRoutingMode: (mode, applyToAll = true) => {
357
+ routingModeRef.current = mode;
358
+ if (applyToAll && graphRef.current) {
359
+ for (const edge of graphRef.current.getEdges()) {
360
+ applyEdgeRouting(edge, mode);
361
+ const currentData = (edge.getData() ?? {}) as RaidEdgeData;
362
+ edge.setData({ ...currentData, routing: mode });
363
+ }
364
+ triggerDebouncedChange();
365
+ }
366
+ },
367
+ getRoutingMode: () => routingModeRef.current,
327
368
  deleteSelection: () => {
328
369
  const graph = graphRef.current;
329
370
  if (!graph || readOnly) return;
330
371
  if (selectedCellIdRef.current) {
331
372
  const cell = graph.getCellById(selectedCellIdRef.current);
332
373
  if (cell) {
374
+ clearEdgeToolsRef.current?.();
333
375
  graph.removeCell(cell);
334
376
  selectedCellIdRef.current = null;
335
377
  onSelectRef.current?.(null);
@@ -455,9 +497,11 @@ export const RaidCanvas = React.forwardRef<RaidCanvasHandle, RaidCanvasProps>(
455
497
  return validateSemanticConnection(sourceKind, targetKind);
456
498
  },
457
499
  createEdge() {
458
- return new Shape.Edge({
500
+ const edge = new Shape.Edge({
459
501
  shape: 'aim-edge',
460
502
  });
503
+ applyEdgeRouting(edge, routingModeRef.current);
504
+ return edge;
461
505
  },
462
506
  },
463
507
  });
@@ -465,9 +509,66 @@ export const RaidCanvas = React.forwardRef<RaidCanvasHandle, RaidCanvasProps>(
465
509
  configureAimGraph(graph);
466
510
  graphRef.current = graph;
467
511
 
468
- // Handle new edge connections with automatic semantic wiring
512
+ let activeToolEdge: Edge | null = null;
513
+
514
+ const clearEdgeTools = () => {
515
+ if (activeToolEdge) {
516
+ try {
517
+ activeToolEdge.removeTools();
518
+ } catch {
519
+ // Ignore if cell was already removed
520
+ }
521
+ activeToolEdge = null;
522
+ }
523
+ };
524
+ clearEdgeToolsRef.current = clearEdgeTools;
525
+
526
+ const setEdgeTools = (edge: Edge) => {
527
+ clearEdgeTools();
528
+ if (readOnly) return;
529
+ activeToolEdge = edge;
530
+ try {
531
+ edge.addTools([
532
+ {
533
+ name: 'source-arrowhead',
534
+ args: {
535
+ attrs: {
536
+ fill: CascaisPalette.NetGold,
537
+ stroke: '#FFFFFF',
538
+ strokeWidth: 2,
539
+ cursor: 'grab',
540
+ },
541
+ },
542
+ },
543
+ {
544
+ name: 'target-arrowhead',
545
+ args: {
546
+ attrs: {
547
+ fill: CascaisPalette.NetGold,
548
+ stroke: '#FFFFFF',
549
+ strokeWidth: 2,
550
+ cursor: 'grab',
551
+ },
552
+ },
553
+ },
554
+ {
555
+ name: 'vertices',
556
+ args: {
557
+ attrs: {
558
+ fill: CascaisPalette.WarmGraphite,
559
+ stroke: '#FFFFFF',
560
+ strokeWidth: 1.5,
561
+ },
562
+ },
563
+ },
564
+ ]);
565
+ } catch {
566
+ // Fallback if tools fail to attach
567
+ }
568
+ };
569
+
570
+ // Handle edge connections and re-connections with automatic semantic wiring
469
571
  graph.on('edge:connected', ({ edge, isNew }) => {
470
- if (!isNew) return;
471
572
  const sourceCell = edge.getSourceCell();
472
573
  const targetCell = edge.getTargetCell();
473
574
  if (sourceCell?.isNode() && targetCell?.isNode()) {
@@ -479,33 +580,44 @@ export const RaidCanvas = React.forwardRef<RaidCanvasHandle, RaidCanvasProps>(
479
580
  ) as AimOntologyKind;
480
581
 
481
582
  const edgeKind = getSemanticEdgeKind(sourceKind, targetKind);
482
- const stereotype = getSemanticEdgeStereotype(sourceKind, targetKind);
583
+ const currentData = (edge.getData() ?? {}) as Partial<RaidEdgeData>;
584
+ const stereotype = currentData.stereotype ?? getSemanticEdgeStereotype(sourceKind, targetKind);
483
585
 
484
586
  edge.setData({
587
+ ...currentData,
485
588
  id: edge.id,
486
589
  kind: edgeKind,
487
590
  sourceId: sourceCell.id,
488
591
  targetId: targetCell.id,
489
592
  sourcePort: edge.getSourcePortId(),
490
593
  targetPort: edge.getTargetPortId(),
491
- label: stereotype,
594
+ label: currentData.label ?? stereotype,
492
595
  stereotype,
493
- bendPoints: [],
596
+ routing: currentData.routing ?? routingModeRef.current,
597
+ bendPoints: currentData.bendPoints ?? [],
494
598
  });
495
599
 
496
- if (stereotype) {
497
- edge.setLabels([
498
- {
499
- attrs: {
500
- text: {
501
- text: stereotype,
502
- fill: CascaisPalette.TextSecondary,
503
- fontSize: 11,
600
+ if (isNew) {
601
+ applyEdgeRouting(edge, routingModeRef.current);
602
+ if (stereotype) {
603
+ edge.setLabels([
604
+ {
605
+ attrs: {
606
+ text: {
607
+ text: stereotype,
608
+ fill: CascaisPalette.TextSecondary,
609
+ fontSize: 11,
610
+ },
504
611
  },
612
+ position: 0.5,
505
613
  },
506
- position: 0.5,
507
- },
508
- ]);
614
+ ]);
615
+ }
616
+ } else {
617
+ // Re-anchored: if edge was selected, re-attach tools
618
+ if (selectedCellIdRef.current === edge.id) {
619
+ setEdgeTools(edge);
620
+ }
509
621
  }
510
622
  }
511
623
  triggerDebouncedChange();
@@ -515,8 +627,15 @@ export const RaidCanvas = React.forwardRef<RaidCanvasHandle, RaidCanvasProps>(
515
627
  graph.on('node:change:position', triggerDebouncedChange);
516
628
  graph.on('node:change:size', triggerDebouncedChange);
517
629
  graph.on('edge:change:vertices', triggerDebouncedChange);
630
+ graph.on('edge:change:source', triggerDebouncedChange);
631
+ graph.on('edge:change:target', triggerDebouncedChange);
518
632
  graph.on('cell:added', triggerDebouncedChange);
519
- graph.on('cell:removed', triggerDebouncedChange);
633
+ graph.on('cell:removed', ({ cell }) => {
634
+ if (cell === activeToolEdge) {
635
+ activeToolEdge = null;
636
+ }
637
+ triggerDebouncedChange();
638
+ });
520
639
 
521
640
  // Zoom listener to keep toolbar indicator accurate
522
641
  graph.on('scale', () => {
@@ -529,6 +648,7 @@ export const RaidCanvas = React.forwardRef<RaidCanvasHandle, RaidCanvasProps>(
529
648
  selectedCellIdRef.current = id;
530
649
 
531
650
  if (cell.isNode()) {
651
+ clearEdgeTools();
532
652
  const data = (cell.getData() ?? {}) as Partial<RaidNodeData>;
533
653
  const kind = (data.kind ?? 'act') as AimOntologyKind;
534
654
  const label =
@@ -538,6 +658,7 @@ export const RaidCanvas = React.forwardRef<RaidCanvasHandle, RaidCanvasProps>(
538
658
  id;
539
659
  onSelectRef.current?.({ id, kind, label });
540
660
  } else if (cell.isEdge()) {
661
+ setEdgeTools(cell);
541
662
  const data = (cell.getData() ?? {}) as Partial<RaidEdgeData>;
542
663
  const label =
543
664
  data.label ??
@@ -550,6 +671,7 @@ export const RaidCanvas = React.forwardRef<RaidCanvasHandle, RaidCanvasProps>(
550
671
  });
551
672
 
552
673
  graph.on('blank:click', () => {
674
+ clearEdgeTools();
553
675
  selectedCellIdRef.current = null;
554
676
  onSelectRef.current?.(null);
555
677
  onSelectionChangeRef.current?.([]);
@@ -572,6 +694,7 @@ export const RaidCanvas = React.forwardRef<RaidCanvasHandle, RaidCanvasProps>(
572
694
  const cell = graphRef.current.getCellById(selectedCellIdRef.current);
573
695
  if (cell) {
574
696
  e.preventDefault();
697
+ clearEdgeTools();
575
698
  graphRef.current.removeCell(cell);
576
699
  selectedCellIdRef.current = null;
577
700
  onSelectRef.current?.(null);
@@ -617,6 +740,7 @@ export const RaidCanvas = React.forwardRef<RaidCanvasHandle, RaidCanvasProps>(
617
740
  }
618
741
  window.removeEventListener('keydown', handleKeyDown);
619
742
  resizeObserver.disconnect();
743
+ clearEdgeToolsRef.current = null;
620
744
  graph.dispose();
621
745
  graphRef.current = null;
622
746
  };
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,
@@ -457,6 +458,34 @@ export function createAimNode(data: RaidNodeData): Node.Metadata {
457
458
  }
458
459
  }
459
460
 
461
+ /**
462
+ * Configures the router and connector for an X6 Edge based on AimRoutingMode.
463
+ * - 'manhattan': Obstacle-avoiding 90° orthogonal router with rounded corners (radius: 8).
464
+ * - 'normal': Direct straight line point-to-point connection.
465
+ * - 'smooth': Curved cubic bezier spline between ports.
466
+ */
467
+ export function applyEdgeRouting(edge: Edge, routing: AimRoutingMode = 'manhattan'): void {
468
+ switch (routing) {
469
+ case 'normal':
470
+ edge.setRouter('normal');
471
+ edge.setConnector('normal');
472
+ break;
473
+ case 'smooth':
474
+ edge.setRouter('normal');
475
+ edge.setConnector('smooth');
476
+ break;
477
+ case 'manhattan':
478
+ default:
479
+ edge.setRouter('manhattan', {
480
+ padding: 20,
481
+ startDirections: ['top', 'right', 'bottom', 'left'],
482
+ endDirections: ['top', 'right', 'bottom', 'left'],
483
+ });
484
+ edge.setConnector('rounded', { radius: 8 });
485
+ break;
486
+ }
487
+ }
488
+
460
489
  /**
461
490
  * Factory creating an AntV X6 Edge model from a RaidEdgeData specification.
462
491
  */
@@ -464,10 +493,34 @@ export function createAimEdge(data: RaidEdgeData): Edge.Metadata {
464
493
  registerAimShapes();
465
494
 
466
495
  const edgeAttrs = getEdgeStyling(data.kind);
496
+ const routing = data.routing ?? 'manhattan';
497
+
498
+ let routerConfig: Edge.Metadata['router'] = {
499
+ name: 'manhattan',
500
+ args: {
501
+ padding: 20,
502
+ startDirections: ['top', 'right', 'bottom', 'left'],
503
+ endDirections: ['top', 'right', 'bottom', 'left'],
504
+ },
505
+ };
506
+ let connectorConfig: Edge.Metadata['connector'] = {
507
+ name: 'rounded',
508
+ args: { radius: 8 },
509
+ };
510
+
511
+ if (routing === 'normal') {
512
+ routerConfig = { name: 'normal' };
513
+ connectorConfig = { name: 'normal' };
514
+ } else if (routing === 'smooth') {
515
+ routerConfig = { name: 'normal' };
516
+ connectorConfig = { name: 'smooth' };
517
+ }
467
518
 
468
519
  return {
469
520
  id: data.id,
470
521
  shape: 'aim-edge',
522
+ router: routerConfig,
523
+ connector: connectorConfig,
471
524
  source: {
472
525
  cell: data.sourceId,
473
526
  ...(data.sourcePort !== undefined ? { port: data.sourcePort } : {}),
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,6 +36,7 @@ export {
35
36
  configureAimGraph,
36
37
  createAimNode,
37
38
  createAimEdge,
39
+ applyEdgeRouting,
38
40
  getDefaultNodeBounds,
39
41
  getDefaultNodeName,
40
42
  } from './X6Shapes.js';
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,6 +134,9 @@ 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[];
131
142
  }
@@ -172,6 +183,7 @@ export const AimSvgContract = {
172
183
  ATTR_TARGET: 'aim-target',
173
184
  ATTR_SOURCE_PORT: 'aim-source-port',
174
185
  ATTR_TARGET_PORT: 'aim-target-port',
186
+ ATTR_ROUTING: 'aim-routing',
175
187
  ATTR_BENDS: 'aim-bends',
176
188
 
177
189
  // Selectors for DOM queries