@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.
@@ -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 */
@@ -54,6 +57,8 @@ export interface RaidCanvasProps {
54
57
  onSelectionChange?: (selectedIds: string[]) => void;
55
58
  /** Callback fired when a stencil is dropped onto the canvas */
56
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;
57
62
  /** Whether to render built-in navigation controls (Zoom In/Out, Fit, Center, Reset). Default: true */
58
63
  showToolbar?: boolean;
59
64
  }
@@ -72,8 +77,12 @@ export interface RaidCanvasHandle {
72
77
  ) => string;
73
78
  /** Update properties of an existing node (label, stereotype, dimensions, etc.) */
74
79
  updateNode: (id: string, updates: Partial<RaidNodeData>) => void;
75
- /** Update properties of an existing edge (kind, label, stereotype) */
80
+ /** Update properties of an existing edge (kind, label, stereotype, routing, ports) */
76
81
  updateEdge: (id: string, updates: Partial<RaidEdgeData>) => void;
82
+ /** Set routing mode across the canvas or for new edges */
83
+ setRoutingMode: (mode: AimRoutingMode, applyToAllEdges?: boolean) => void;
84
+ /** Get the current active canvas routing mode */
85
+ getRoutingMode: () => AimRoutingMode;
77
86
  /** Delete currently selected cell(s) */
78
87
  deleteSelection: () => void;
79
88
  /** Clear all cells on the canvas */
@@ -100,6 +109,7 @@ export const RaidCanvas = React.forwardRef<RaidCanvasHandle, RaidCanvasProps>(
100
109
  svg,
101
110
  svgContent,
102
111
  readOnly = false,
112
+ defaultRouting = 'manhattan',
103
113
  className,
104
114
  style,
105
115
  onChange,
@@ -107,6 +117,7 @@ export const RaidCanvas = React.forwardRef<RaidCanvasHandle, RaidCanvasProps>(
107
117
  onSelect,
108
118
  onSelectionChange,
109
119
  onDropStencil,
120
+ onRoutingModeChange,
110
121
  showToolbar = true,
111
122
  },
112
123
  ref,
@@ -118,6 +129,8 @@ export const RaidCanvas = React.forwardRef<RaidCanvasHandle, RaidCanvasProps>(
118
129
  const isHydratingRef = useRef<boolean>(false);
119
130
  const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
120
131
  const selectedCellIdRef = useRef<string | null>(null);
132
+ const clearEdgeToolsRef = useRef<(() => void) | null>(null);
133
+ const routingModeRef = useRef<AimRoutingMode>(defaultRouting);
121
134
 
122
135
  const [zoomLevel, setZoomLevel] = useState<number>(100);
123
136
  const [isDragOver, setIsDragOver] = useState<boolean>(false);
@@ -141,6 +154,9 @@ export const RaidCanvas = React.forwardRef<RaidCanvasHandle, RaidCanvasProps>(
141
154
  const onDropStencilRef = useRef(onDropStencil);
142
155
  onDropStencilRef.current = onDropStencil;
143
156
 
157
+ const onRoutingModeChangeRef = useRef(onRoutingModeChange);
158
+ onRoutingModeChangeRef.current = onRoutingModeChange;
159
+
144
160
  const svgPropRef = useRef(activeSvg);
145
161
  svgPropRef.current = activeSvg;
146
162
 
@@ -158,6 +174,7 @@ export const RaidCanvas = React.forwardRef<RaidCanvasHandle, RaidCanvasProps>(
158
174
  const updatedSvg = bridgeRef.current.serializeToSvg(
159
175
  graphRef.current,
160
176
  currentBaseSvg,
177
+ { routingMode: routingModeRef.current },
161
178
  );
162
179
  lastSerializedSvgRef.current = updatedSvg;
163
180
 
@@ -216,6 +233,7 @@ export const RaidCanvas = React.forwardRef<RaidCanvasHandle, RaidCanvasProps>(
216
233
  return bridgeRef.current.serializeToSvg(
217
234
  graphRef.current,
218
235
  svgPropRef.current,
236
+ { routingMode: routingModeRef.current },
219
237
  );
220
238
  },
221
239
  addNode: (kind, x, y, customData) => {
@@ -299,8 +317,39 @@ export const RaidCanvas = React.forwardRef<RaidCanvasHandle, RaidCanvasProps>(
299
317
  if (!edge || !edge.isEdge()) return;
300
318
 
301
319
  const currentData = (edge.getData() ?? {}) as RaidEdgeData;
302
- const nextData = { ...currentData, ...updates };
303
- edge.setData(nextData);
320
+ const mutableData: Record<string, unknown> = { ...currentData, ...updates };
321
+
322
+ if (updates.routing !== undefined) {
323
+ applyEdgeRouting(edge, updates.routing);
324
+ }
325
+
326
+ if (updates.sourcePort !== undefined) {
327
+ const currentSource = edge.getSource() as { cell?: string };
328
+ if (currentSource.cell) {
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
+ }
336
+ }
337
+ }
338
+
339
+ if (updates.targetPort !== undefined) {
340
+ const currentTarget = edge.getTarget() as { cell?: string };
341
+ if (currentTarget.cell) {
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
+ }
349
+ }
350
+ }
351
+
352
+ edge.setData(mutableData as unknown as RaidEdgeData);
304
353
 
305
354
  if (updates.label !== undefined || updates.stereotype !== undefined) {
306
355
  const text = updates.label ?? updates.stereotype ?? '';
@@ -324,12 +373,29 @@ export const RaidCanvas = React.forwardRef<RaidCanvasHandle, RaidCanvasProps>(
324
373
 
325
374
  triggerDebouncedChange();
326
375
  },
376
+ setRoutingMode: (mode, applyToAll = true) => {
377
+ routingModeRef.current = mode;
378
+ if (graphRef.current) {
379
+ (graphRef.current as unknown as { _aimRoutingMode?: AimRoutingMode })._aimRoutingMode = mode;
380
+ }
381
+ onRoutingModeChangeRef.current?.(mode);
382
+ if (applyToAll && graphRef.current) {
383
+ for (const edge of graphRef.current.getEdges()) {
384
+ applyEdgeRouting(edge, mode);
385
+ const currentData = (edge.getData() ?? {}) as RaidEdgeData;
386
+ edge.setData({ ...currentData, routing: mode });
387
+ }
388
+ triggerDebouncedChange();
389
+ }
390
+ },
391
+ getRoutingMode: () => routingModeRef.current,
327
392
  deleteSelection: () => {
328
393
  const graph = graphRef.current;
329
394
  if (!graph || readOnly) return;
330
395
  if (selectedCellIdRef.current) {
331
396
  const cell = graph.getCellById(selectedCellIdRef.current);
332
397
  if (cell) {
398
+ clearEdgeToolsRef.current?.();
333
399
  graph.removeCell(cell);
334
400
  selectedCellIdRef.current = null;
335
401
  onSelectRef.current?.(null);
@@ -455,9 +521,11 @@ export const RaidCanvas = React.forwardRef<RaidCanvasHandle, RaidCanvasProps>(
455
521
  return validateSemanticConnection(sourceKind, targetKind);
456
522
  },
457
523
  createEdge() {
458
- return new Shape.Edge({
524
+ const edge = new Shape.Edge({
459
525
  shape: 'aim-edge',
460
526
  });
527
+ applyEdgeRouting(edge, routingModeRef.current);
528
+ return edge;
461
529
  },
462
530
  },
463
531
  });
@@ -465,9 +533,66 @@ export const RaidCanvas = React.forwardRef<RaidCanvasHandle, RaidCanvasProps>(
465
533
  configureAimGraph(graph);
466
534
  graphRef.current = graph;
467
535
 
468
- // Handle new edge connections with automatic semantic wiring
536
+ let activeToolEdge: Edge | null = null;
537
+
538
+ const clearEdgeTools = () => {
539
+ if (activeToolEdge) {
540
+ try {
541
+ activeToolEdge.removeTools();
542
+ } catch {
543
+ // Ignore if cell was already removed
544
+ }
545
+ activeToolEdge = null;
546
+ }
547
+ };
548
+ clearEdgeToolsRef.current = clearEdgeTools;
549
+
550
+ const setEdgeTools = (edge: Edge) => {
551
+ clearEdgeTools();
552
+ if (readOnly) return;
553
+ activeToolEdge = edge;
554
+ try {
555
+ edge.addTools([
556
+ {
557
+ name: 'source-arrowhead',
558
+ args: {
559
+ attrs: {
560
+ fill: CascaisPalette.NetGold,
561
+ stroke: '#FFFFFF',
562
+ strokeWidth: 2,
563
+ cursor: 'grab',
564
+ },
565
+ },
566
+ },
567
+ {
568
+ name: 'target-arrowhead',
569
+ args: {
570
+ attrs: {
571
+ fill: CascaisPalette.NetGold,
572
+ stroke: '#FFFFFF',
573
+ strokeWidth: 2,
574
+ cursor: 'grab',
575
+ },
576
+ },
577
+ },
578
+ {
579
+ name: 'vertices',
580
+ args: {
581
+ attrs: {
582
+ fill: CascaisPalette.WarmGraphite,
583
+ stroke: '#FFFFFF',
584
+ strokeWidth: 1.5,
585
+ },
586
+ },
587
+ },
588
+ ]);
589
+ } catch {
590
+ // Fallback if tools fail to attach
591
+ }
592
+ };
593
+
594
+ // Handle edge connections and re-connections with automatic semantic wiring
469
595
  graph.on('edge:connected', ({ edge, isNew }) => {
470
- if (!isNew) return;
471
596
  const sourceCell = edge.getSourceCell();
472
597
  const targetCell = edge.getTargetCell();
473
598
  if (sourceCell?.isNode() && targetCell?.isNode()) {
@@ -479,33 +604,44 @@ export const RaidCanvas = React.forwardRef<RaidCanvasHandle, RaidCanvasProps>(
479
604
  ) as AimOntologyKind;
480
605
 
481
606
  const edgeKind = getSemanticEdgeKind(sourceKind, targetKind);
482
- const stereotype = getSemanticEdgeStereotype(sourceKind, targetKind);
607
+ const currentData = (edge.getData() ?? {}) as Partial<RaidEdgeData>;
608
+ const stereotype = currentData.stereotype ?? getSemanticEdgeStereotype(sourceKind, targetKind);
483
609
 
484
610
  edge.setData({
611
+ ...currentData,
485
612
  id: edge.id,
486
613
  kind: edgeKind,
487
614
  sourceId: sourceCell.id,
488
615
  targetId: targetCell.id,
489
616
  sourcePort: edge.getSourcePortId(),
490
617
  targetPort: edge.getTargetPortId(),
491
- label: stereotype,
618
+ label: currentData.label ?? stereotype,
492
619
  stereotype,
493
- bendPoints: [],
620
+ routing: currentData.routing ?? routingModeRef.current,
621
+ bendPoints: currentData.bendPoints ?? [],
494
622
  });
495
623
 
496
- if (stereotype) {
497
- edge.setLabels([
498
- {
499
- attrs: {
500
- text: {
501
- text: stereotype,
502
- fill: CascaisPalette.TextSecondary,
503
- fontSize: 11,
624
+ if (isNew) {
625
+ applyEdgeRouting(edge, routingModeRef.current);
626
+ if (stereotype) {
627
+ edge.setLabels([
628
+ {
629
+ attrs: {
630
+ text: {
631
+ text: stereotype,
632
+ fill: CascaisPalette.TextSecondary,
633
+ fontSize: 11,
634
+ },
504
635
  },
636
+ position: 0.5,
505
637
  },
506
- position: 0.5,
507
- },
508
- ]);
638
+ ]);
639
+ }
640
+ } else {
641
+ // Re-anchored: if edge was selected, re-attach tools
642
+ if (selectedCellIdRef.current === edge.id) {
643
+ setEdgeTools(edge);
644
+ }
509
645
  }
510
646
  }
511
647
  triggerDebouncedChange();
@@ -515,8 +651,15 @@ export const RaidCanvas = React.forwardRef<RaidCanvasHandle, RaidCanvasProps>(
515
651
  graph.on('node:change:position', triggerDebouncedChange);
516
652
  graph.on('node:change:size', triggerDebouncedChange);
517
653
  graph.on('edge:change:vertices', triggerDebouncedChange);
654
+ graph.on('edge:change:source', triggerDebouncedChange);
655
+ graph.on('edge:change:target', triggerDebouncedChange);
518
656
  graph.on('cell:added', triggerDebouncedChange);
519
- graph.on('cell:removed', triggerDebouncedChange);
657
+ graph.on('cell:removed', ({ cell }) => {
658
+ if (cell === activeToolEdge) {
659
+ activeToolEdge = null;
660
+ }
661
+ triggerDebouncedChange();
662
+ });
520
663
 
521
664
  // Zoom listener to keep toolbar indicator accurate
522
665
  graph.on('scale', () => {
@@ -529,6 +672,7 @@ export const RaidCanvas = React.forwardRef<RaidCanvasHandle, RaidCanvasProps>(
529
672
  selectedCellIdRef.current = id;
530
673
 
531
674
  if (cell.isNode()) {
675
+ clearEdgeTools();
532
676
  const data = (cell.getData() ?? {}) as Partial<RaidNodeData>;
533
677
  const kind = (data.kind ?? 'act') as AimOntologyKind;
534
678
  const label =
@@ -538,6 +682,7 @@ export const RaidCanvas = React.forwardRef<RaidCanvasHandle, RaidCanvasProps>(
538
682
  id;
539
683
  onSelectRef.current?.({ id, kind, label });
540
684
  } else if (cell.isEdge()) {
685
+ setEdgeTools(cell);
541
686
  const data = (cell.getData() ?? {}) as Partial<RaidEdgeData>;
542
687
  const label =
543
688
  data.label ??
@@ -550,6 +695,7 @@ export const RaidCanvas = React.forwardRef<RaidCanvasHandle, RaidCanvasProps>(
550
695
  });
551
696
 
552
697
  graph.on('blank:click', () => {
698
+ clearEdgeTools();
553
699
  selectedCellIdRef.current = null;
554
700
  onSelectRef.current?.(null);
555
701
  onSelectionChangeRef.current?.([]);
@@ -572,6 +718,7 @@ export const RaidCanvas = React.forwardRef<RaidCanvasHandle, RaidCanvasProps>(
572
718
  const cell = graphRef.current.getCellById(selectedCellIdRef.current);
573
719
  if (cell) {
574
720
  e.preventDefault();
721
+ clearEdgeTools();
575
722
  graphRef.current.removeCell(cell);
576
723
  selectedCellIdRef.current = null;
577
724
  onSelectRef.current?.(null);
@@ -600,8 +747,13 @@ export const RaidCanvas = React.forwardRef<RaidCanvasHandle, RaidCanvasProps>(
600
747
  if (svgPropRef.current) {
601
748
  isHydratingRef.current = true;
602
749
  try {
603
- bridgeRef.current.hydrateFromSvg(svgPropRef.current, graph);
750
+ const metamodel = bridgeRef.current.hydrateFromSvg(svgPropRef.current, graph, { inferPorts: false });
604
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
+ }
605
757
  graph.centerContent();
606
758
  } catch (err) {
607
759
  console.error('RaidCanvas hydration error:', err);
@@ -617,6 +769,7 @@ export const RaidCanvas = React.forwardRef<RaidCanvasHandle, RaidCanvasProps>(
617
769
  }
618
770
  window.removeEventListener('keydown', handleKeyDown);
619
771
  resizeObserver.disconnect();
772
+ clearEdgeToolsRef.current = null;
620
773
  graph.dispose();
621
774
  graphRef.current = null;
622
775
  };
@@ -636,8 +789,13 @@ export const RaidCanvas = React.forwardRef<RaidCanvasHandle, RaidCanvasProps>(
636
789
 
637
790
  isHydratingRef.current = true;
638
791
  try {
639
- bridgeRef.current.hydrateFromSvg(activeSvg, graph);
792
+ const metamodel = bridgeRef.current.hydrateFromSvg(activeSvg, graph, { inferPorts: false });
640
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
+ }
641
799
  graph.centerContent();
642
800
  } catch (err) {
643
801
  console.error('RaidCanvas re-hydration error:', err);