@utisha/graph-editor 1.0.0-beta.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.
@@ -0,0 +1,1564 @@
1
+ import * as i0 from '@angular/core';
2
+ import { EventEmitter, viewChild, signal, computed, Output, Input, ChangeDetectionStrategy, Component } from '@angular/core';
3
+
4
+ /**
5
+ * Main graph editor component.
6
+ *
7
+ * @example
8
+ * <graph-editor
9
+ * [config]="editorConfig"
10
+ * [graph]="currentGraph()"
11
+ * (graphChange)="onGraphChange($event)"
12
+ * />
13
+ */
14
+ class GraphEditorComponent {
15
+ // Inputs
16
+ config;
17
+ graph = { nodes: [], edges: [] };
18
+ readonly = false;
19
+ visualizationMode = false;
20
+ overlayData;
21
+ // Outputs
22
+ graphChange = new EventEmitter();
23
+ nodeAdded = new EventEmitter();
24
+ nodeUpdated = new EventEmitter();
25
+ nodeRemoved = new EventEmitter();
26
+ edgeAdded = new EventEmitter();
27
+ edgeUpdated = new EventEmitter();
28
+ edgeRemoved = new EventEmitter();
29
+ selectionChange = new EventEmitter();
30
+ validationChange = new EventEmitter();
31
+ nodeClick = new EventEmitter();
32
+ nodeDoubleClick = new EventEmitter();
33
+ edgeClick = new EventEmitter();
34
+ edgeDoubleClick = new EventEmitter();
35
+ canvasClick = new EventEmitter();
36
+ contextMenu = new EventEmitter();
37
+ canvasSvgRef = viewChild('canvasSvg');
38
+ // Internal state
39
+ internalGraph = signal({ nodes: [], edges: [] });
40
+ selection = signal({ nodes: [], edges: [] });
41
+ validationResult = signal(null);
42
+ // Pan & Zoom state
43
+ panX = signal(0);
44
+ panY = signal(0);
45
+ scale = signal(1);
46
+ // Dragging state
47
+ draggedNode = null;
48
+ dragOffset = { x: 0, y: 0 };
49
+ isPanning = false;
50
+ lastMousePos = { x: 0, y: 0 };
51
+ draggedEdge = null;
52
+ hoveredNodeId = null;
53
+ hoveredPort = null;
54
+ // Attachment points visibility
55
+ showAttachmentPoints = signal(null); // nodeId to show ports for
56
+ // Active tool
57
+ activeTool = signal('hand');
58
+ // Line tool state
59
+ pendingEdge = null;
60
+ // Preview line for line tool (rubber-band from source to cursor)
61
+ previewLine = signal(null);
62
+ // Computed
63
+ transform = computed(() => `translate(${this.panX()}, ${this.panY()}) scale(${this.scale()})`);
64
+ gridBounds = computed(() => {
65
+ const gridSize = this.config.canvas?.grid?.size || 20;
66
+ const viewportWidth = 10000; // Large enough to cover any reasonable viewport
67
+ const viewportHeight = 10000;
68
+ // Calculate grid offset to align with pan
69
+ const x = Math.floor(-this.panX() / this.scale() / gridSize) * gridSize - viewportWidth / 2;
70
+ const y = Math.floor(-this.panY() / this.scale() / gridSize) * gridSize - viewportHeight / 2;
71
+ return {
72
+ x,
73
+ y,
74
+ width: viewportWidth * 2,
75
+ height: viewportHeight * 2
76
+ };
77
+ });
78
+ // Selected edge info for direction selector positioning
79
+ selectedEdgeMidpoint = computed(() => {
80
+ const sel = this.selection();
81
+ if (sel.edges.length !== 1)
82
+ return null;
83
+ const edge = this.internalGraph().edges.find(e => e.id === sel.edges[0]);
84
+ if (!edge)
85
+ return null;
86
+ const sourcePoint = this.getEdgeSourcePoint(edge);
87
+ const targetPoint = this.getEdgeTargetPoint(edge);
88
+ const midX = (sourcePoint.x + targetPoint.x) / 2;
89
+ const midY = (sourcePoint.y + targetPoint.y) / 2;
90
+ return {
91
+ edge,
92
+ x: midX * this.scale() + this.panX(),
93
+ y: midY * this.scale() + this.panY()
94
+ };
95
+ });
96
+ constructor() { }
97
+ ngOnChanges(changes) {
98
+ // Sync graph input to internal signal
99
+ if (changes['graph'] && changes['graph'].currentValue) {
100
+ this.internalGraph.set(structuredClone(changes['graph'].currentValue));
101
+ }
102
+ }
103
+ ngOnInit() {
104
+ // Initialize with current graph value
105
+ if (this.graph) {
106
+ this.internalGraph.set(structuredClone(this.graph));
107
+ }
108
+ this.validate();
109
+ }
110
+ // Node operations
111
+ addNode(type, position) {
112
+ const nodeConfig = this.config.nodes.types.find(t => t.type === type);
113
+ if (!nodeConfig) {
114
+ throw new Error(`Unknown node type: ${type}`);
115
+ }
116
+ const newNode = {
117
+ id: this.generateId(),
118
+ type,
119
+ data: structuredClone(nodeConfig.defaultData),
120
+ position: position || { x: 100, y: 100 }
121
+ };
122
+ const graph = this.internalGraph();
123
+ this.internalGraph.set({
124
+ ...graph,
125
+ nodes: [...graph.nodes, newNode]
126
+ });
127
+ this.emitGraphChange();
128
+ this.nodeAdded.emit(newNode);
129
+ this.switchTool('hand');
130
+ return newNode;
131
+ }
132
+ removeNode(nodeId, removeAttachedEdges = false) {
133
+ const graph = this.internalGraph();
134
+ const removedNode = graph.nodes.find(n => n.id === nodeId);
135
+ this.internalGraph.set({
136
+ ...graph,
137
+ nodes: graph.nodes.filter(n => n.id !== nodeId),
138
+ edges: removeAttachedEdges
139
+ ? graph.edges.filter(e => e.source !== nodeId && e.target !== nodeId)
140
+ : graph.edges
141
+ });
142
+ this.selection.set({ nodes: [], edges: [] });
143
+ this.emitGraphChange();
144
+ if (removedNode)
145
+ this.nodeRemoved.emit(removedNode);
146
+ }
147
+ removeEdge(edgeId) {
148
+ const graph = this.internalGraph();
149
+ const removedEdge = graph.edges.find(e => e.id === edgeId);
150
+ this.internalGraph.set({
151
+ ...graph,
152
+ edges: graph.edges.filter(e => e.id !== edgeId)
153
+ });
154
+ this.selection.set({ nodes: [], edges: [] });
155
+ this.emitGraphChange();
156
+ if (removedEdge)
157
+ this.edgeRemoved.emit(removedEdge);
158
+ }
159
+ updateNode(nodeId, updates) {
160
+ const graph = this.internalGraph();
161
+ const nodeIndex = graph.nodes.findIndex(n => n.id === nodeId);
162
+ if (nodeIndex === -1)
163
+ return;
164
+ const updatedNodes = [...graph.nodes];
165
+ updatedNodes[nodeIndex] = { ...updatedNodes[nodeIndex], ...updates };
166
+ this.internalGraph.set({
167
+ ...graph,
168
+ nodes: updatedNodes
169
+ });
170
+ this.emitGraphChange();
171
+ }
172
+ // Selection
173
+ selectNode(nodeId) {
174
+ if (nodeId === null) {
175
+ this.selection.set({ nodes: [], edges: [] });
176
+ }
177
+ else {
178
+ this.selection.set({ nodes: [nodeId], edges: [] });
179
+ }
180
+ this.selectionChange.emit(this.selection());
181
+ }
182
+ selectEdge(edgeId) {
183
+ if (edgeId === null) {
184
+ this.selection.set({ nodes: [], edges: [] });
185
+ }
186
+ else {
187
+ this.selection.set({ nodes: [], edges: [edgeId] });
188
+ }
189
+ this.selectionChange.emit(this.selection());
190
+ }
191
+ onKeyDown(event) {
192
+ if (this.readonly || this.config.interaction?.readonly)
193
+ return;
194
+ // Escape: cancel line drawing, clear selection
195
+ if (event.key === 'Escape') {
196
+ this.pendingEdge = null;
197
+ this.previewLine.set(null);
198
+ this.selection.set({ nodes: [], edges: [] });
199
+ this.selectionChange.emit(this.selection());
200
+ this.showAttachmentPoints.set(null);
201
+ event.preventDefault();
202
+ return;
203
+ }
204
+ if (event.key === 'Delete' || event.key === 'Backspace') {
205
+ const sel = this.selection();
206
+ // Delete selected edges
207
+ if (sel.edges.length > 0) {
208
+ for (const edgeId of sel.edges) {
209
+ this.removeEdge(edgeId);
210
+ }
211
+ event.preventDefault();
212
+ return;
213
+ }
214
+ // Delete selected nodes (keep attached edges)
215
+ if (sel.nodes.length > 0) {
216
+ for (const nodeId of sel.nodes) {
217
+ this.removeNode(nodeId, false);
218
+ }
219
+ event.preventDefault();
220
+ return;
221
+ }
222
+ }
223
+ // Arrow keys: nudge selected node(s) — 1px default, 10px with Shift
224
+ if (event.key.startsWith('Arrow')) {
225
+ const sel = this.selection();
226
+ if (sel.nodes.length === 0)
227
+ return;
228
+ const step = event.shiftKey ? 10 : 1;
229
+ let dx = 0;
230
+ let dy = 0;
231
+ switch (event.key) {
232
+ case 'ArrowUp':
233
+ dy = -step;
234
+ break;
235
+ case 'ArrowDown':
236
+ dy = step;
237
+ break;
238
+ case 'ArrowLeft':
239
+ dx = -step;
240
+ break;
241
+ case 'ArrowRight':
242
+ dx = step;
243
+ break;
244
+ }
245
+ event.preventDefault();
246
+ const graph = this.internalGraph();
247
+ const updatedNodes = [...graph.nodes];
248
+ for (const nodeId of sel.nodes) {
249
+ const idx = updatedNodes.findIndex(n => n.id === nodeId);
250
+ if (idx === -1)
251
+ continue;
252
+ const pos = updatedNodes[idx].position;
253
+ updatedNodes[idx] = { ...updatedNodes[idx], position: { x: pos.x + dx, y: pos.y + dy } };
254
+ }
255
+ // Recalculate edge ports for moved nodes (atomic update)
256
+ const movedIds = new Set(sel.nodes);
257
+ const updatedEdges = graph.edges.map(edge => {
258
+ if (!movedIds.has(edge.source) && !movedIds.has(edge.target))
259
+ return edge;
260
+ const sourceNode = updatedNodes.find(n => n.id === edge.source);
261
+ const targetNode = updatedNodes.find(n => n.id === edge.target);
262
+ if (!sourceNode || !targetNode)
263
+ return edge;
264
+ const newSourcePort = this.findClosestPortForEdge(sourceNode, targetNode, 'source');
265
+ const newTargetPort = this.findClosestPortForEdge(targetNode, sourceNode, 'target');
266
+ if (edge.sourcePort === newSourcePort && edge.targetPort === newTargetPort)
267
+ return edge;
268
+ return { ...edge, sourcePort: newSourcePort, targetPort: newTargetPort };
269
+ });
270
+ this.internalGraph.set({ ...graph, nodes: updatedNodes, edges: updatedEdges });
271
+ this.emitGraphChange();
272
+ }
273
+ }
274
+ switchTool(tool) {
275
+ const previousTool = this.activeTool();
276
+ // Cancel any in-progress line drawing
277
+ this.pendingEdge = null;
278
+ this.previewLine.set(null);
279
+ this.showAttachmentPoints.set(null);
280
+ // Preserve node selection when switching hand → line
281
+ if (!(previousTool === 'hand' && tool === 'line')) {
282
+ this.selection.set({ nodes: [], edges: [] });
283
+ this.selectionChange.emit(this.selection());
284
+ }
285
+ this.activeTool.set(tool);
286
+ // Hand → line with a node selected: start edge from that node
287
+ if (previousTool === 'hand' && tool === 'line') {
288
+ const sel = this.selection();
289
+ if (sel.nodes.length === 1) {
290
+ this.pendingEdge = { sourceId: sel.nodes[0], sourcePort: 'bottom' };
291
+ }
292
+ }
293
+ }
294
+ /** @deprecated Use switchTool('line') instead */
295
+ switchToLineTool() {
296
+ this.switchTool('line');
297
+ }
298
+ onEdgeClick(event, edge) {
299
+ if (this.activeTool() !== 'hand')
300
+ return;
301
+ event.stopPropagation();
302
+ this.selectEdge(edge.id);
303
+ this.edgeClick.emit(edge);
304
+ }
305
+ onEdgeDoubleClick(event, edge) {
306
+ if (this.activeTool() !== 'hand')
307
+ return;
308
+ event.stopPropagation();
309
+ this.selectEdge(edge.id);
310
+ this.edgeDoubleClick.emit(edge);
311
+ }
312
+ clearSelection() {
313
+ this.selection.set({ nodes: [], edges: [] });
314
+ this.selectionChange.emit(this.selection());
315
+ }
316
+ // Validation
317
+ validate() {
318
+ if (!this.config.validation) {
319
+ const result = { valid: true, errors: [] };
320
+ this.validationResult.set(result);
321
+ return result;
322
+ }
323
+ const errors = this.config.validation.validators.flatMap(rule => rule.validator(this.internalGraph(), this.config));
324
+ const result = {
325
+ valid: errors.filter(e => e.severity !== 'warning').length === 0,
326
+ errors
327
+ };
328
+ this.validationResult.set(result);
329
+ this.validationChange.emit(result);
330
+ return result;
331
+ }
332
+ // Layout
333
+ async applyLayout(direction = 'TB') {
334
+ const graph = this.internalGraph();
335
+ if (graph.nodes.length === 0)
336
+ return;
337
+ // Dynamic import to avoid compile-time module resolution issues
338
+ const dagreModule = await import('dagre');
339
+ const dagre = dagreModule.default ?? dagreModule;
340
+ const g = new dagre.graphlib.Graph();
341
+ g.setGraph({
342
+ rankdir: direction,
343
+ nodesep: 60,
344
+ ranksep: 80,
345
+ marginx: 40,
346
+ marginy: 40,
347
+ });
348
+ g.setDefaultEdgeLabel(() => ({}));
349
+ for (const node of graph.nodes) {
350
+ const size = this.getNodeSize(node);
351
+ g.setNode(node.id, { width: size.width, height: size.height });
352
+ }
353
+ for (const edge of graph.edges) {
354
+ g.setEdge(edge.source, edge.target);
355
+ }
356
+ dagre.layout(g);
357
+ const updatedNodes = graph.nodes.map(node => {
358
+ const dagreNode = g.node(node.id);
359
+ if (!dagreNode)
360
+ return node;
361
+ const size = this.getNodeSize(node);
362
+ return {
363
+ ...node,
364
+ position: {
365
+ x: dagreNode.x - size.width / 2,
366
+ y: dagreNode.y - size.height / 2,
367
+ },
368
+ };
369
+ });
370
+ this.internalGraph.set({ ...graph, nodes: updatedNodes });
371
+ this.emitGraphChange();
372
+ setTimeout(() => this.fitToScreen());
373
+ }
374
+ fitToScreen(padding = 40) {
375
+ const nodes = this.internalGraph().nodes;
376
+ if (nodes.length === 0)
377
+ return;
378
+ // Get SVG element dimensions
379
+ const ref = this.canvasSvgRef();
380
+ const svgEl = ref?.nativeElement ?? ref ?? null;
381
+ if (!svgEl || typeof svgEl.getBoundingClientRect !== 'function')
382
+ return;
383
+ const rect = svgEl.getBoundingClientRect();
384
+ const viewW = rect.width;
385
+ const viewH = rect.height;
386
+ if (viewW === 0 || viewH === 0)
387
+ return;
388
+ // Calculate bounding box of all nodes
389
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
390
+ for (const node of nodes) {
391
+ const size = this.getNodeSize(node);
392
+ minX = Math.min(minX, node.position.x);
393
+ minY = Math.min(minY, node.position.y);
394
+ maxX = Math.max(maxX, node.position.x + size.width);
395
+ maxY = Math.max(maxY, node.position.y + size.height);
396
+ }
397
+ const contentW = maxX - minX;
398
+ const contentH = maxY - minY;
399
+ // Handle single node or all nodes stacked
400
+ if (contentW <= 0 && contentH <= 0) {
401
+ this.scale.set(1);
402
+ this.panX.set(viewW / 2 - (minX + 110) * 1);
403
+ this.panY.set(viewH / 2 - (minY + 50) * 1);
404
+ return;
405
+ }
406
+ // Calculate scale to fit content with padding (cap at 1 to avoid zooming in too much)
407
+ const zoomConfig = this.config.canvas?.zoom;
408
+ const minScale = zoomConfig?.min ?? 0.25;
409
+ const scaleX = contentW > 0 ? (viewW - padding * 2) / contentW : 1;
410
+ const scaleY = contentH > 0 ? (viewH - padding * 2) / contentH : 1;
411
+ const newScale = Math.max(minScale, Math.min(1, Math.min(scaleX, scaleY)));
412
+ // Center the content in the viewport
413
+ const centerX = (minX + maxX) / 2;
414
+ const centerY = (minY + maxY) / 2;
415
+ const newPanX = viewW / 2 - centerX * newScale;
416
+ const newPanY = viewH / 2 - centerY * newScale;
417
+ this.scale.set(newScale);
418
+ this.panX.set(newPanX);
419
+ this.panY.set(newPanY);
420
+ }
421
+ zoomTo(level) {
422
+ const zoomConfig = this.config.canvas?.zoom;
423
+ const min = zoomConfig?.min ?? 0.25;
424
+ const max = zoomConfig?.max ?? 2.0;
425
+ this.scale.set(Math.max(min, Math.min(max, level)));
426
+ }
427
+ getSelection() {
428
+ return this.selection();
429
+ }
430
+ // Event handlers
431
+ onCanvasMouseDown(event) {
432
+ if (this.readonly)
433
+ return;
434
+ // Cancel pending edge on empty space click
435
+ if (this.pendingEdge) {
436
+ this.pendingEdge = null;
437
+ this.previewLine.set(null);
438
+ this.showAttachmentPoints.set(null);
439
+ this.hoveredPort = null;
440
+ this.clearSelection();
441
+ }
442
+ const target = event.target;
443
+ const isNode = !!target.closest('.graph-node');
444
+ const isEdgeEndpoint = target.classList.contains('edge-endpoint');
445
+ const isAttachmentPoint = target.classList.contains('attachment-point');
446
+ const isHitArea = target.classList.contains('edge-hit-area');
447
+ const isInteractive = isNode || isEdgeEndpoint || isAttachmentPoint || isHitArea;
448
+ if (!isInteractive) {
449
+ this.isPanning = true;
450
+ this.lastMousePos = { x: event.clientX, y: event.clientY };
451
+ this.clearSelection();
452
+ event.preventDefault();
453
+ }
454
+ }
455
+ onCanvasMouseMove(event) {
456
+ if (this.isPanning) {
457
+ const dx = event.clientX - this.lastMousePos.x;
458
+ const dy = event.clientY - this.lastMousePos.y;
459
+ this.panX.set(this.panX() + dx);
460
+ this.panY.set(this.panY() + dy);
461
+ this.lastMousePos = { x: event.clientX, y: event.clientY };
462
+ }
463
+ else if (this.draggedNode) {
464
+ const rect = event.currentTarget.getBoundingClientRect();
465
+ const mouseX = (event.clientX - rect.left - this.panX()) / this.scale();
466
+ const mouseY = (event.clientY - rect.top - this.panY()) / this.scale();
467
+ let x = mouseX - this.dragOffset.x;
468
+ let y = mouseY - this.dragOffset.y;
469
+ // Smart snap to grid
470
+ if (this.config.canvas?.grid?.snap) {
471
+ const gridSize = this.config.canvas.grid.size || 20;
472
+ const snapThreshold = gridSize / 4;
473
+ const snapX = Math.round(x / gridSize) * gridSize;
474
+ const snapY = Math.round(y / gridSize) * gridSize;
475
+ if (Math.abs(x - snapX) < snapThreshold)
476
+ x = snapX;
477
+ if (Math.abs(y - snapY) < snapThreshold)
478
+ y = snapY;
479
+ }
480
+ // Atomic update: node position + edge port recalculation in one graph set
481
+ const graph = this.internalGraph();
482
+ const nodeIndex = graph.nodes.findIndex(n => n.id === this.draggedNode.id);
483
+ if (nodeIndex !== -1) {
484
+ const updatedNodes = [...graph.nodes];
485
+ updatedNodes[nodeIndex] = { ...updatedNodes[nodeIndex], position: { x, y } };
486
+ // Recalculate ports for all edges connected to this node
487
+ const draggedId = this.draggedNode.id;
488
+ const updatedEdges = graph.edges.map(edge => {
489
+ if (edge.source !== draggedId && edge.target !== draggedId)
490
+ return edge;
491
+ const sourceNode = updatedNodes.find(n => n.id === edge.source);
492
+ const targetNode = updatedNodes.find(n => n.id === edge.target);
493
+ if (!sourceNode || !targetNode)
494
+ return edge;
495
+ const newSourcePort = this.findClosestPortForEdge(sourceNode, targetNode, 'source');
496
+ const newTargetPort = this.findClosestPortForEdge(targetNode, sourceNode, 'target');
497
+ if (edge.sourcePort === newSourcePort && edge.targetPort === newTargetPort)
498
+ return edge;
499
+ return { ...edge, sourcePort: newSourcePort, targetPort: newTargetPort };
500
+ });
501
+ this.internalGraph.set({ ...graph, nodes: updatedNodes, edges: updatedEdges });
502
+ this.emitGraphChange();
503
+ }
504
+ }
505
+ else if (this.draggedEdge) {
506
+ // Edge reconnection - find hovered node and closest port
507
+ const rect = event.currentTarget.getBoundingClientRect();
508
+ const mouseX = (event.clientX - rect.left - this.panX()) / this.scale();
509
+ const mouseY = (event.clientY - rect.top - this.panY()) / this.scale();
510
+ // Find node under cursor
511
+ const nodeId = this.findNodeAtPosition({ x: mouseX, y: mouseY });
512
+ if (nodeId) {
513
+ // Show attachment points for this node
514
+ this.showAttachmentPoints.set(nodeId);
515
+ // Find closest port
516
+ const closestPort = this.findClosestPort(nodeId, { x: mouseX, y: mouseY });
517
+ // Highlight port if within snap distance (40px)
518
+ if (closestPort && closestPort.distance < 40) {
519
+ this.hoveredPort = { nodeId, port: closestPort.port };
520
+ this.hoveredNodeId = nodeId;
521
+ }
522
+ else {
523
+ this.hoveredPort = null;
524
+ this.hoveredNodeId = null;
525
+ }
526
+ }
527
+ else {
528
+ // No node nearby - hide attachment points
529
+ this.showAttachmentPoints.set(null);
530
+ this.hoveredPort = null;
531
+ this.hoveredNodeId = null;
532
+ }
533
+ }
534
+ else if (this.pendingEdge && this.activeTool() === 'line') {
535
+ // Line tool pending state - show rubber-band preview + attachment points on hovered node
536
+ const rect = event.currentTarget.getBoundingClientRect();
537
+ const mouseX = (event.clientX - rect.left - this.panX()) / this.scale();
538
+ const mouseY = (event.clientY - rect.top - this.panY()) / this.scale();
539
+ // Get source port position
540
+ const sourceNode = this.internalGraph().nodes.find(n => n.id === this.pendingEdge.sourceId);
541
+ if (sourceNode) {
542
+ const sourcePoint = this.getPortWorldPosition(sourceNode, this.pendingEdge.sourcePort);
543
+ // Check if cursor is near a node - snap to its closest port
544
+ const hoveredNodeId = this.findNodeAtPosition({ x: mouseX, y: mouseY });
545
+ let targetPoint = { x: mouseX, y: mouseY };
546
+ if (hoveredNodeId && hoveredNodeId !== this.pendingEdge.sourceId) {
547
+ // Show attachment points on hovered node
548
+ this.showAttachmentPoints.set(hoveredNodeId);
549
+ // Find and highlight closest port
550
+ const closestPort = this.findClosestPort(hoveredNodeId, { x: mouseX, y: mouseY });
551
+ if (closestPort && closestPort.distance < 40) {
552
+ this.hoveredPort = { nodeId: hoveredNodeId, port: closestPort.port };
553
+ // Snap preview line to port
554
+ const hoveredNode = this.internalGraph().nodes.find(n => n.id === hoveredNodeId);
555
+ if (hoveredNode) {
556
+ targetPoint = this.getPortWorldPosition(hoveredNode, closestPort.port);
557
+ }
558
+ }
559
+ else {
560
+ this.hoveredPort = null;
561
+ }
562
+ }
563
+ else {
564
+ // Not over a valid target node - hide attachment points
565
+ this.showAttachmentPoints.set(null);
566
+ this.hoveredPort = null;
567
+ }
568
+ this.previewLine.set({ source: sourcePoint, target: targetPoint });
569
+ }
570
+ }
571
+ }
572
+ onCanvasMouseUp(_event) {
573
+ // Handle edge reconnection with port snapping
574
+ if (this.draggedEdge && this.hoveredNodeId && this.hoveredPort) {
575
+ const graph = this.internalGraph();
576
+ const edgeIndex = graph.edges.findIndex(e => e.id === this.draggedEdge.edge.id);
577
+ if (edgeIndex !== -1) {
578
+ const updatedEdges = [...graph.edges];
579
+ const updatedEdge = { ...updatedEdges[edgeIndex] };
580
+ // Update node connection and store port information (non-null: guarded by if condition)
581
+ if (this.draggedEdge.endpoint === 'source') {
582
+ updatedEdge.source = this.hoveredNodeId;
583
+ updatedEdge.sourcePort = this.hoveredPort.port;
584
+ }
585
+ else {
586
+ updatedEdge.target = this.hoveredNodeId;
587
+ updatedEdge.targetPort = this.hoveredPort.port;
588
+ }
589
+ updatedEdges[edgeIndex] = updatedEdge;
590
+ this.internalGraph.set({ ...graph, edges: updatedEdges });
591
+ this.emitGraphChange();
592
+ this.edgeUpdated.emit(updatedEdge);
593
+ }
594
+ }
595
+ this.isPanning = false;
596
+ this.draggedNode = null;
597
+ this.draggedEdge = null;
598
+ this.hoveredNodeId = null;
599
+ this.hoveredPort = null;
600
+ this.showAttachmentPoints.set(null);
601
+ }
602
+ onNodeMouseDown(event, node) {
603
+ if (this.readonly)
604
+ return;
605
+ event.stopPropagation(); // Always prevent canvas from seeing node mousedowns
606
+ if (this.activeTool() !== 'hand')
607
+ return;
608
+ this.draggedNode = node;
609
+ // Calculate offset between mouse position and node origin to prevent jump
610
+ const svg = event.target.closest('svg');
611
+ const rect = svg.getBoundingClientRect();
612
+ const mouseX = (event.clientX - rect.left - this.panX()) / this.scale();
613
+ const mouseY = (event.clientY - rect.top - this.panY()) / this.scale();
614
+ this.dragOffset = {
615
+ x: mouseX - node.position.x,
616
+ y: mouseY - node.position.y
617
+ };
618
+ }
619
+ onNodeClick(event, node) {
620
+ if (this.activeTool() === 'line') {
621
+ event.stopPropagation();
622
+ if (!this.pendingEdge) {
623
+ // First click - start edge from this node
624
+ // Pick initial port based on geometry (will be recalculated on second click)
625
+ this.pendingEdge = { sourceId: node.id, sourcePort: 'bottom' };
626
+ this.selectNode(node.id);
627
+ }
628
+ else if (this.pendingEdge.sourceId !== node.id) {
629
+ // Second click on different node - complete the edge
630
+ const sourceNode = this.internalGraph().nodes.find(n => n.id === this.pendingEdge.sourceId);
631
+ if (sourceNode) {
632
+ const sourcePort = this.findClosestPortForEdge(sourceNode, node, 'source');
633
+ const targetPort = this.findClosestPortForEdge(node, sourceNode, 'target');
634
+ const newEdge = {
635
+ id: `edge_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
636
+ source: this.pendingEdge.sourceId,
637
+ target: node.id,
638
+ sourcePort,
639
+ targetPort
640
+ };
641
+ const graph = this.internalGraph();
642
+ this.internalGraph.set({
643
+ ...graph,
644
+ edges: [...graph.edges, newEdge]
645
+ });
646
+ this.emitGraphChange();
647
+ this.edgeAdded.emit(newEdge);
648
+ }
649
+ this.pendingEdge = null;
650
+ this.previewLine.set(null);
651
+ this.showAttachmentPoints.set(null);
652
+ this.hoveredPort = null;
653
+ this.clearSelection();
654
+ }
655
+ else {
656
+ // Clicked same node - cancel
657
+ this.pendingEdge = null;
658
+ this.previewLine.set(null);
659
+ this.showAttachmentPoints.set(null);
660
+ this.hoveredPort = null;
661
+ this.clearSelection();
662
+ }
663
+ }
664
+ else {
665
+ // Hand tool - normal select
666
+ this.selectNode(node.id);
667
+ }
668
+ }
669
+ onAttachmentPointClick(event, node, port) {
670
+ event.stopPropagation();
671
+ if (this.readonly)
672
+ return;
673
+ if (this.activeTool() === 'line') {
674
+ if (!this.pendingEdge) {
675
+ // First click on attachment point - start edge from this specific port
676
+ this.pendingEdge = { sourceId: node.id, sourcePort: port };
677
+ this.selectNode(node.id);
678
+ }
679
+ else if (this.pendingEdge.sourceId !== node.id) {
680
+ // Second click - complete edge to this specific port
681
+ const sourceNode = this.internalGraph().nodes.find(n => n.id === this.pendingEdge.sourceId);
682
+ if (sourceNode) {
683
+ const sourcePort = this.findClosestPortForEdge(sourceNode, node, 'source');
684
+ const newEdge = {
685
+ id: `edge_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
686
+ source: this.pendingEdge.sourceId,
687
+ target: node.id,
688
+ sourcePort,
689
+ targetPort: port
690
+ };
691
+ const graph = this.internalGraph();
692
+ this.internalGraph.set({
693
+ ...graph,
694
+ edges: [...graph.edges, newEdge]
695
+ });
696
+ this.emitGraphChange();
697
+ this.edgeAdded.emit(newEdge);
698
+ }
699
+ this.pendingEdge = null;
700
+ this.previewLine.set(null);
701
+ this.showAttachmentPoints.set(null);
702
+ this.hoveredPort = null;
703
+ this.clearSelection();
704
+ }
705
+ else {
706
+ // Clicked same node - cancel
707
+ this.pendingEdge = null;
708
+ this.previewLine.set(null);
709
+ this.showAttachmentPoints.set(null);
710
+ this.hoveredPort = null;
711
+ this.clearSelection();
712
+ }
713
+ }
714
+ }
715
+ onEdgeEndpointMouseDown(event, edge, endpoint) {
716
+ if (this.readonly)
717
+ return;
718
+ event.stopPropagation();
719
+ this.draggedEdge = { edge, endpoint };
720
+ }
721
+ onWheel(event) {
722
+ const zoomConfig = this.config.canvas?.zoom;
723
+ if (!zoomConfig?.wheelEnabled)
724
+ return;
725
+ event.preventDefault();
726
+ const delta = -event.deltaY;
727
+ const step = zoomConfig.step ?? 0.1;
728
+ const newScale = Math.max(zoomConfig.min ?? 0.25, Math.min(zoomConfig.max ?? 2.0, this.scale() + (delta > 0 ? step : -step)));
729
+ this.scale.set(newScale);
730
+ }
731
+ onContextMenu(event) {
732
+ event.preventDefault();
733
+ // TODO: Show context menu
734
+ }
735
+ // Helper methods
736
+ emitGraphChange() {
737
+ this.graphChange.emit(this.internalGraph());
738
+ if (this.config.validation?.validateOnChange) {
739
+ this.validate();
740
+ }
741
+ }
742
+ generateId() {
743
+ return `node_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
744
+ }
745
+ recalculateEdgePorts(nodeId) {
746
+ const graph = this.internalGraph();
747
+ let changed = false;
748
+ const updatedEdges = graph.edges.map(edge => {
749
+ if (edge.source !== nodeId && edge.target !== nodeId)
750
+ return edge;
751
+ const sourceNode = graph.nodes.find(n => n.id === edge.source);
752
+ const targetNode = graph.nodes.find(n => n.id === edge.target);
753
+ if (!sourceNode || !targetNode)
754
+ return edge;
755
+ const newSourcePort = this.findClosestPortForEdge(sourceNode, targetNode, 'source');
756
+ const newTargetPort = this.findClosestPortForEdge(targetNode, sourceNode, 'target');
757
+ if (edge.sourcePort === newSourcePort && edge.targetPort === newTargetPort)
758
+ return edge;
759
+ changed = true;
760
+ return { ...edge, sourcePort: newSourcePort, targetPort: newTargetPort };
761
+ });
762
+ if (changed) {
763
+ this.internalGraph.set({ ...graph, edges: updatedEdges });
764
+ }
765
+ }
766
+ getNodeSize(node) {
767
+ const nodeConfig = this.config.nodes.types.find(t => t.type === node.type);
768
+ return nodeConfig?.size || this.config.nodes.defaultSize || { width: 220, height: 100 };
769
+ }
770
+ getEdgePath(edge) {
771
+ const sourceNode = this.internalGraph().nodes.find(n => n.id === edge.source);
772
+ const targetNode = this.internalGraph().nodes.find(n => n.id === edge.target);
773
+ if (!sourceNode || !targetNode)
774
+ return '';
775
+ // Get port positions from edge or calculate closest
776
+ const sourcePort = edge.sourcePort || this.findClosestPortForEdge(sourceNode, targetNode, 'source');
777
+ const targetPort = edge.targetPort || this.findClosestPortForEdge(targetNode, sourceNode, 'target');
778
+ const sourcePoint = this.getPortWorldPosition(sourceNode, sourcePort);
779
+ const targetPoint = this.getPortWorldPosition(targetNode, targetPort);
780
+ // Simple straight line
781
+ return `M ${sourcePoint.x},${sourcePoint.y} L ${targetPoint.x},${targetPoint.y}`;
782
+ }
783
+ getEdgeColor(edge) {
784
+ return edge.metadata?.style?.stroke || this.config.edges.style?.stroke || '#94a3b8';
785
+ }
786
+ getEdgeMarkerEnd(edge) {
787
+ const dir = edge.direction || 'forward';
788
+ const selected = this.selection().edges.includes(edge.id);
789
+ if (dir === 'forward' || dir === 'bidirectional') {
790
+ return selected ? 'url(#arrow-end-selected)' : 'url(#arrow-end)';
791
+ }
792
+ return null;
793
+ }
794
+ getEdgeMarkerStart(edge) {
795
+ const dir = edge.direction || 'forward';
796
+ const selected = this.selection().edges.includes(edge.id);
797
+ if (dir === 'backward' || dir === 'bidirectional') {
798
+ return selected ? 'url(#arrow-start-selected)' : 'url(#arrow-start)';
799
+ }
800
+ return null;
801
+ }
802
+ setEdgeDirection(direction) {
803
+ const sel = this.selection();
804
+ if (sel.edges.length !== 1)
805
+ return;
806
+ const graph = this.internalGraph();
807
+ const edgeIndex = graph.edges.findIndex(e => e.id === sel.edges[0]);
808
+ if (edgeIndex === -1)
809
+ return;
810
+ const updatedEdges = [...graph.edges];
811
+ updatedEdges[edgeIndex] = { ...updatedEdges[edgeIndex], direction };
812
+ this.internalGraph.set({ ...graph, edges: updatedEdges });
813
+ this.emitGraphChange();
814
+ this.edgeUpdated.emit(updatedEdges[edgeIndex]);
815
+ }
816
+ getEdgeSourcePoint(edge) {
817
+ const sourceNode = this.internalGraph().nodes.find(n => n.id === edge.source);
818
+ const targetNode = this.internalGraph().nodes.find(n => n.id === edge.target);
819
+ if (!sourceNode || !targetNode)
820
+ return { x: 0, y: 0 };
821
+ const sourcePort = edge.sourcePort || this.findClosestPortForEdge(sourceNode, targetNode, 'source');
822
+ return this.getPortWorldPosition(sourceNode, sourcePort);
823
+ }
824
+ getEdgeTargetPoint(edge) {
825
+ const sourceNode = this.internalGraph().nodes.find(n => n.id === edge.source);
826
+ const targetNode = this.internalGraph().nodes.find(n => n.id === edge.target);
827
+ if (!sourceNode || !targetNode)
828
+ return { x: 0, y: 0 };
829
+ const targetPort = edge.targetPort || this.findClosestPortForEdge(targetNode, sourceNode, 'target');
830
+ return this.getPortWorldPosition(targetNode, targetPort);
831
+ }
832
+ getNodeTypeIcon(node) {
833
+ const nodeConfig = this.config.nodes.types.find(t => t.type === node.type);
834
+ return nodeConfig?.icon || '●';
835
+ }
836
+ findNodeAtPosition(pos) {
837
+ for (const node of this.internalGraph().nodes) {
838
+ const size = this.getNodeSize(node);
839
+ if (pos.x >= node.position.x &&
840
+ pos.x <= node.position.x + size.width &&
841
+ pos.y >= node.position.y &&
842
+ pos.y <= node.position.y + size.height) {
843
+ return node.id;
844
+ }
845
+ }
846
+ return null;
847
+ }
848
+ getNodePorts(node) {
849
+ const size = this.getNodeSize(node);
850
+ return [
851
+ { position: 'top', x: size.width / 2, y: 0 },
852
+ { position: 'bottom', x: size.width / 2, y: size.height },
853
+ { position: 'left', x: 0, y: size.height / 2 },
854
+ { position: 'right', x: size.width, y: size.height / 2 }
855
+ ];
856
+ }
857
+ findClosestPort(nodeId, worldPos) {
858
+ const node = this.internalGraph().nodes.find(n => n.id === nodeId);
859
+ if (!node)
860
+ return null;
861
+ const ports = this.getNodePorts(node);
862
+ let closestPort = null;
863
+ let minDistance = Infinity;
864
+ for (const port of ports) {
865
+ const portWorldX = node.position.x + port.x;
866
+ const portWorldY = node.position.y + port.y;
867
+ const dx = worldPos.x - portWorldX;
868
+ const dy = worldPos.y - portWorldY;
869
+ const distance = Math.sqrt(dx * dx + dy * dy);
870
+ if (distance < minDistance) {
871
+ minDistance = distance;
872
+ closestPort = port;
873
+ }
874
+ }
875
+ return closestPort ? { port: closestPort.position, distance: minDistance } : null;
876
+ }
877
+ getPortWorldPosition(node, port) {
878
+ const size = this.getNodeSize(node);
879
+ const portOffsets = {
880
+ top: { x: size.width / 2, y: 0 },
881
+ bottom: { x: size.width / 2, y: size.height },
882
+ left: { x: 0, y: size.height / 2 },
883
+ right: { x: size.width, y: size.height / 2 }
884
+ };
885
+ const offset = portOffsets[port];
886
+ return {
887
+ x: node.position.x + offset.x,
888
+ y: node.position.y + offset.y
889
+ };
890
+ }
891
+ findClosestPortForEdge(node, otherNode, endpoint) {
892
+ const size = this.getNodeSize(node);
893
+ const nodeCenter = {
894
+ x: node.position.x + size.width / 2,
895
+ y: node.position.y + size.height / 2
896
+ };
897
+ const otherSize = this.getNodeSize(otherNode);
898
+ const otherCenter = {
899
+ x: otherNode.position.x + otherSize.width / 2,
900
+ y: otherNode.position.y + otherSize.height / 2
901
+ };
902
+ const dx = otherCenter.x - nodeCenter.x;
903
+ const dy = otherCenter.y - nodeCenter.y;
904
+ // Determine which port is closest based on relative position
905
+ const absDx = Math.abs(dx);
906
+ const absDy = Math.abs(dy);
907
+ if (absDx > absDy) {
908
+ // Horizontal connection
909
+ return dx > 0 ? 'right' : 'left';
910
+ }
911
+ else {
912
+ // Vertical connection
913
+ return dy > 0 ? 'bottom' : 'top';
914
+ }
915
+ }
916
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.19", ngImport: i0, type: GraphEditorComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
917
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "19.2.19", type: GraphEditorComponent, isStandalone: true, selector: "graph-editor", inputs: { config: "config", graph: "graph", readonly: "readonly", visualizationMode: "visualizationMode", overlayData: "overlayData" }, outputs: { graphChange: "graphChange", nodeAdded: "nodeAdded", nodeUpdated: "nodeUpdated", nodeRemoved: "nodeRemoved", edgeAdded: "edgeAdded", edgeUpdated: "edgeUpdated", edgeRemoved: "edgeRemoved", selectionChange: "selectionChange", validationChange: "validationChange", nodeClick: "nodeClick", nodeDoubleClick: "nodeDoubleClick", edgeClick: "edgeClick", edgeDoubleClick: "edgeDoubleClick", canvasClick: "canvasClick", contextMenu: "contextMenu" }, host: { attributes: { "tabindex": "0" }, listeners: { "keydown": "onKeyDown($event)" }, styleAttribute: "outline: none;" }, viewQueries: [{ propertyName: "canvasSvgRef", first: true, predicate: ["canvasSvg"], descendants: true, isSignal: true }], usesOnChanges: true, ngImport: i0, template: `
918
+ <div class="graph-editor-container">
919
+ <!-- Canvas with overlaid palette -->
920
+ <div class="graph-canvas-wrapper">
921
+ <!-- Top-left horizontal palette overlay -->
922
+ @if (config.palette?.enabled !== false) {
923
+ <div class="graph-palette-overlay">
924
+ <!-- Tools -->
925
+ <button
926
+ class="palette-item tool-item"
927
+ [class.active]="activeTool() === 'hand'"
928
+ title="Hand tool (move nodes)"
929
+ (click)="switchTool('hand')"
930
+ >
931
+ <span class="icon">✋</span>
932
+ </button>
933
+ <button
934
+ class="palette-item tool-item"
935
+ [class.active]="activeTool() === 'line'"
936
+ title="Line tool (draw connections)"
937
+ (click)="switchTool('line')"
938
+ >
939
+ <span class="icon">∕</span>
940
+ </button>
941
+
942
+ <!-- Divider -->
943
+ <div class="palette-divider"></div>
944
+
945
+ <!-- Node types -->
946
+ @for (nodeType of config.nodes.types; track nodeType.type) {
947
+ <button
948
+ class="palette-item"
949
+ [attr.data-node-type]="nodeType.type"
950
+ [attr.title]="nodeType.label || nodeType.type"
951
+ (click)="addNode(nodeType.type)"
952
+ >
953
+ <span class="icon">{{ nodeType.icon || '●' }}</span>
954
+ </button>
955
+ }
956
+ </div>
957
+ }
958
+
959
+ <svg
960
+ #canvasSvg
961
+ [class.tool-line]="activeTool() === 'line'"
962
+ [attr.width]="'100%'"
963
+ [attr.height]="'100%'"
964
+ (mousedown)="onCanvasMouseDown($event)"
965
+ (mousemove)="onCanvasMouseMove($event)"
966
+ (mouseup)="onCanvasMouseUp($event)"
967
+ (wheel)="onWheel($event)"
968
+ (contextmenu)="onContextMenu($event)"
969
+ >
970
+ <!-- Arrow marker definitions -->
971
+ <defs>
972
+ <marker id="arrow-end" viewBox="0 0 10 10" refX="9" refY="5"
973
+ markerWidth="8" markerHeight="8" orient="auto">
974
+ <path d="M 0 1 L 8 5 L 0 9 z" fill="#94a3b8"/>
975
+ </marker>
976
+ <marker id="arrow-end-selected" viewBox="0 0 10 10" refX="9" refY="5"
977
+ markerWidth="8" markerHeight="8" orient="auto">
978
+ <path d="M 0 1 L 8 5 L 0 9 z" fill="#3b82f6"/>
979
+ </marker>
980
+ <marker id="arrow-start" viewBox="0 0 10 10" refX="1" refY="5"
981
+ markerWidth="8" markerHeight="8" orient="auto">
982
+ <path d="M 10 1 L 2 5 L 10 9 z" fill="#94a3b8"/>
983
+ </marker>
984
+ <marker id="arrow-start-selected" viewBox="0 0 10 10" refX="1" refY="5"
985
+ markerWidth="8" markerHeight="8" orient="auto">
986
+ <path d="M 10 1 L 2 5 L 10 9 z" fill="#3b82f6"/>
987
+ </marker>
988
+ </defs>
989
+
990
+ <!-- Main transform group (pan + zoom) -->
991
+ <g [attr.transform]="transform()">
992
+ <!-- Grid (if enabled) -->
993
+ <!-- Grid (if enabled) - extended to cover viewport during pan -->
994
+ @if (config.canvas?.grid?.enabled) {
995
+ <defs>
996
+ <pattern
997
+ id="grid"
998
+ [attr.width]="config.canvas!.grid!.size"
999
+ [attr.height]="config.canvas!.grid!.size"
1000
+ patternUnits="userSpaceOnUse"
1001
+ >
1002
+ <path
1003
+ [attr.d]="'M ' + config.canvas!.grid!.size + ' 0 L 0 0 0 ' + config.canvas!.grid!.size"
1004
+ fill="none"
1005
+ [attr.stroke]="config.canvas!.grid!.color || '#e0e0e0'"
1006
+ stroke-width="1"
1007
+ />
1008
+ </pattern>
1009
+ </defs>
1010
+ <!-- Extended grid background covering viewport + pan offset -->
1011
+ <rect
1012
+ [attr.x]="gridBounds().x"
1013
+ [attr.y]="gridBounds().y"
1014
+ [attr.width]="gridBounds().width"
1015
+ [attr.height]="gridBounds().height"
1016
+ fill="url(#grid)"
1017
+ />
1018
+ }
1019
+
1020
+ <!-- Layer 0.5: Preview line for line tool (rubber-band) -->
1021
+ @if (previewLine()) {
1022
+ <line
1023
+ [attr.x1]="previewLine()!.source.x"
1024
+ [attr.y1]="previewLine()!.source.y"
1025
+ [attr.x2]="previewLine()!.target.x"
1026
+ [attr.y2]="previewLine()!.target.y"
1027
+ stroke="#3b82f6"
1028
+ stroke-width="2"
1029
+ stroke-dasharray="6,4"
1030
+ opacity="0.6"
1031
+ />
1032
+ }
1033
+
1034
+ <!-- Layer 1: Edge paths (behind everything) -->
1035
+ @for (edge of internalGraph().edges; track edge.id) {
1036
+ <!-- Invisible wide hit-area for easier clicking (hand tool only) -->
1037
+ <path
1038
+ [attr.d]="getEdgePath(edge)"
1039
+ stroke="transparent"
1040
+ [attr.stroke-width]="16"
1041
+ fill="none"
1042
+ class="edge-hit-area"
1043
+ [attr.pointer-events]="activeTool() === 'hand' ? 'stroke' : 'none'"
1044
+ (click)="onEdgeClick($event, edge)"
1045
+ (dblclick)="onEdgeDoubleClick($event, edge)"
1046
+ />
1047
+ <!-- Visible edge line -->
1048
+ <path
1049
+ [attr.d]="getEdgePath(edge)"
1050
+ [attr.stroke]="getEdgeColor(edge)"
1051
+ [attr.stroke-width]="2"
1052
+ fill="none"
1053
+ [class.selected]="selection().edges.includes(edge.id)"
1054
+ [attr.marker-end]="getEdgeMarkerEnd(edge)"
1055
+ [attr.marker-start]="getEdgeMarkerStart(edge)"
1056
+ pointer-events="none"
1057
+ />
1058
+ }
1059
+
1060
+ <!-- Layer 2: Nodes -->
1061
+ @for (node of internalGraph().nodes; track node.id) {
1062
+ <g
1063
+ [attr.transform]="'translate(' + node.position.x + ',' + node.position.y + ')'"
1064
+ class="graph-node"
1065
+ [class.selected]="selection().nodes.includes(node.id)"
1066
+ [attr.data-node-id]="node.id"
1067
+ (mousedown)="onNodeMouseDown($event, node)"
1068
+ (click)="onNodeClick($event, node)"
1069
+ (dblclick)="nodeDoubleClick.emit(node)"
1070
+ >
1071
+ <!-- Node background -->
1072
+ <rect
1073
+ [attr.width]="getNodeSize(node).width"
1074
+ [attr.height]="getNodeSize(node).height"
1075
+ [attr.fill]="'white'"
1076
+ [attr.stroke]="selection().nodes.includes(node.id) ? '#3b82f6' : '#cbd5e0'"
1077
+ [attr.stroke-width]="selection().nodes.includes(node.id) ? 3 : 2"
1078
+ rx="8"
1079
+ />
1080
+
1081
+ <!-- Node type icon badge (top-left, with padding from corner) -->
1082
+ <g class="node-type-badge">
1083
+ <circle
1084
+ cx="28"
1085
+ cy="28"
1086
+ r="16"
1087
+ fill="#f3f4f6"
1088
+ stroke="#cbd5e0"
1089
+ stroke-width="2"
1090
+ />
1091
+ <text
1092
+ x="28"
1093
+ y="28"
1094
+ text-anchor="middle"
1095
+ dominant-baseline="middle"
1096
+ font-size="20"
1097
+ fill="#374151"
1098
+ >
1099
+ {{ getNodeTypeIcon(node) }}
1100
+ </text>
1101
+ </g>
1102
+
1103
+ <!-- Node label -->
1104
+ <text
1105
+ [attr.x]="getNodeSize(node).width / 2"
1106
+ [attr.y]="getNodeSize(node).height / 2"
1107
+ text-anchor="middle"
1108
+ dominant-baseline="middle"
1109
+ font-size="14"
1110
+ >
1111
+ {{ node.data['name'] || node.type }}
1112
+ </text>
1113
+ </g>
1114
+ }
1115
+
1116
+ <!-- Layer 3: Attachment points (on top of nodes) -->
1117
+ @for (node of internalGraph().nodes; track node.id) {
1118
+ @if (showAttachmentPoints() === node.id) {
1119
+ <g [attr.transform]="'translate(' + node.position.x + ',' + node.position.y + ')'">
1120
+ @for (port of getNodePorts(node); track port.position) {
1121
+ <circle
1122
+ [attr.cx]="port.x"
1123
+ [attr.cy]="port.y"
1124
+ [attr.r]="hoveredPort?.nodeId === node.id && hoveredPort?.port === port.position ? 8 : 6"
1125
+ [attr.fill]="hoveredPort?.nodeId === node.id && hoveredPort?.port === port.position ? '#2563eb' : '#94a3b8'"
1126
+ stroke="white"
1127
+ stroke-width="2"
1128
+ class="attachment-point"
1129
+ [class.hovered]="hoveredPort?.nodeId === node.id && hoveredPort?.port === port.position"
1130
+ (mousedown)="$event.stopPropagation()"
1131
+ (click)="onAttachmentPointClick($event, node, port.position)"
1132
+ />
1133
+ }
1134
+ </g>
1135
+ }
1136
+ }
1137
+
1138
+ <!-- Layer 4: Edge endpoints (only visible when edge is selected) -->
1139
+ @for (edge of internalGraph().edges; track edge.id) {
1140
+ @if (selection().edges.includes(edge.id)) {
1141
+ <g>
1142
+ <!-- Source endpoint -->
1143
+ <circle
1144
+ [attr.cx]="getEdgeSourcePoint(edge).x"
1145
+ [attr.cy]="getEdgeSourcePoint(edge).y"
1146
+ r="6"
1147
+ fill="#3b82f6"
1148
+ stroke="white"
1149
+ stroke-width="2"
1150
+ class="edge-endpoint selected"
1151
+ (mousedown)="onEdgeEndpointMouseDown($event, edge, 'source')"
1152
+ />
1153
+
1154
+ <!-- Target endpoint -->
1155
+ <circle
1156
+ [attr.cx]="getEdgeTargetPoint(edge).x"
1157
+ [attr.cy]="getEdgeTargetPoint(edge).y"
1158
+ r="6"
1159
+ fill="#3b82f6"
1160
+ stroke="white"
1161
+ stroke-width="2"
1162
+ class="edge-endpoint selected"
1163
+ (mousedown)="onEdgeEndpointMouseDown($event, edge, 'target')"
1164
+ />
1165
+ </g>
1166
+ }
1167
+ }
1168
+ </g>
1169
+ </svg>
1170
+ </div>
1171
+
1172
+ <!-- Edge direction selector overlay -->
1173
+ @if (selectedEdgeMidpoint()) {
1174
+ <div
1175
+ class="edge-direction-selector"
1176
+ [style.left.px]="selectedEdgeMidpoint()!.x"
1177
+ [style.top.px]="selectedEdgeMidpoint()!.y"
1178
+ >
1179
+ <button
1180
+ class="direction-btn"
1181
+ [class.active]="selectedEdgeMidpoint()!.edge.direction === 'backward'"
1182
+ title="Backward"
1183
+ (click)="setEdgeDirection('backward')"
1184
+ >←</button>
1185
+ <button
1186
+ class="direction-btn"
1187
+ [class.active]="selectedEdgeMidpoint()!.edge.direction === 'bidirectional'"
1188
+ title="Bidirectional"
1189
+ (click)="setEdgeDirection('bidirectional')"
1190
+ >↔</button>
1191
+ <button
1192
+ class="direction-btn"
1193
+ [class.active]="!selectedEdgeMidpoint()!.edge.direction || selectedEdgeMidpoint()!.edge.direction === 'forward'"
1194
+ title="Forward"
1195
+ (click)="setEdgeDirection('forward')"
1196
+ >→</button>
1197
+ </div>
1198
+ }
1199
+ <!-- Validation errors -->
1200
+ @if (validationResult() && !validationResult()!.valid) {
1201
+ <div class="validation-panel">
1202
+ <h4>Validation Errors</h4>
1203
+ @for (error of validationResult()!.errors; track error.rule) {
1204
+ <div class="error-item" [class.warning]="error.severity === 'warning'">
1205
+ {{ error.message }}
1206
+ </div>
1207
+ }
1208
+ </div>
1209
+ }
1210
+ </div>
1211
+ `, isInline: true, styles: [".graph-editor-container{display:flex;width:100%;height:100%;position:relative;background:var(--graph-editor-canvas-bg, #f8f9fa)}.graph-palette-overlay{position:absolute;top:16px;left:16px;display:flex;gap:4px;z-index:10;background:#fffffff2;padding:6px;border-radius:var(--radius-md, 8px);box-shadow:0 2px 8px #0000001a;backdrop-filter:blur(4px)}.palette-item{display:inline-flex;align-items:center;justify-content:center;width:40px;height:40px;padding:0;border:1.5px solid var(--neutral-200, #e5e7eb);border-radius:var(--radius-md, 8px);background:var(--white, #fff);color:var(--neutral-600, #4b5563);cursor:pointer;-webkit-user-select:none;user-select:none;transition:all .15s ease;font-size:20px}.palette-item:focus-visible{outline:2px solid var(--indigo-400, #818cf8);outline-offset:2px}.palette-item:hover{background:var(--neutral-50, #f9fafb);border-color:var(--interactive, #3b82f6);color:var(--interactive, #3b82f6);transform:translateY(-1px);box-shadow:0 2px 6px #00000014}.palette-item:active{transform:translateY(0);box-shadow:none}.palette-item.tool-item.active{background:var(--interactive, #3b82f6);border-color:var(--interactive, #3b82f6);color:#fff}.palette-item.tool-item.active:hover{background:var(--interactive-hover, #2563eb);border-color:var(--interactive-hover, #2563eb);color:#fff}.palette-divider{width:1px;background:var(--neutral-200, #e5e7eb);align-self:stretch;margin:4px 2px}.graph-canvas-wrapper{flex:1;position:relative;overflow:hidden}.graph-canvas{width:100%;height:100%;cursor:grab}.graph-canvas:active{cursor:grabbing}.graph-canvas.tool-line,.graph-canvas.tool-line .graph-node{cursor:crosshair}.graph-node{cursor:move;user-select:none;-webkit-user-select:none}.graph-node text{pointer-events:none}.graph-node.selected rect{filter:drop-shadow(0 0 6px rgba(59,130,246,.4))}path.selected{stroke:#3b82f6!important;stroke-width:3!important}.edge-hit-area{cursor:pointer}.edge-endpoint{cursor:pointer;transition:r .2s,fill .2s}.edge-endpoint:hover{r:8;fill:#2563eb}.edge-endpoint.selected{fill:#2563eb}.attachment-point{cursor:crosshair;transition:all .2s}.attachment-point.hovered{filter:drop-shadow(0 0 4px rgba(37,99,235,.6))}.validation-panel{position:absolute;bottom:0;left:0;right:0;max-height:200px;overflow-y:auto;background:#fff;border-top:1px solid #e5e7eb;padding:16px}.error-item{padding:8px 12px;margin-bottom:8px;background:#fee2e2;border-left:3px solid #ef4444;border-radius:4px;font-size:14px}.error-item.warning{background:#fef3c7;border-left-color:#f59e0b}.edge-direction-selector{position:absolute;transform:translate(-50%,-100%);margin-top:-12px;display:flex;gap:2px;background:#fffffff2;padding:4px;border-radius:6px;box-shadow:0 2px 8px #00000026;backdrop-filter:blur(4px);z-index:20;pointer-events:auto}.direction-btn{display:flex;align-items:center;justify-content:center;width:28px;height:28px;padding:0;border:1px solid #e5e7eb;border-radius:4px;background:#fff;cursor:pointer;font-size:16px;transition:all .15s;color:#6b7280}.direction-btn:hover{background:#f3f4f6;border-color:#3b82f6;color:#3b82f6}.direction-btn.active{background:#3b82f6;border-color:#3b82f6;color:#fff}\n"], changeDetection: i0.ChangeDetectionStrategy.OnPush });
1212
+ }
1213
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.19", ngImport: i0, type: GraphEditorComponent, decorators: [{
1214
+ type: Component,
1215
+ args: [{ selector: 'graph-editor', standalone: true, imports: [], host: {
1216
+ 'tabindex': '0',
1217
+ 'style': 'outline: none;',
1218
+ '(keydown)': 'onKeyDown($event)'
1219
+ }, template: `
1220
+ <div class="graph-editor-container">
1221
+ <!-- Canvas with overlaid palette -->
1222
+ <div class="graph-canvas-wrapper">
1223
+ <!-- Top-left horizontal palette overlay -->
1224
+ @if (config.palette?.enabled !== false) {
1225
+ <div class="graph-palette-overlay">
1226
+ <!-- Tools -->
1227
+ <button
1228
+ class="palette-item tool-item"
1229
+ [class.active]="activeTool() === 'hand'"
1230
+ title="Hand tool (move nodes)"
1231
+ (click)="switchTool('hand')"
1232
+ >
1233
+ <span class="icon">✋</span>
1234
+ </button>
1235
+ <button
1236
+ class="palette-item tool-item"
1237
+ [class.active]="activeTool() === 'line'"
1238
+ title="Line tool (draw connections)"
1239
+ (click)="switchTool('line')"
1240
+ >
1241
+ <span class="icon">∕</span>
1242
+ </button>
1243
+
1244
+ <!-- Divider -->
1245
+ <div class="palette-divider"></div>
1246
+
1247
+ <!-- Node types -->
1248
+ @for (nodeType of config.nodes.types; track nodeType.type) {
1249
+ <button
1250
+ class="palette-item"
1251
+ [attr.data-node-type]="nodeType.type"
1252
+ [attr.title]="nodeType.label || nodeType.type"
1253
+ (click)="addNode(nodeType.type)"
1254
+ >
1255
+ <span class="icon">{{ nodeType.icon || '●' }}</span>
1256
+ </button>
1257
+ }
1258
+ </div>
1259
+ }
1260
+
1261
+ <svg
1262
+ #canvasSvg
1263
+ [class.tool-line]="activeTool() === 'line'"
1264
+ [attr.width]="'100%'"
1265
+ [attr.height]="'100%'"
1266
+ (mousedown)="onCanvasMouseDown($event)"
1267
+ (mousemove)="onCanvasMouseMove($event)"
1268
+ (mouseup)="onCanvasMouseUp($event)"
1269
+ (wheel)="onWheel($event)"
1270
+ (contextmenu)="onContextMenu($event)"
1271
+ >
1272
+ <!-- Arrow marker definitions -->
1273
+ <defs>
1274
+ <marker id="arrow-end" viewBox="0 0 10 10" refX="9" refY="5"
1275
+ markerWidth="8" markerHeight="8" orient="auto">
1276
+ <path d="M 0 1 L 8 5 L 0 9 z" fill="#94a3b8"/>
1277
+ </marker>
1278
+ <marker id="arrow-end-selected" viewBox="0 0 10 10" refX="9" refY="5"
1279
+ markerWidth="8" markerHeight="8" orient="auto">
1280
+ <path d="M 0 1 L 8 5 L 0 9 z" fill="#3b82f6"/>
1281
+ </marker>
1282
+ <marker id="arrow-start" viewBox="0 0 10 10" refX="1" refY="5"
1283
+ markerWidth="8" markerHeight="8" orient="auto">
1284
+ <path d="M 10 1 L 2 5 L 10 9 z" fill="#94a3b8"/>
1285
+ </marker>
1286
+ <marker id="arrow-start-selected" viewBox="0 0 10 10" refX="1" refY="5"
1287
+ markerWidth="8" markerHeight="8" orient="auto">
1288
+ <path d="M 10 1 L 2 5 L 10 9 z" fill="#3b82f6"/>
1289
+ </marker>
1290
+ </defs>
1291
+
1292
+ <!-- Main transform group (pan + zoom) -->
1293
+ <g [attr.transform]="transform()">
1294
+ <!-- Grid (if enabled) -->
1295
+ <!-- Grid (if enabled) - extended to cover viewport during pan -->
1296
+ @if (config.canvas?.grid?.enabled) {
1297
+ <defs>
1298
+ <pattern
1299
+ id="grid"
1300
+ [attr.width]="config.canvas!.grid!.size"
1301
+ [attr.height]="config.canvas!.grid!.size"
1302
+ patternUnits="userSpaceOnUse"
1303
+ >
1304
+ <path
1305
+ [attr.d]="'M ' + config.canvas!.grid!.size + ' 0 L 0 0 0 ' + config.canvas!.grid!.size"
1306
+ fill="none"
1307
+ [attr.stroke]="config.canvas!.grid!.color || '#e0e0e0'"
1308
+ stroke-width="1"
1309
+ />
1310
+ </pattern>
1311
+ </defs>
1312
+ <!-- Extended grid background covering viewport + pan offset -->
1313
+ <rect
1314
+ [attr.x]="gridBounds().x"
1315
+ [attr.y]="gridBounds().y"
1316
+ [attr.width]="gridBounds().width"
1317
+ [attr.height]="gridBounds().height"
1318
+ fill="url(#grid)"
1319
+ />
1320
+ }
1321
+
1322
+ <!-- Layer 0.5: Preview line for line tool (rubber-band) -->
1323
+ @if (previewLine()) {
1324
+ <line
1325
+ [attr.x1]="previewLine()!.source.x"
1326
+ [attr.y1]="previewLine()!.source.y"
1327
+ [attr.x2]="previewLine()!.target.x"
1328
+ [attr.y2]="previewLine()!.target.y"
1329
+ stroke="#3b82f6"
1330
+ stroke-width="2"
1331
+ stroke-dasharray="6,4"
1332
+ opacity="0.6"
1333
+ />
1334
+ }
1335
+
1336
+ <!-- Layer 1: Edge paths (behind everything) -->
1337
+ @for (edge of internalGraph().edges; track edge.id) {
1338
+ <!-- Invisible wide hit-area for easier clicking (hand tool only) -->
1339
+ <path
1340
+ [attr.d]="getEdgePath(edge)"
1341
+ stroke="transparent"
1342
+ [attr.stroke-width]="16"
1343
+ fill="none"
1344
+ class="edge-hit-area"
1345
+ [attr.pointer-events]="activeTool() === 'hand' ? 'stroke' : 'none'"
1346
+ (click)="onEdgeClick($event, edge)"
1347
+ (dblclick)="onEdgeDoubleClick($event, edge)"
1348
+ />
1349
+ <!-- Visible edge line -->
1350
+ <path
1351
+ [attr.d]="getEdgePath(edge)"
1352
+ [attr.stroke]="getEdgeColor(edge)"
1353
+ [attr.stroke-width]="2"
1354
+ fill="none"
1355
+ [class.selected]="selection().edges.includes(edge.id)"
1356
+ [attr.marker-end]="getEdgeMarkerEnd(edge)"
1357
+ [attr.marker-start]="getEdgeMarkerStart(edge)"
1358
+ pointer-events="none"
1359
+ />
1360
+ }
1361
+
1362
+ <!-- Layer 2: Nodes -->
1363
+ @for (node of internalGraph().nodes; track node.id) {
1364
+ <g
1365
+ [attr.transform]="'translate(' + node.position.x + ',' + node.position.y + ')'"
1366
+ class="graph-node"
1367
+ [class.selected]="selection().nodes.includes(node.id)"
1368
+ [attr.data-node-id]="node.id"
1369
+ (mousedown)="onNodeMouseDown($event, node)"
1370
+ (click)="onNodeClick($event, node)"
1371
+ (dblclick)="nodeDoubleClick.emit(node)"
1372
+ >
1373
+ <!-- Node background -->
1374
+ <rect
1375
+ [attr.width]="getNodeSize(node).width"
1376
+ [attr.height]="getNodeSize(node).height"
1377
+ [attr.fill]="'white'"
1378
+ [attr.stroke]="selection().nodes.includes(node.id) ? '#3b82f6' : '#cbd5e0'"
1379
+ [attr.stroke-width]="selection().nodes.includes(node.id) ? 3 : 2"
1380
+ rx="8"
1381
+ />
1382
+
1383
+ <!-- Node type icon badge (top-left, with padding from corner) -->
1384
+ <g class="node-type-badge">
1385
+ <circle
1386
+ cx="28"
1387
+ cy="28"
1388
+ r="16"
1389
+ fill="#f3f4f6"
1390
+ stroke="#cbd5e0"
1391
+ stroke-width="2"
1392
+ />
1393
+ <text
1394
+ x="28"
1395
+ y="28"
1396
+ text-anchor="middle"
1397
+ dominant-baseline="middle"
1398
+ font-size="20"
1399
+ fill="#374151"
1400
+ >
1401
+ {{ getNodeTypeIcon(node) }}
1402
+ </text>
1403
+ </g>
1404
+
1405
+ <!-- Node label -->
1406
+ <text
1407
+ [attr.x]="getNodeSize(node).width / 2"
1408
+ [attr.y]="getNodeSize(node).height / 2"
1409
+ text-anchor="middle"
1410
+ dominant-baseline="middle"
1411
+ font-size="14"
1412
+ >
1413
+ {{ node.data['name'] || node.type }}
1414
+ </text>
1415
+ </g>
1416
+ }
1417
+
1418
+ <!-- Layer 3: Attachment points (on top of nodes) -->
1419
+ @for (node of internalGraph().nodes; track node.id) {
1420
+ @if (showAttachmentPoints() === node.id) {
1421
+ <g [attr.transform]="'translate(' + node.position.x + ',' + node.position.y + ')'">
1422
+ @for (port of getNodePorts(node); track port.position) {
1423
+ <circle
1424
+ [attr.cx]="port.x"
1425
+ [attr.cy]="port.y"
1426
+ [attr.r]="hoveredPort?.nodeId === node.id && hoveredPort?.port === port.position ? 8 : 6"
1427
+ [attr.fill]="hoveredPort?.nodeId === node.id && hoveredPort?.port === port.position ? '#2563eb' : '#94a3b8'"
1428
+ stroke="white"
1429
+ stroke-width="2"
1430
+ class="attachment-point"
1431
+ [class.hovered]="hoveredPort?.nodeId === node.id && hoveredPort?.port === port.position"
1432
+ (mousedown)="$event.stopPropagation()"
1433
+ (click)="onAttachmentPointClick($event, node, port.position)"
1434
+ />
1435
+ }
1436
+ </g>
1437
+ }
1438
+ }
1439
+
1440
+ <!-- Layer 4: Edge endpoints (only visible when edge is selected) -->
1441
+ @for (edge of internalGraph().edges; track edge.id) {
1442
+ @if (selection().edges.includes(edge.id)) {
1443
+ <g>
1444
+ <!-- Source endpoint -->
1445
+ <circle
1446
+ [attr.cx]="getEdgeSourcePoint(edge).x"
1447
+ [attr.cy]="getEdgeSourcePoint(edge).y"
1448
+ r="6"
1449
+ fill="#3b82f6"
1450
+ stroke="white"
1451
+ stroke-width="2"
1452
+ class="edge-endpoint selected"
1453
+ (mousedown)="onEdgeEndpointMouseDown($event, edge, 'source')"
1454
+ />
1455
+
1456
+ <!-- Target endpoint -->
1457
+ <circle
1458
+ [attr.cx]="getEdgeTargetPoint(edge).x"
1459
+ [attr.cy]="getEdgeTargetPoint(edge).y"
1460
+ r="6"
1461
+ fill="#3b82f6"
1462
+ stroke="white"
1463
+ stroke-width="2"
1464
+ class="edge-endpoint selected"
1465
+ (mousedown)="onEdgeEndpointMouseDown($event, edge, 'target')"
1466
+ />
1467
+ </g>
1468
+ }
1469
+ }
1470
+ </g>
1471
+ </svg>
1472
+ </div>
1473
+
1474
+ <!-- Edge direction selector overlay -->
1475
+ @if (selectedEdgeMidpoint()) {
1476
+ <div
1477
+ class="edge-direction-selector"
1478
+ [style.left.px]="selectedEdgeMidpoint()!.x"
1479
+ [style.top.px]="selectedEdgeMidpoint()!.y"
1480
+ >
1481
+ <button
1482
+ class="direction-btn"
1483
+ [class.active]="selectedEdgeMidpoint()!.edge.direction === 'backward'"
1484
+ title="Backward"
1485
+ (click)="setEdgeDirection('backward')"
1486
+ >←</button>
1487
+ <button
1488
+ class="direction-btn"
1489
+ [class.active]="selectedEdgeMidpoint()!.edge.direction === 'bidirectional'"
1490
+ title="Bidirectional"
1491
+ (click)="setEdgeDirection('bidirectional')"
1492
+ >↔</button>
1493
+ <button
1494
+ class="direction-btn"
1495
+ [class.active]="!selectedEdgeMidpoint()!.edge.direction || selectedEdgeMidpoint()!.edge.direction === 'forward'"
1496
+ title="Forward"
1497
+ (click)="setEdgeDirection('forward')"
1498
+ >→</button>
1499
+ </div>
1500
+ }
1501
+ <!-- Validation errors -->
1502
+ @if (validationResult() && !validationResult()!.valid) {
1503
+ <div class="validation-panel">
1504
+ <h4>Validation Errors</h4>
1505
+ @for (error of validationResult()!.errors; track error.rule) {
1506
+ <div class="error-item" [class.warning]="error.severity === 'warning'">
1507
+ {{ error.message }}
1508
+ </div>
1509
+ }
1510
+ </div>
1511
+ }
1512
+ </div>
1513
+ `, changeDetection: ChangeDetectionStrategy.OnPush, styles: [".graph-editor-container{display:flex;width:100%;height:100%;position:relative;background:var(--graph-editor-canvas-bg, #f8f9fa)}.graph-palette-overlay{position:absolute;top:16px;left:16px;display:flex;gap:4px;z-index:10;background:#fffffff2;padding:6px;border-radius:var(--radius-md, 8px);box-shadow:0 2px 8px #0000001a;backdrop-filter:blur(4px)}.palette-item{display:inline-flex;align-items:center;justify-content:center;width:40px;height:40px;padding:0;border:1.5px solid var(--neutral-200, #e5e7eb);border-radius:var(--radius-md, 8px);background:var(--white, #fff);color:var(--neutral-600, #4b5563);cursor:pointer;-webkit-user-select:none;user-select:none;transition:all .15s ease;font-size:20px}.palette-item:focus-visible{outline:2px solid var(--indigo-400, #818cf8);outline-offset:2px}.palette-item:hover{background:var(--neutral-50, #f9fafb);border-color:var(--interactive, #3b82f6);color:var(--interactive, #3b82f6);transform:translateY(-1px);box-shadow:0 2px 6px #00000014}.palette-item:active{transform:translateY(0);box-shadow:none}.palette-item.tool-item.active{background:var(--interactive, #3b82f6);border-color:var(--interactive, #3b82f6);color:#fff}.palette-item.tool-item.active:hover{background:var(--interactive-hover, #2563eb);border-color:var(--interactive-hover, #2563eb);color:#fff}.palette-divider{width:1px;background:var(--neutral-200, #e5e7eb);align-self:stretch;margin:4px 2px}.graph-canvas-wrapper{flex:1;position:relative;overflow:hidden}.graph-canvas{width:100%;height:100%;cursor:grab}.graph-canvas:active{cursor:grabbing}.graph-canvas.tool-line,.graph-canvas.tool-line .graph-node{cursor:crosshair}.graph-node{cursor:move;user-select:none;-webkit-user-select:none}.graph-node text{pointer-events:none}.graph-node.selected rect{filter:drop-shadow(0 0 6px rgba(59,130,246,.4))}path.selected{stroke:#3b82f6!important;stroke-width:3!important}.edge-hit-area{cursor:pointer}.edge-endpoint{cursor:pointer;transition:r .2s,fill .2s}.edge-endpoint:hover{r:8;fill:#2563eb}.edge-endpoint.selected{fill:#2563eb}.attachment-point{cursor:crosshair;transition:all .2s}.attachment-point.hovered{filter:drop-shadow(0 0 4px rgba(37,99,235,.6))}.validation-panel{position:absolute;bottom:0;left:0;right:0;max-height:200px;overflow-y:auto;background:#fff;border-top:1px solid #e5e7eb;padding:16px}.error-item{padding:8px 12px;margin-bottom:8px;background:#fee2e2;border-left:3px solid #ef4444;border-radius:4px;font-size:14px}.error-item.warning{background:#fef3c7;border-left-color:#f59e0b}.edge-direction-selector{position:absolute;transform:translate(-50%,-100%);margin-top:-12px;display:flex;gap:2px;background:#fffffff2;padding:4px;border-radius:6px;box-shadow:0 2px 8px #00000026;backdrop-filter:blur(4px);z-index:20;pointer-events:auto}.direction-btn{display:flex;align-items:center;justify-content:center;width:28px;height:28px;padding:0;border:1px solid #e5e7eb;border-radius:4px;background:#fff;cursor:pointer;font-size:16px;transition:all .15s;color:#6b7280}.direction-btn:hover{background:#f3f4f6;border-color:#3b82f6;color:#3b82f6}.direction-btn.active{background:#3b82f6;border-color:#3b82f6;color:#fff}\n"] }]
1514
+ }], ctorParameters: () => [], propDecorators: { config: [{
1515
+ type: Input,
1516
+ args: [{ required: true }]
1517
+ }], graph: [{
1518
+ type: Input
1519
+ }], readonly: [{
1520
+ type: Input
1521
+ }], visualizationMode: [{
1522
+ type: Input
1523
+ }], overlayData: [{
1524
+ type: Input
1525
+ }], graphChange: [{
1526
+ type: Output
1527
+ }], nodeAdded: [{
1528
+ type: Output
1529
+ }], nodeUpdated: [{
1530
+ type: Output
1531
+ }], nodeRemoved: [{
1532
+ type: Output
1533
+ }], edgeAdded: [{
1534
+ type: Output
1535
+ }], edgeUpdated: [{
1536
+ type: Output
1537
+ }], edgeRemoved: [{
1538
+ type: Output
1539
+ }], selectionChange: [{
1540
+ type: Output
1541
+ }], validationChange: [{
1542
+ type: Output
1543
+ }], nodeClick: [{
1544
+ type: Output
1545
+ }], nodeDoubleClick: [{
1546
+ type: Output
1547
+ }], edgeClick: [{
1548
+ type: Output
1549
+ }], edgeDoubleClick: [{
1550
+ type: Output
1551
+ }], canvasClick: [{
1552
+ type: Output
1553
+ }], contextMenu: [{
1554
+ type: Output
1555
+ }] } });
1556
+
1557
+ // Main component
1558
+
1559
+ /**
1560
+ * Generated bundle index. Do not edit.
1561
+ */
1562
+
1563
+ export { GraphEditorComponent };
1564
+ //# sourceMappingURL=utisha-graph-editor.mjs.map