@dr2rai/raid-canvas 0.1.0 → 0.2.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.
@@ -0,0 +1,875 @@
1
+ /**
2
+ * @file RaidCanvas.tsx
3
+ * @description Reusable React component wrapping AntV X6 and RaiBridge for
4
+ * AOAIM (Activity-Object-AI Model) interactive visual editing, Manhattan orthogonal
5
+ * connector routing, drag-and-drop shape stencils, semantic anti-entropy wiring,
6
+ * and aim-* SVG synchronization.
7
+ *
8
+ * Honors Alan Kay's Dynabook vision of dynamic, malleable visual objects
9
+ * and Rainer Burkhardt's C++ GrafObj graphical hierarchy contracts.
10
+ */
11
+
12
+ import React, {
13
+ useEffect,
14
+ useRef,
15
+ useState,
16
+ useCallback,
17
+ useImperativeHandle,
18
+ } from 'react';
19
+ import { Graph, Shape } from '@antv/x6';
20
+ import { RaiBridge } from './RaiBridge.js';
21
+ import {
22
+ registerAimShapes,
23
+ configureAimGraph,
24
+ createAimNode,
25
+ CascaisPalette,
26
+ getDefaultNodeBounds,
27
+ getDefaultNodeName,
28
+ } from './X6Shapes.js';
29
+ import {
30
+ validateSemanticConnection,
31
+ getSemanticEdgeKind,
32
+ getSemanticEdgeStereotype,
33
+ } from './semanticRules.js';
34
+ import type { AimOntologyKind, RaidNodeData, RaidEdgeData } from './types.js';
35
+
36
+ export interface RaidCanvasProps {
37
+ /** The raw SVG string carrying aim-* ontological attributes (preferred) */
38
+ svg?: string;
39
+ /** Raw aim-* SVG string alias (supported for compatibility) */
40
+ svgContent?: string;
41
+ /** Whether the canvas allows dragging and editing, or behaves as a pan/zoom viewer */
42
+ readOnly?: boolean;
43
+ /** Optional CSS class name for the outer wrapper */
44
+ className?: string;
45
+ /** Optional inline styles for outer wrapper */
46
+ style?: React.CSSProperties;
47
+ /** Callback fired when user moves a node, adjusts edge, or drops a shape */
48
+ onChange?: (updatedSvg: string) => void;
49
+ /** Callback returning serialized SVG DOM upon canvas change or save trigger */
50
+ onSave?: (svg: string) => void;
51
+ /** Callback fired when an entity is selected, providing its ontological metadata */
52
+ onSelect?: (selection: { id: string; kind: AimOntologyKind; label: string } | null) => void;
53
+ /** Callback fired emitting IDs of selected nodes/edges */
54
+ onSelectionChange?: (selectedIds: string[]) => void;
55
+ /** Callback fired when a stencil is dropped onto the canvas */
56
+ onDropStencil?: (kind: AimOntologyKind, point: { x: number; y: number }) => void;
57
+ /** Whether to render built-in navigation controls (Zoom In/Out, Fit, Center, Reset). Default: true */
58
+ showToolbar?: boolean;
59
+ }
60
+
61
+ export interface RaidCanvasHandle {
62
+ /** Access underlying AntV X6 Graph instance */
63
+ getGraph: () => Graph | null;
64
+ /** Get current serialized SVG */
65
+ getSvg: () => string;
66
+ /** Add a new AOAIM archetype node to the canvas */
67
+ addNode: (
68
+ kind: AimOntologyKind,
69
+ x?: number,
70
+ y?: number,
71
+ customData?: Partial<RaidNodeData>,
72
+ ) => string;
73
+ /** Update properties of an existing node (label, stereotype, dimensions, etc.) */
74
+ updateNode: (id: string, updates: Partial<RaidNodeData>) => void;
75
+ /** Update properties of an existing edge (kind, label, stereotype) */
76
+ updateEdge: (id: string, updates: Partial<RaidEdgeData>) => void;
77
+ /** Delete currently selected cell(s) */
78
+ deleteSelection: () => void;
79
+ /** Clear all cells on the canvas */
80
+ clear: () => void;
81
+ /** Center canvas content */
82
+ center: () => void;
83
+ /** Zoom to fit content */
84
+ zoomToFit: () => void;
85
+ /** Reset view to 100% zoom and center */
86
+ resetView: () => void;
87
+ /** Zoom in */
88
+ zoomIn: () => void;
89
+ /** Zoom out */
90
+ zoomOut: () => void;
91
+ }
92
+
93
+ /**
94
+ * Declarative drop-in React component for rendering and interacting with
95
+ * AOAIM diagrams conforming to the ontological aim-* SVG contract.
96
+ */
97
+ export const RaidCanvas = React.forwardRef<RaidCanvasHandle, RaidCanvasProps>(
98
+ function RaidCanvas(
99
+ {
100
+ svg,
101
+ svgContent,
102
+ readOnly = false,
103
+ className,
104
+ style,
105
+ onChange,
106
+ onSave,
107
+ onSelect,
108
+ onSelectionChange,
109
+ onDropStencil,
110
+ showToolbar = true,
111
+ },
112
+ ref,
113
+ ) {
114
+ const containerRef = useRef<HTMLDivElement | null>(null);
115
+ const graphRef = useRef<Graph | null>(null);
116
+ const bridgeRef = useRef<RaiBridge>(new RaiBridge());
117
+ const lastSerializedSvgRef = useRef<string>('');
118
+ const isHydratingRef = useRef<boolean>(false);
119
+ const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
120
+ const selectedCellIdRef = useRef<string | null>(null);
121
+
122
+ const [zoomLevel, setZoomLevel] = useState<number>(100);
123
+ const [isDragOver, setIsDragOver] = useState<boolean>(false);
124
+
125
+ // Unify svg vs svgContent props
126
+ const activeSvg = svgContent ?? svg ?? '';
127
+
128
+ // Callback refs to maintain stable graph event listeners
129
+ const onChangeRef = useRef(onChange);
130
+ onChangeRef.current = onChange;
131
+
132
+ const onSaveRef = useRef(onSave);
133
+ onSaveRef.current = onSave;
134
+
135
+ const onSelectRef = useRef(onSelect);
136
+ onSelectRef.current = onSelect;
137
+
138
+ const onSelectionChangeRef = useRef(onSelectionChange);
139
+ onSelectionChangeRef.current = onSelectionChange;
140
+
141
+ const onDropStencilRef = useRef(onDropStencil);
142
+ onDropStencilRef.current = onDropStencil;
143
+
144
+ const svgPropRef = useRef(activeSvg);
145
+ svgPropRef.current = activeSvg;
146
+
147
+ // Helper: Debounced Serialization and notification
148
+ const triggerDebouncedChange = useCallback(() => {
149
+ if (isHydratingRef.current) return;
150
+
151
+ if (debounceTimerRef.current) {
152
+ clearTimeout(debounceTimerRef.current);
153
+ }
154
+
155
+ debounceTimerRef.current = setTimeout(() => {
156
+ if (!graphRef.current) return;
157
+ const currentBaseSvg = svgPropRef.current;
158
+ const updatedSvg = bridgeRef.current.serializeToSvg(
159
+ graphRef.current,
160
+ currentBaseSvg,
161
+ );
162
+ lastSerializedSvgRef.current = updatedSvg;
163
+
164
+ onChangeRef.current?.(updatedSvg);
165
+ onSaveRef.current?.(updatedSvg);
166
+ }, 100);
167
+ }, []);
168
+
169
+ // --------------------------------------------------------------------------
170
+ // Toolbar Actions
171
+ // --------------------------------------------------------------------------
172
+ const handleZoomIn = useCallback(() => {
173
+ const graph = graphRef.current;
174
+ if (!graph) return;
175
+ graph.zoom(0.2);
176
+ setZoomLevel(Math.round(graph.zoom() * 100));
177
+ }, []);
178
+
179
+ const handleZoomOut = useCallback(() => {
180
+ const graph = graphRef.current;
181
+ if (!graph) return;
182
+ graph.zoom(-0.2);
183
+ setZoomLevel(Math.round(graph.zoom() * 100));
184
+ }, []);
185
+
186
+ const handleFitToContent = useCallback(() => {
187
+ const graph = graphRef.current;
188
+ if (!graph) return;
189
+ graph.zoomToFit({ padding: 32, maxScale: 1.5 });
190
+ setZoomLevel(Math.round(graph.zoom() * 100));
191
+ }, []);
192
+
193
+ const handleCenter = useCallback(() => {
194
+ const graph = graphRef.current;
195
+ if (!graph) return;
196
+ graph.centerContent();
197
+ }, []);
198
+
199
+ const handleResetView = useCallback(() => {
200
+ const graph = graphRef.current;
201
+ if (!graph) return;
202
+ graph.zoom(1, { absolute: true });
203
+ graph.centerContent();
204
+ setZoomLevel(100);
205
+ }, []);
206
+
207
+ // --------------------------------------------------------------------------
208
+ // Imperative Ref Handle API
209
+ // --------------------------------------------------------------------------
210
+ useImperativeHandle(
211
+ ref,
212
+ () => ({
213
+ getGraph: () => graphRef.current,
214
+ getSvg: () => {
215
+ if (!graphRef.current) return svgPropRef.current;
216
+ return bridgeRef.current.serializeToSvg(
217
+ graphRef.current,
218
+ svgPropRef.current,
219
+ );
220
+ },
221
+ addNode: (kind, x, y, customData) => {
222
+ const graph = graphRef.current;
223
+ if (!graph) return '';
224
+
225
+ const defaultBounds = getDefaultNodeBounds(kind, x ?? 200, y ?? 150);
226
+ const defaultName = getDefaultNodeName(kind);
227
+ const id = `${kind.toUpperCase()}_${Date.now().toString(36).slice(-4)}`;
228
+
229
+ const nodeData: RaidNodeData = {
230
+ id,
231
+ kind,
232
+ displayName: customData?.displayName ?? defaultName,
233
+ ...(customData?.stereotype !== undefined ? { stereotype: customData.stereotype } : {}),
234
+ ...(customData?.attributes !== undefined ? { attributes: customData.attributes } : {}),
235
+ ...(customData?.methods !== undefined ? { methods: customData.methods } : {}),
236
+ bounds: {
237
+ ...defaultBounds,
238
+ ...(customData?.bounds ?? {}),
239
+ },
240
+ };
241
+
242
+ const nodeMeta = createAimNode(nodeData);
243
+ graph.addNode(nodeMeta);
244
+
245
+ selectedCellIdRef.current = id;
246
+ onSelectRef.current?.({ id, kind, label: nodeData.displayName });
247
+ onSelectionChangeRef.current?.([id]);
248
+ triggerDebouncedChange();
249
+
250
+ return id;
251
+ },
252
+ updateNode: (id, updates) => {
253
+ const graph = graphRef.current;
254
+ if (!graph) return;
255
+ const node = graph.getCellById(id);
256
+ if (!node || !node.isNode()) return;
257
+
258
+ const currentData = (node.getData() ?? {}) as RaidNodeData;
259
+ const nextData: RaidNodeData = {
260
+ ...currentData,
261
+ ...updates,
262
+ id: updates.id ?? currentData.id ?? id,
263
+ kind: updates.kind ?? currentData.kind ?? 'act',
264
+ displayName: updates.displayName ?? currentData.displayName ?? id,
265
+ bounds: {
266
+ ...(currentData.bounds ?? { x: 0, y: 0, width: 140, height: 60 }),
267
+ ...(updates.bounds ?? {}),
268
+ },
269
+ };
270
+ node.setData(nextData);
271
+
272
+ const displayName = nextData.displayName;
273
+ const stereotype = nextData.stereotype;
274
+ const labelText = stereotype ? `${stereotype}\n${displayName}` : displayName;
275
+
276
+ if (nextData.kind === 'cls') {
277
+ node.setAttrByPath('title/text', displayName);
278
+ if (nextData.attributes !== undefined) {
279
+ node.setAttrByPath('attributes/text', nextData.attributes.join('\n'));
280
+ }
281
+ if (nextData.methods !== undefined) {
282
+ node.setAttrByPath('methods/text', nextData.methods.join('\n'));
283
+ }
284
+ } else {
285
+ node.setAttrByPath('label/text', labelText);
286
+ }
287
+
288
+ if (updates.bounds) {
289
+ node.setPosition(updates.bounds.x, updates.bounds.y);
290
+ node.setSize(updates.bounds.width, updates.bounds.height);
291
+ }
292
+
293
+ triggerDebouncedChange();
294
+ },
295
+ updateEdge: (id, updates) => {
296
+ const graph = graphRef.current;
297
+ if (!graph) return;
298
+ const edge = graph.getCellById(id);
299
+ if (!edge || !edge.isEdge()) return;
300
+
301
+ const currentData = (edge.getData() ?? {}) as RaidEdgeData;
302
+ const nextData = { ...currentData, ...updates };
303
+ edge.setData(nextData);
304
+
305
+ if (updates.label !== undefined || updates.stereotype !== undefined) {
306
+ const text = updates.label ?? updates.stereotype ?? '';
307
+ edge.setLabels(
308
+ text
309
+ ? [
310
+ {
311
+ attrs: {
312
+ text: {
313
+ text,
314
+ fill: CascaisPalette.TextSecondary,
315
+ fontSize: 11,
316
+ },
317
+ },
318
+ position: 0.5,
319
+ },
320
+ ]
321
+ : [],
322
+ );
323
+ }
324
+
325
+ triggerDebouncedChange();
326
+ },
327
+ deleteSelection: () => {
328
+ const graph = graphRef.current;
329
+ if (!graph || readOnly) return;
330
+ if (selectedCellIdRef.current) {
331
+ const cell = graph.getCellById(selectedCellIdRef.current);
332
+ if (cell) {
333
+ graph.removeCell(cell);
334
+ selectedCellIdRef.current = null;
335
+ onSelectRef.current?.(null);
336
+ onSelectionChangeRef.current?.([]);
337
+ triggerDebouncedChange();
338
+ }
339
+ }
340
+ },
341
+ clear: () => {
342
+ const graph = graphRef.current;
343
+ if (!graph || readOnly) return;
344
+ graph.clearCells();
345
+ selectedCellIdRef.current = null;
346
+ onSelectRef.current?.(null);
347
+ onSelectionChangeRef.current?.([]);
348
+ triggerDebouncedChange();
349
+ },
350
+ center: handleCenter,
351
+ zoomToFit: handleFitToContent,
352
+ resetView: handleResetView,
353
+ zoomIn: handleZoomIn,
354
+ zoomOut: handleZoomOut,
355
+ }),
356
+ [
357
+ handleCenter,
358
+ handleFitToContent,
359
+ handleResetView,
360
+ handleZoomIn,
361
+ handleZoomOut,
362
+ readOnly,
363
+ triggerDebouncedChange,
364
+ ],
365
+ );
366
+
367
+ // --------------------------------------------------------------------------
368
+ // 1. Mount Graph & Event Listeners
369
+ // --------------------------------------------------------------------------
370
+ useEffect(() => {
371
+ if (!containerRef.current) return;
372
+
373
+ registerAimShapes();
374
+
375
+ const container = containerRef.current;
376
+ const initialWidth = container.clientWidth || 800;
377
+ const initialHeight = container.clientHeight || 600;
378
+
379
+ const graph = new Graph({
380
+ container,
381
+ width: initialWidth,
382
+ height: initialHeight,
383
+ autoResize: false,
384
+ grid: {
385
+ visible: true,
386
+ type: 'dot',
387
+ args: {
388
+ color: CascaisPalette.SilverLine,
389
+ thickness: 1,
390
+ },
391
+ },
392
+ highlighting: {
393
+ magnetAvailable: {
394
+ name: 'stroke',
395
+ args: {
396
+ padding: 3,
397
+ attrs: {
398
+ stroke: CascaisPalette.HeraldicGreen,
399
+ strokeWidth: 2,
400
+ },
401
+ },
402
+ },
403
+ magnetAdsorbed: {
404
+ name: 'stroke',
405
+ args: {
406
+ padding: 4,
407
+ attrs: {
408
+ stroke: CascaisPalette.HeraldicGreen,
409
+ strokeWidth: 3,
410
+ fill: CascaisPalette.HeraldicGreen,
411
+ },
412
+ },
413
+ },
414
+ },
415
+ interacting: {
416
+ nodeMovable: !readOnly,
417
+ edgeMovable: !readOnly,
418
+ edgeLabelMovable: !readOnly,
419
+ arrowheadMovable: !readOnly,
420
+ vertexMovable: !readOnly,
421
+ vertexAddable: !readOnly,
422
+ vertexDeletable: !readOnly,
423
+ },
424
+ panning: {
425
+ enabled: true,
426
+ eventTypes: readOnly
427
+ ? ['leftMouseDown', 'rightMouseDown']
428
+ : ['rightMouseDown', 'mouseWheel'],
429
+ },
430
+ mousewheel: {
431
+ enabled: true,
432
+ modifiers: ['ctrl', 'meta'],
433
+ },
434
+ connecting: {
435
+ router: 'manhattan',
436
+ connector: { name: 'rounded', args: { radius: 8 } },
437
+ allowBlank: false,
438
+ allowLoop: false,
439
+ allowNode: true,
440
+ allowPort: true,
441
+ allowEdge: false,
442
+ highlight: true,
443
+ validateConnection({ sourceCell, targetCell }) {
444
+ if (!sourceCell || !targetCell) return false;
445
+ if (sourceCell === targetCell) return false;
446
+ if (!sourceCell.isNode() || !targetCell.isNode()) return false;
447
+
448
+ const sourceKind = (
449
+ (sourceCell.getData() as Partial<RaidNodeData>)?.kind ?? 'act'
450
+ ) as AimOntologyKind;
451
+ const targetKind = (
452
+ (targetCell.getData() as Partial<RaidNodeData>)?.kind ?? 'act'
453
+ ) as AimOntologyKind;
454
+
455
+ return validateSemanticConnection(sourceKind, targetKind);
456
+ },
457
+ createEdge() {
458
+ return new Shape.Edge({
459
+ shape: 'aim-edge',
460
+ });
461
+ },
462
+ },
463
+ });
464
+
465
+ configureAimGraph(graph);
466
+ graphRef.current = graph;
467
+
468
+ // Handle new edge connections with automatic semantic wiring
469
+ graph.on('edge:connected', ({ edge, isNew }) => {
470
+ if (!isNew) return;
471
+ const sourceCell = edge.getSourceCell();
472
+ const targetCell = edge.getTargetCell();
473
+ if (sourceCell?.isNode() && targetCell?.isNode()) {
474
+ const sourceKind = (
475
+ (sourceCell.getData() as Partial<RaidNodeData>)?.kind ?? 'act'
476
+ ) as AimOntologyKind;
477
+ const targetKind = (
478
+ (targetCell.getData() as Partial<RaidNodeData>)?.kind ?? 'act'
479
+ ) as AimOntologyKind;
480
+
481
+ const edgeKind = getSemanticEdgeKind(sourceKind, targetKind);
482
+ const stereotype = getSemanticEdgeStereotype(sourceKind, targetKind);
483
+
484
+ edge.setData({
485
+ id: edge.id,
486
+ kind: edgeKind,
487
+ sourceId: sourceCell.id,
488
+ targetId: targetCell.id,
489
+ sourcePort: edge.getSourcePortId(),
490
+ targetPort: edge.getTargetPortId(),
491
+ label: stereotype,
492
+ stereotype,
493
+ bendPoints: [],
494
+ });
495
+
496
+ if (stereotype) {
497
+ edge.setLabels([
498
+ {
499
+ attrs: {
500
+ text: {
501
+ text: stereotype,
502
+ fill: CascaisPalette.TextSecondary,
503
+ fontSize: 11,
504
+ },
505
+ },
506
+ position: 0.5,
507
+ },
508
+ ]);
509
+ }
510
+ }
511
+ triggerDebouncedChange();
512
+ });
513
+
514
+ // Canvas change events
515
+ graph.on('node:change:position', triggerDebouncedChange);
516
+ graph.on('node:change:size', triggerDebouncedChange);
517
+ graph.on('edge:change:vertices', triggerDebouncedChange);
518
+ graph.on('cell:added', triggerDebouncedChange);
519
+ graph.on('cell:removed', triggerDebouncedChange);
520
+
521
+ // Zoom listener to keep toolbar indicator accurate
522
+ graph.on('scale', () => {
523
+ setZoomLevel(Math.round(graph.zoom() * 100));
524
+ });
525
+
526
+ // Selection listeners
527
+ graph.on('cell:click', ({ cell }) => {
528
+ const id = String(cell.id);
529
+ selectedCellIdRef.current = id;
530
+
531
+ if (cell.isNode()) {
532
+ const data = (cell.getData() ?? {}) as Partial<RaidNodeData>;
533
+ const kind = (data.kind ?? 'act') as AimOntologyKind;
534
+ const label =
535
+ data.displayName ??
536
+ (cell.getAttrByPath('label/text') as string) ??
537
+ (cell.getAttrByPath('title/text') as string) ??
538
+ id;
539
+ onSelectRef.current?.({ id, kind, label });
540
+ } else if (cell.isEdge()) {
541
+ const data = (cell.getData() ?? {}) as Partial<RaidEdgeData>;
542
+ const label =
543
+ data.label ??
544
+ (cell.getLabels()?.[0]?.attrs?.['text']?.['text'] as string) ??
545
+ '';
546
+ onSelectRef.current?.({ id, kind: 'act', label });
547
+ }
548
+
549
+ onSelectionChangeRef.current?.([id]);
550
+ });
551
+
552
+ graph.on('blank:click', () => {
553
+ selectedCellIdRef.current = null;
554
+ onSelectRef.current?.(null);
555
+ onSelectionChangeRef.current?.([]);
556
+ });
557
+
558
+ // Keyboard listener for deletion
559
+ const handleKeyDown = (e: KeyboardEvent) => {
560
+ if (readOnly) return;
561
+ const activeTag = (document.activeElement?.tagName ?? '').toLowerCase();
562
+ if (
563
+ activeTag === 'input' ||
564
+ activeTag === 'textarea' ||
565
+ (document.activeElement as HTMLElement)?.isContentEditable
566
+ ) {
567
+ return;
568
+ }
569
+
570
+ if (e.key === 'Delete' || e.key === 'Backspace') {
571
+ if (selectedCellIdRef.current && graphRef.current) {
572
+ const cell = graphRef.current.getCellById(selectedCellIdRef.current);
573
+ if (cell) {
574
+ e.preventDefault();
575
+ graphRef.current.removeCell(cell);
576
+ selectedCellIdRef.current = null;
577
+ onSelectRef.current?.(null);
578
+ onSelectionChangeRef.current?.([]);
579
+ triggerDebouncedChange();
580
+ }
581
+ }
582
+ }
583
+ };
584
+
585
+ window.addEventListener('keydown', handleKeyDown);
586
+
587
+ // Auto-resize observer for fluid layouts
588
+ const resizeObserver = new ResizeObserver((entries) => {
589
+ for (const entry of entries) {
590
+ const { width, height } = entry.contentRect;
591
+ if (width > 0 && height > 0 && graphRef.current) {
592
+ graphRef.current.resize(width, height);
593
+ }
594
+ }
595
+ });
596
+
597
+ resizeObserver.observe(container);
598
+
599
+ // Initial Hydration
600
+ if (svgPropRef.current) {
601
+ isHydratingRef.current = true;
602
+ try {
603
+ bridgeRef.current.hydrateFromSvg(svgPropRef.current, graph);
604
+ lastSerializedSvgRef.current = svgPropRef.current;
605
+ graph.centerContent();
606
+ } catch (err) {
607
+ console.error('RaidCanvas hydration error:', err);
608
+ } finally {
609
+ isHydratingRef.current = false;
610
+ }
611
+ }
612
+
613
+ // Cleanup
614
+ return () => {
615
+ if (debounceTimerRef.current) {
616
+ clearTimeout(debounceTimerRef.current);
617
+ }
618
+ window.removeEventListener('keydown', handleKeyDown);
619
+ resizeObserver.disconnect();
620
+ graph.dispose();
621
+ graphRef.current = null;
622
+ };
623
+ }, [readOnly, triggerDebouncedChange]);
624
+
625
+ // --------------------------------------------------------------------------
626
+ // 2. React to External SVG Changes
627
+ // --------------------------------------------------------------------------
628
+ useEffect(() => {
629
+ const graph = graphRef.current;
630
+ if (!graph || !activeSvg) return;
631
+
632
+ // Ignore if this change originated from our own serialization
633
+ if (activeSvg === lastSerializedSvgRef.current) {
634
+ return;
635
+ }
636
+
637
+ isHydratingRef.current = true;
638
+ try {
639
+ bridgeRef.current.hydrateFromSvg(activeSvg, graph);
640
+ lastSerializedSvgRef.current = activeSvg;
641
+ graph.centerContent();
642
+ } catch (err) {
643
+ console.error('RaidCanvas re-hydration error:', err);
644
+ } finally {
645
+ isHydratingRef.current = false;
646
+ }
647
+ }, [activeSvg]);
648
+
649
+ // --------------------------------------------------------------------------
650
+ // 3. HTML5 Drag-and-Drop Stencil Dropzone
651
+ // --------------------------------------------------------------------------
652
+ const handleDragOver = (e: React.DragEvent) => {
653
+ if (readOnly) return;
654
+ const hasStencil =
655
+ e.dataTransfer.types.includes('application/aoaim-kind') ||
656
+ e.dataTransfer.types.includes('text/plain');
657
+ if (hasStencil) {
658
+ e.preventDefault();
659
+ e.dataTransfer.dropEffect = 'copy';
660
+ if (!isDragOver) setIsDragOver(true);
661
+ }
662
+ };
663
+
664
+ const handleDragLeave = (e: React.DragEvent) => {
665
+ if (readOnly) return;
666
+ if (e.currentTarget.contains(e.relatedTarget as Node)) return;
667
+ setIsDragOver(false);
668
+ };
669
+
670
+ const handleDrop = (e: React.DragEvent) => {
671
+ if (readOnly) return;
672
+ e.preventDefault();
673
+ setIsDragOver(false);
674
+
675
+ const kind = (e.dataTransfer.getData('application/aoaim-kind') ||
676
+ e.dataTransfer.getData('text/plain')) as AimOntologyKind;
677
+
678
+ if (!kind || !graphRef.current || !containerRef.current) return;
679
+
680
+ const rect = containerRef.current.getBoundingClientRect();
681
+ const clientX = e.clientX - rect.left;
682
+ const clientY = e.clientY - rect.top;
683
+
684
+ const graph = graphRef.current;
685
+ const localPos = graph.clientToLocal({ x: clientX, y: clientY });
686
+
687
+ const defaultBounds = getDefaultNodeBounds(kind, localPos.x, localPos.y);
688
+ const bounds = {
689
+ ...defaultBounds,
690
+ x: Math.round(localPos.x - defaultBounds.width / 2),
691
+ y: Math.round(localPos.y - defaultBounds.height / 2),
692
+ };
693
+
694
+ const defaultName = getDefaultNodeName(kind);
695
+ const id = `${kind.toUpperCase()}_${Date.now().toString(36).slice(-4)}`;
696
+
697
+ const nodeData: RaidNodeData = {
698
+ id,
699
+ kind,
700
+ displayName: defaultName,
701
+ bounds,
702
+ };
703
+
704
+ const nodeMeta = createAimNode(nodeData);
705
+ graph.addNode(nodeMeta);
706
+
707
+ selectedCellIdRef.current = id;
708
+ onSelectRef.current?.({ id, kind, label: defaultName });
709
+ onSelectionChangeRef.current?.([id]);
710
+ onDropStencilRef.current?.(kind, { x: bounds.x, y: bounds.y });
711
+ triggerDebouncedChange();
712
+ };
713
+
714
+ return (
715
+ <div
716
+ className={`raid-canvas-wrapper ${className ?? ''}`.trim()}
717
+ onDragOver={handleDragOver}
718
+ onDragLeave={handleDragLeave}
719
+ onDrop={handleDrop}
720
+ style={{
721
+ position: 'relative',
722
+ width: '100%',
723
+ height: '100%',
724
+ minHeight: 300,
725
+ overflow: 'hidden',
726
+ background: isDragOver ? '#F0FDF4' : CascaisPalette.ChalkWhite,
727
+ outline: isDragOver ? `2px dashed ${CascaisPalette.HeraldicGreen}` : 'none',
728
+ outlineOffset: -4,
729
+ transition: 'background-color 0.2s ease, outline 0.2s ease',
730
+ ...style,
731
+ }}
732
+ >
733
+ {/* Graph Mount Target */}
734
+ <div
735
+ ref={containerRef}
736
+ style={{
737
+ width: '100%',
738
+ height: '100%',
739
+ position: 'absolute',
740
+ inset: 0,
741
+ }}
742
+ />
743
+
744
+ {/* Dropzone Overlay Badge (active during drag over) */}
745
+ {isDragOver && (
746
+ <div
747
+ style={{
748
+ position: 'absolute',
749
+ top: 16,
750
+ left: '50%',
751
+ transform: 'translateX(-50%)',
752
+ zIndex: 20,
753
+ padding: '6px 16px',
754
+ background: '#10B981',
755
+ color: '#FFFFFF',
756
+ borderRadius: 20,
757
+ fontSize: 12,
758
+ fontWeight: 600,
759
+ boxShadow: '0 4px 12px rgba(16, 185, 129, 0.3)',
760
+ pointerEvents: 'none',
761
+ }}
762
+ >
763
+ Drop to Instantiate AOAIM Node
764
+ </div>
765
+ )}
766
+
767
+ {/* Built-in Navigation & Zoom Controls */}
768
+ {showToolbar && (
769
+ <div
770
+ className="raid-canvas-toolbar"
771
+ style={{
772
+ position: 'absolute',
773
+ bottom: 16,
774
+ left: 16,
775
+ zIndex: 10,
776
+ display: 'inline-flex',
777
+ alignItems: 'center',
778
+ gap: 2,
779
+ padding: '4px 6px',
780
+ background: 'rgba(255, 255, 255, 0.92)',
781
+ backdropFilter: 'blur(8px)',
782
+ border: `1px solid ${CascaisPalette.SilverLineDark}`,
783
+ borderRadius: 8,
784
+ boxShadow: '0 4px 12px rgba(0, 0, 0, 0.08)',
785
+ fontFamily: 'Inter, system-ui, sans-serif',
786
+ userSelect: 'none',
787
+ }}
788
+ >
789
+ <button
790
+ type="button"
791
+ title="Zoom In"
792
+ aria-label="Zoom In"
793
+ onClick={handleZoomIn}
794
+ style={toolbarBtnStyle}
795
+ >
796
+
797
+ </button>
798
+ <button
799
+ type="button"
800
+ title="Zoom Out"
801
+ aria-label="Zoom Out"
802
+ onClick={handleZoomOut}
803
+ style={toolbarBtnStyle}
804
+ >
805
+
806
+ </button>
807
+ <span
808
+ style={{
809
+ padding: '0 6px',
810
+ fontSize: 11,
811
+ fontWeight: 600,
812
+ color: CascaisPalette.TextSecondary,
813
+ minWidth: 42,
814
+ textAlign: 'center',
815
+ }}
816
+ >
817
+ {zoomLevel}%
818
+ </span>
819
+ <div
820
+ style={{
821
+ width: 1,
822
+ height: 16,
823
+ background: CascaisPalette.SilverLine,
824
+ margin: '0 2px',
825
+ }}
826
+ />
827
+ <button
828
+ type="button"
829
+ title="Fit to Content"
830
+ aria-label="Fit to Content"
831
+ onClick={handleFitToContent}
832
+ style={toolbarBtnStyle}
833
+ >
834
+
835
+ </button>
836
+ <button
837
+ type="button"
838
+ title="Center Content"
839
+ aria-label="Center Content"
840
+ onClick={handleCenter}
841
+ style={toolbarBtnStyle}
842
+ >
843
+ 🎯
844
+ </button>
845
+ <button
846
+ type="button"
847
+ title="Reset View (100%)"
848
+ aria-label="Reset View"
849
+ onClick={handleResetView}
850
+ style={toolbarBtnStyle}
851
+ >
852
+
853
+ </button>
854
+ </div>
855
+ )}
856
+ </div>
857
+ );
858
+ },
859
+ );
860
+
861
+ const toolbarBtnStyle: React.CSSProperties = {
862
+ display: 'inline-flex',
863
+ alignItems: 'center',
864
+ justifyContent: 'center',
865
+ width: 28,
866
+ height: 28,
867
+ padding: 0,
868
+ border: 'none',
869
+ background: 'transparent',
870
+ borderRadius: 6,
871
+ cursor: 'pointer',
872
+ fontSize: 12,
873
+ color: CascaisPalette.WarmGraphite,
874
+ transition: 'background-color 0.15s ease',
875
+ };