@xui/node-graph 2.0.0-alpha.11

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,2975 @@
1
+ import * as i0 from '@angular/core';
2
+ import { signal, computed, untracked, Injectable, inject, InjectionToken, input, booleanAttribute, ViewEncapsulation, ChangeDetectionStrategy, Component, ElementRef, numberAttribute, DestroyRef, viewChild, afterRenderEffect, model, output, contentChildren, Directive } from '@angular/core';
3
+ import { xui } from '@xui/core';
4
+ import { cva } from 'class-variance-authority';
5
+
6
+ /**
7
+ * Composite key for the port registry. A port id is only unique within its node.
8
+ *
9
+ * Length-prefixed so the pair can never be ambiguous: a node called `a` with a
10
+ * port called `b:c` and a node called `a:b` with a port called `c` must not
11
+ * collide, which any plain separator would allow.
12
+ */
13
+ function xuiGraphPortKey(nodeId, portId) {
14
+ return `${nodeId.length}:${nodeId}:${portId}`;
15
+ }
16
+
17
+ /**
18
+ * Reactive state shared by a graph and everything inside it.
19
+ *
20
+ * Provided by {@link XuiNodeGraph}, so every node, port and edge in one canvas
21
+ * sees the same instance and two graphs on a page never interfere.
22
+ */
23
+ class XuiNodeGraphStore {
24
+ settingsSource = null;
25
+ listeners = null;
26
+ surface = null;
27
+ /** Node top-left positions captured at the start of a drag, keyed by node id. */
28
+ dragOrigins = new Map();
29
+ dragMoved = false;
30
+ /**
31
+ * The drag in flight, including every group frame as it stood when the drag
32
+ * began. Reactive, because those frozen frames are what the groups *render*
33
+ * while a node is being dragged, not merely what the drop is judged against.
34
+ *
35
+ * Both uses come from the same problem: a frame is sized from its members, so
36
+ * a live frame stretches to follow the very node being dragged out of it and
37
+ * then snaps back on release. Pinning the frame for the duration makes the
38
+ * node visibly leave a box that stays put — and makes the box you can see the
39
+ * box that decides where the node lands.
40
+ */
41
+ drag = signal(null, /* @ts-ignore */
42
+ ...(ngDevMode ? [{ debugName: "drag" }] : /* istanbul ignore next */ []));
43
+ /**
44
+ * The graph binds its own `viewport` model and `edges` input here, so those
45
+ * stay the single source of truth and the store never has to mirror them
46
+ * through an effect that could fight the host for ownership.
47
+ */
48
+ viewportSource = null;
49
+ edgesSource = null;
50
+ ownViewport = signal({ x: 0, y: 0, zoom: 1 }, /* @ts-ignore */
51
+ ...(ngDevMode ? [{ debugName: "ownViewport" }] : /* istanbul ignore next */ []));
52
+ viewport = computed(() => (this.viewportSource ?? this.ownViewport)(), /* @ts-ignore */
53
+ ...(ngDevMode ? [{ debugName: "viewport" }] : /* istanbul ignore next */ []));
54
+ edges = computed(() => this.edgesSource?.() ?? EMPTY_EDGES, /* @ts-ignore */
55
+ ...(ngDevMode ? [{ debugName: "edges" }] : /* istanbul ignore next */ []));
56
+ selectedNodes = signal(new Set(), /* @ts-ignore */
57
+ ...(ngDevMode ? [{ debugName: "selectedNodes" }] : /* istanbul ignore next */ []));
58
+ selectedEdges = signal(new Set(), /* @ts-ignore */
59
+ ...(ngDevMode ? [{ debugName: "selectedEdges" }] : /* istanbul ignore next */ []));
60
+ linking = signal(null, /* @ts-ignore */
61
+ ...(ngDevMode ? [{ debugName: "linking" }] : /* istanbul ignore next */ []));
62
+ /** Size of the visible canvas in screen pixels; kept current by a ResizeObserver. */
63
+ surfaceSize = signal({ width: 0, height: 0 }, /* @ts-ignore */
64
+ ...(ngDevMode ? [{ debugName: "surfaceSize" }] : /* istanbul ignore next */ []));
65
+ nodeMap = signal(new Map(), /* @ts-ignore */
66
+ ...(ngDevMode ? [{ debugName: "nodeMap" }] : /* istanbul ignore next */ []));
67
+ portMap = signal(new Map(), /* @ts-ignore */
68
+ ...(ngDevMode ? [{ debugName: "portMap" }] : /* istanbul ignore next */ []));
69
+ groupMap = signal(new Map(), /* @ts-ignore */
70
+ ...(ngDevMode ? [{ debugName: "groupMap" }] : /* istanbul ignore next */ []));
71
+ nodes = computed(() => [...this.nodeMap().values()], /* @ts-ignore */
72
+ ...(ngDevMode ? [{ debugName: "nodes" }] : /* istanbul ignore next */ []));
73
+ ports = computed(() => [...this.portMap().values()], /* @ts-ignore */
74
+ ...(ngDevMode ? [{ debugName: "ports" }] : /* istanbul ignore next */ []));
75
+ groups = computed(() => [...this.groupMap().values()], /* @ts-ignore */
76
+ ...(ngDevMode ? [{ debugName: "groups" }] : /* istanbul ignore next */ []));
77
+ settings = computed(() => this.settingsSource?.() ?? FALLBACK_SETTINGS, /* @ts-ignore */
78
+ ...(ngDevMode ? [{ debugName: "settings" }] : /* istanbul ignore next */ []));
79
+ /** How many edges terminate at each port, keyed the same way as the registry. */
80
+ connectionCounts = computed(() => {
81
+ const counts = new Map();
82
+ for (const edge of this.edges()) {
83
+ for (const ref of [edge.source, edge.target]) {
84
+ const key = xuiGraphPortKey(ref.nodeId, ref.portId);
85
+ counts.set(key, (counts.get(key) ?? 0) + 1);
86
+ }
87
+ }
88
+ return counts;
89
+ }, /* @ts-ignore */
90
+ ...(ngDevMode ? [{ debugName: "connectionCounts" }] : /* istanbul ignore next */ []));
91
+ /**
92
+ * Wire the store to the graph that provides it. Called from the graph's
93
+ * constructor, before anything can read the bound signals.
94
+ *
95
+ * @internal
96
+ */
97
+ configure(bindings) {
98
+ this.settingsSource = bindings.settings;
99
+ this.viewportSource = bindings.viewport;
100
+ this.edgesSource = bindings.edges;
101
+ this.listeners = bindings.listeners;
102
+ }
103
+ /** Writes through to whichever signal is bound as the viewport. */
104
+ updateViewport(next) {
105
+ const target = this.viewportSource ?? this.ownViewport;
106
+ target.set(next(untracked(target)));
107
+ }
108
+ /** @internal */
109
+ setSurface(element) {
110
+ this.surface = element;
111
+ }
112
+ // ------------------------------------------------------------------ registry
113
+ /** @internal */
114
+ registerNode(node) {
115
+ this.nodeMap.update(map => new Map(map).set(node.nodeId(), node));
116
+ }
117
+ /** @internal */
118
+ unregisterNode(node) {
119
+ this.nodeMap.update(map => {
120
+ const next = new Map(map);
121
+ // Guard against a re-registered id: a node that has already been replaced
122
+ // by another instance must not be deleted by its predecessor's teardown.
123
+ if (next.get(node.nodeId()) === node) {
124
+ next.delete(node.nodeId());
125
+ }
126
+ return next;
127
+ });
128
+ }
129
+ /** @internal */
130
+ registerPort(port) {
131
+ this.portMap.update(map => new Map(map).set(port.key, port));
132
+ }
133
+ /** @internal */
134
+ unregisterPort(port) {
135
+ this.portMap.update(map => {
136
+ const next = new Map(map);
137
+ if (next.get(port.key) === port) {
138
+ next.delete(port.key);
139
+ }
140
+ return next;
141
+ });
142
+ }
143
+ /** @internal */
144
+ registerGroup(group) {
145
+ this.groupMap.update(map => new Map(map).set(group.groupId(), group));
146
+ }
147
+ /** @internal */
148
+ unregisterGroup(group) {
149
+ this.groupMap.update(map => {
150
+ const next = new Map(map);
151
+ if (next.get(group.groupId()) === group) {
152
+ next.delete(group.groupId());
153
+ }
154
+ return next;
155
+ });
156
+ }
157
+ node(nodeId) {
158
+ return this.nodeMap().get(nodeId);
159
+ }
160
+ port(key) {
161
+ return this.portMap().get(key);
162
+ }
163
+ /**
164
+ * Absolute position of a connector in graph space.
165
+ *
166
+ * Composed from the node's position and the port's node-relative offset rather
167
+ * than measured directly, so dragging a node re-routes its wires from a single
168
+ * signal write with no DOM reads.
169
+ */
170
+ portPoint(key) {
171
+ const port = this.portMap().get(key);
172
+ const node = port && this.nodeMap().get(port.nodeId());
173
+ if (!port || !node) {
174
+ return null;
175
+ }
176
+ const position = node.position();
177
+ const offset = port.offset();
178
+ return { x: position.x + offset.x, y: position.y + offset.y };
179
+ }
180
+ descriptor(key) {
181
+ const port = this.portMap().get(key);
182
+ if (!port) {
183
+ return null;
184
+ }
185
+ return {
186
+ nodeId: port.nodeId(),
187
+ portId: port.portId(),
188
+ direction: port.direction(),
189
+ side: port.resolvedSide(),
190
+ dataType: port.dataType(),
191
+ maxConnections: port.resolvedMaxConnections(),
192
+ disabled: port.disabled(),
193
+ connections: this.connectionCounts().get(key) ?? 0
194
+ };
195
+ }
196
+ connectionCount(key) {
197
+ return this.connectionCounts().get(key) ?? 0;
198
+ }
199
+ // --------------------------------------------------------------- coordinates
200
+ /** Client (viewport) coordinates to graph space. */
201
+ toGraphPoint(clientX, clientY) {
202
+ const rect = this.surface?.getBoundingClientRect();
203
+ const { x, y, zoom } = this.viewport();
204
+ return {
205
+ x: (clientX - (rect?.left ?? 0) - x) / zoom,
206
+ y: (clientY - (rect?.top ?? 0) - y) / zoom
207
+ };
208
+ }
209
+ /** Graph space to coordinates relative to the canvas element's top-left corner. */
210
+ toSurfacePoint(point) {
211
+ const { x, y, zoom } = this.viewport();
212
+ return { x: point.x * zoom + x, y: point.y * zoom + y };
213
+ }
214
+ /** Rounds to the nearest grid intersection when snapping is on. */
215
+ snap(point) {
216
+ const { snapToGrid, gridSize } = this.settings();
217
+ if (!snapToGrid || gridSize <= 0) {
218
+ return point;
219
+ }
220
+ return { x: Math.round(point.x / gridSize) * gridSize, y: Math.round(point.y / gridSize) * gridSize };
221
+ }
222
+ // ------------------------------------------------------------------ viewport
223
+ panBy(dx, dy) {
224
+ this.updateViewport(v => ({ ...v, x: v.x + dx, y: v.y + dy }));
225
+ }
226
+ /**
227
+ * Scale about a fixed point, given in surface coordinates.
228
+ *
229
+ * Holding that point still is what makes wheel-zoom feel anchored to the
230
+ * cursor instead of to the centre of the canvas.
231
+ */
232
+ zoomTo(zoom, anchor) {
233
+ const { minZoom, maxZoom } = this.settings();
234
+ this.updateViewport(v => {
235
+ const next = Math.min(maxZoom, Math.max(minZoom, zoom));
236
+ const size = this.surfaceSize();
237
+ const point = anchor ?? { x: size.width / 2, y: size.height / 2 };
238
+ const ratio = next / v.zoom;
239
+ return { zoom: next, x: point.x - (point.x - v.x) * ratio, y: point.y - (point.y - v.y) * ratio };
240
+ });
241
+ }
242
+ zoomBy(factor, anchor) {
243
+ this.zoomTo(this.viewport().zoom * factor, anchor);
244
+ }
245
+ /** Bounding box of every node, or `null` while the graph is empty. */
246
+ contentBounds() {
247
+ const nodes = this.nodes();
248
+ if (nodes.length === 0) {
249
+ return null;
250
+ }
251
+ let minX = Infinity;
252
+ let minY = Infinity;
253
+ let maxX = -Infinity;
254
+ let maxY = -Infinity;
255
+ for (const node of nodes) {
256
+ const { x, y } = node.position();
257
+ const { width, height } = node.size();
258
+ minX = Math.min(minX, x);
259
+ minY = Math.min(minY, y);
260
+ maxX = Math.max(maxX, x + width);
261
+ maxY = Math.max(maxY, y + height);
262
+ }
263
+ return { x: minX, y: minY, width: maxX - minX, height: maxY - minY };
264
+ }
265
+ /** Frame every node, leaving `padding` screen pixels of margin. */
266
+ fitView(padding = 40) {
267
+ const bounds = this.contentBounds();
268
+ const { width, height } = this.surfaceSize();
269
+ if (!bounds || width === 0 || height === 0) {
270
+ return;
271
+ }
272
+ const { minZoom, maxZoom } = this.settings();
273
+ const scale = Math.min((width - padding * 2) / Math.max(bounds.width, 1), (height - padding * 2) / Math.max(bounds.height, 1));
274
+ const zoom = Math.min(maxZoom, Math.max(minZoom, scale));
275
+ this.updateViewport(() => ({
276
+ zoom,
277
+ x: width / 2 - (bounds.x + bounds.width / 2) * zoom,
278
+ y: height / 2 - (bounds.y + bounds.height / 2) * zoom
279
+ }));
280
+ }
281
+ /** Centre the viewport on a node without changing the zoom. */
282
+ centerOn(nodeId) {
283
+ const node = this.nodeMap().get(nodeId);
284
+ const { width, height } = this.surfaceSize();
285
+ if (!node) {
286
+ return;
287
+ }
288
+ const position = node.position();
289
+ const size = node.size();
290
+ this.updateViewport(v => ({
291
+ ...v,
292
+ x: width / 2 - (position.x + size.width / 2) * v.zoom,
293
+ y: height / 2 - (position.y + size.height / 2) * v.zoom
294
+ }));
295
+ }
296
+ // ----------------------------------------------------------------- selection
297
+ isNodeSelected(nodeId) {
298
+ return this.selectedNodes().has(nodeId);
299
+ }
300
+ isEdgeSelected(edgeId) {
301
+ return this.selectedEdges().has(edgeId);
302
+ }
303
+ selectNode(nodeId, additive = false) {
304
+ this.selectedNodes.update(current => {
305
+ if (!additive) {
306
+ return current.size === 1 && current.has(nodeId) ? current : new Set([nodeId]);
307
+ }
308
+ const next = new Set(current);
309
+ next.has(nodeId) ? next.delete(nodeId) : next.add(nodeId);
310
+ return next;
311
+ });
312
+ if (!additive) {
313
+ this.selectedEdges.set(new Set());
314
+ }
315
+ this.listeners?.selectionChange();
316
+ }
317
+ selectEdge(edgeId, additive = false) {
318
+ this.selectedEdges.update(current => {
319
+ if (!additive) {
320
+ return current.size === 1 && current.has(edgeId) ? current : new Set([edgeId]);
321
+ }
322
+ const next = new Set(current);
323
+ next.has(edgeId) ? next.delete(edgeId) : next.add(edgeId);
324
+ return next;
325
+ });
326
+ if (!additive) {
327
+ this.selectedNodes.set(new Set());
328
+ }
329
+ this.listeners?.selectionChange();
330
+ }
331
+ setSelection(nodeIds, edgeIds = []) {
332
+ this.selectedNodes.set(new Set(nodeIds));
333
+ this.selectedEdges.set(new Set(edgeIds));
334
+ this.listeners?.selectionChange();
335
+ }
336
+ clearSelection() {
337
+ if (this.selectedNodes().size === 0 && this.selectedEdges().size === 0) {
338
+ return;
339
+ }
340
+ this.selectedNodes.set(new Set());
341
+ this.selectedEdges.set(new Set());
342
+ this.listeners?.selectionChange();
343
+ }
344
+ selectAll() {
345
+ this.setSelection(this.nodes().map(node => node.nodeId()), this.edges().map(edge => edge.id));
346
+ }
347
+ /** Select every node whose box intersects `rect`, in graph space. */
348
+ selectInRect(rect, additive = false) {
349
+ const hits = this.nodes()
350
+ .filter(node => {
351
+ const position = node.position();
352
+ const size = node.size();
353
+ return (position.x < rect.x + rect.width &&
354
+ position.x + size.width > rect.x &&
355
+ position.y < rect.y + rect.height &&
356
+ position.y + size.height > rect.y);
357
+ })
358
+ .map(node => node.nodeId());
359
+ this.setSelection(additive ? [...this.selectedNodes(), ...hits] : hits, additive ? this.selectedEdges() : []);
360
+ }
361
+ // -------------------------------------------------------------------- groups
362
+ /** The nodes that declare themselves members of a group, in registration order. */
363
+ nodesInGroup(groupId) {
364
+ return this.nodes().filter(node => node.group() === groupId);
365
+ }
366
+ /**
367
+ * The frame a group draws: its members' bounding box, grown by the group's own
368
+ * padding and title bar. A group owns no geometry, so an empty one has no box
369
+ * at all and renders nothing.
370
+ *
371
+ */
372
+ groupBounds(groupId) {
373
+ const group = this.groupMap().get(groupId);
374
+ const members = this.nodesInGroup(groupId);
375
+ if (!group || members.length === 0) {
376
+ return null;
377
+ }
378
+ let minX = Infinity;
379
+ let minY = Infinity;
380
+ let maxX = -Infinity;
381
+ let maxY = -Infinity;
382
+ for (const node of members) {
383
+ const { x, y } = node.position();
384
+ const { width, height } = node.size();
385
+ minX = Math.min(minX, x);
386
+ minY = Math.min(minY, y);
387
+ maxX = Math.max(maxX, x + width);
388
+ maxY = Math.max(maxY, y + height);
389
+ }
390
+ const padding = group.padding();
391
+ const header = group.headerHeight();
392
+ return {
393
+ x: minX - padding,
394
+ y: minY - padding - header,
395
+ width: maxX - minX + padding * 2,
396
+ height: maxY - minY + padding * 2 + header
397
+ };
398
+ }
399
+ /**
400
+ * The box a frame should be drawn at right now: its live bounds, except while a
401
+ * node drag is in flight, when it is pinned to where it stood as that drag
402
+ * began. A group being dragged is never pinned — it has to travel with the
403
+ * members it is carrying.
404
+ */
405
+ groupFrame(groupId) {
406
+ const drag = this.drag();
407
+ if (drag?.source === 'node') {
408
+ return drag.frames.get(groupId) ?? this.groupBounds(groupId);
409
+ }
410
+ return this.groupBounds(groupId);
411
+ }
412
+ /**
413
+ * Frames that would take one of the nodes being dragged if it were released
414
+ * now. Groups render this as a highlight, so which frame is about to claim the
415
+ * node is visible before the button comes up rather than after.
416
+ */
417
+ dropTargetGroups = computed(() => {
418
+ const drag = this.drag();
419
+ if (!drag || drag.source === 'group') {
420
+ return EMPTY_IDS;
421
+ }
422
+ const frames = [...drag.frames];
423
+ const targets = new Set();
424
+ for (const id of drag.ids) {
425
+ const node = this.nodeMap().get(id);
426
+ if (!node) {
427
+ continue;
428
+ }
429
+ const position = node.position();
430
+ const size = node.size();
431
+ const hit = smallestContaining({ x: position.x + size.width / 2, y: position.y + size.height / 2 }, frames);
432
+ if (hit) {
433
+ targets.add(hit);
434
+ }
435
+ }
436
+ return targets;
437
+ }, /* @ts-ignore */
438
+ ...(ngDevMode ? [{ debugName: "dropTargetGroups" }] : /* istanbul ignore next */ []));
439
+ /**
440
+ * Innermost group frame containing a point. Smallest-first, so a frame nested
441
+ * inside another wins the node that lands in the overlap.
442
+ */
443
+ groupAt(point) {
444
+ const frames = this.groups()
445
+ .map(group => [group.groupId(), this.groupBounds(group.groupId())])
446
+ .filter((entry) => !!entry[1]);
447
+ return smallestContaining(point, frames);
448
+ }
449
+ // ------------------------------------------------------------------ dragging
450
+ /**
451
+ * Capture the starting position of everything a drag will move.
452
+ *
453
+ * Deltas are applied to these captured origins rather than accumulated onto
454
+ * live positions, so snapping cannot compound rounding error over a long drag.
455
+ */
456
+ beginNodeDrag(nodeId) {
457
+ const selected = this.selectedNodes();
458
+ this.captureDrag(selected.has(nodeId) ? selected : [nodeId], 'node');
459
+ }
460
+ /**
461
+ * Drag a whole group: every member moves together, and the frame follows them
462
+ * because it is sized from where they are.
463
+ *
464
+ * @internal
465
+ */
466
+ beginGroupDrag(groupId) {
467
+ this.captureDrag(this.nodesInGroup(groupId).map(node => node.nodeId()), 'group');
468
+ }
469
+ captureDrag(ids, source) {
470
+ const frames = new Map();
471
+ this.dragOrigins = new Map();
472
+ this.dragMoved = false;
473
+ for (const group of this.groups()) {
474
+ const bounds = this.groupBounds(group.groupId());
475
+ if (bounds) {
476
+ frames.set(group.groupId(), bounds);
477
+ }
478
+ }
479
+ for (const id of ids) {
480
+ const node = this.nodeMap().get(id);
481
+ if (node && !node.locked()) {
482
+ this.dragOrigins.set(id, node.position());
483
+ }
484
+ }
485
+ this.drag.set({ source, ids: new Set(this.dragOrigins.keys()), frames });
486
+ }
487
+ /** Offset every dragged node from its captured origin, in graph units. */
488
+ dragNodesBy(delta) {
489
+ if (delta.x !== 0 || delta.y !== 0) {
490
+ this.dragMoved = true;
491
+ }
492
+ for (const [id, origin] of this.dragOrigins) {
493
+ this.nodeMap()
494
+ .get(id)
495
+ ?.moveTo(this.snap({ x: origin.x + delta.x, y: origin.y + delta.y }));
496
+ }
497
+ }
498
+ /** Ends the drag and raises `nodeMove`. Returns whether anything actually moved. */
499
+ endNodeDrag() {
500
+ const moved = this.dragMoved;
501
+ const drag = this.drag();
502
+ const source = drag?.source ?? 'node';
503
+ if (moved) {
504
+ const nodes = [...this.dragOrigins.keys()]
505
+ .map(id => this.nodeMap().get(id))
506
+ .filter((node) => !!node)
507
+ .map(node => {
508
+ const position = node.position();
509
+ const size = node.size();
510
+ // The centre, not the corner: a node half over a frame's edge reads as
511
+ // being wherever the bulk of it sits.
512
+ const centre = { x: position.x + size.width / 2, y: position.y + size.height / 2 };
513
+ return {
514
+ nodeId: node.nodeId(),
515
+ position,
516
+ // A group drag carries its own members around; none of them can have
517
+ // changed hands, so there is nothing to report.
518
+ group: source === 'group' || !drag ? undefined : smallestContaining(centre, drag.frames)
519
+ };
520
+ });
521
+ this.listeners?.nodeMove({ source, nodes });
522
+ }
523
+ this.dragOrigins = new Map();
524
+ this.dragMoved = false;
525
+ this.drag.set(null);
526
+ return moved;
527
+ }
528
+ // ------------------------------------------------------------------- linking
529
+ /** @internal */
530
+ beginLink(port, pointer) {
531
+ this.linking.set({
532
+ from: { nodeId: port.nodeId(), portId: port.portId() },
533
+ fromKey: port.key,
534
+ reversed: port.direction() === 'input',
535
+ pointer,
536
+ hoverKey: null
537
+ });
538
+ }
539
+ /** @internal */
540
+ moveLink(pointer) {
541
+ this.linking.update(state => (state ? { ...state, pointer } : null));
542
+ }
543
+ /** @internal */
544
+ setLinkHover(key) {
545
+ this.linking.update(state => (state ? { ...state, hoverKey: key } : null));
546
+ }
547
+ /**
548
+ * Finish a link. Dropping on a legal port raises `connect`; dropping anywhere
549
+ * else raises `connectionDrop` so the host can offer to create a node there.
550
+ *
551
+ * @internal
552
+ */
553
+ endLink(dropKey) {
554
+ const state = this.linking();
555
+ this.linking.set(null);
556
+ if (!state) {
557
+ return;
558
+ }
559
+ const connection = dropKey && this.orient(state, dropKey);
560
+ if (connection && this.canConnect(connection)) {
561
+ this.listeners?.connect(connection);
562
+ return;
563
+ }
564
+ if (!dropKey) {
565
+ const port = this.descriptor(state.fromKey);
566
+ if (port) {
567
+ this.listeners?.connectionDrop({
568
+ source: state.from,
569
+ port,
570
+ position: state.pointer,
571
+ surfacePosition: this.toSurfacePoint(state.pointer)
572
+ });
573
+ }
574
+ }
575
+ }
576
+ /** Resolve which end of a dragged wire is the source. */
577
+ orient(state, dropKey) {
578
+ const dropped = this.portMap().get(dropKey);
579
+ if (!dropped) {
580
+ return null;
581
+ }
582
+ const droppedRef = { nodeId: dropped.nodeId(), portId: dropped.portId() };
583
+ return state.reversed ? { source: droppedRef, target: state.from } : { source: state.from, target: droppedRef };
584
+ }
585
+ /**
586
+ * How a port should render while a wire is in flight — the affordance that
587
+ * tells you where a wire may land before you release it.
588
+ */
589
+ linkState(key) {
590
+ const state = this.linking();
591
+ if (!state) {
592
+ return 'idle';
593
+ }
594
+ if (state.fromKey === key) {
595
+ return 'source';
596
+ }
597
+ const connection = this.orient(state, key);
598
+ return connection && this.canConnect(connection) ? 'valid' : 'invalid';
599
+ }
600
+ /**
601
+ * The full connection rule set: existence, arity, direction, duplicates and
602
+ * data type, then the host's own validator, which can only narrow the result.
603
+ */
604
+ canConnect(connection) {
605
+ const settings = this.settings();
606
+ if (settings.locked) {
607
+ return false;
608
+ }
609
+ const sourceKey = xuiGraphPortKey(connection.source.nodeId, connection.source.portId);
610
+ const targetKey = xuiGraphPortKey(connection.target.nodeId, connection.target.portId);
611
+ if (sourceKey === targetKey) {
612
+ return false;
613
+ }
614
+ const source = this.descriptor(sourceKey);
615
+ const target = this.descriptor(targetKey);
616
+ if (!source || !target || source.disabled || target.disabled) {
617
+ return false;
618
+ }
619
+ if (!settings.allowSelfConnection && source.nodeId === target.nodeId) {
620
+ return false;
621
+ }
622
+ // An `inout` port plays either role; a plain input can never be a source.
623
+ if (source.direction === 'input' || target.direction === 'output') {
624
+ return false;
625
+ }
626
+ if (source.connections >= source.maxConnections || target.connections >= target.maxConnections) {
627
+ return false;
628
+ }
629
+ const edges = this.edges();
630
+ const duplicate = edges.some(edge => samePort(edge.source, connection.source) && samePort(edge.target, connection.target));
631
+ if (duplicate) {
632
+ return false;
633
+ }
634
+ // An undeclared type is a wildcard, so untyped graphs need no configuration.
635
+ if (settings.enforcePortTypes && source.dataType && target.dataType && source.dataType !== target.dataType) {
636
+ return false;
637
+ }
638
+ return settings.isValidConnection?.(connection, { source, target, edges }) ?? true;
639
+ }
640
+ /** @nocollapse */ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: XuiNodeGraphStore, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
641
+ /** @nocollapse */ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: XuiNodeGraphStore });
642
+ }
643
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: XuiNodeGraphStore, decorators: [{
644
+ type: Injectable
645
+ }] });
646
+ /**
647
+ * The smallest of the frames containing a point, so a frame nested inside
648
+ * another wins the node that lands in the overlap.
649
+ */
650
+ function smallestContaining(point, frames) {
651
+ let best;
652
+ let bestArea = Infinity;
653
+ for (const [id, bounds] of frames) {
654
+ const inside = point.x >= bounds.x &&
655
+ point.x <= bounds.x + bounds.width &&
656
+ point.y >= bounds.y &&
657
+ point.y <= bounds.y + bounds.height;
658
+ const area = bounds.width * bounds.height;
659
+ if (inside && area < bestArea) {
660
+ bestArea = area;
661
+ best = id;
662
+ }
663
+ }
664
+ return best;
665
+ }
666
+ /** Shared so the `edges` computed keeps a stable identity while unbound. */
667
+ const EMPTY_EDGES = [];
668
+ const EMPTY_IDS = new Set();
669
+ function samePort(a, b) {
670
+ return a.nodeId === b.nodeId && a.portId === b.portId;
671
+ }
672
+ /** Used only if a node or port is somehow instantiated outside a graph. @internal */
673
+ const FALLBACK_SETTINGS = {
674
+ routing: 'bezier',
675
+ gridSize: 16,
676
+ snapToGrid: false,
677
+ minZoom: 0.15,
678
+ maxZoom: 4,
679
+ stubLength: 18,
680
+ cornerRadius: 8,
681
+ curvature: 0.5,
682
+ edgeWidth: 2,
683
+ portSize: 11,
684
+ portShape: 'circle',
685
+ portColor: 'var(--color-foreground-subtle)',
686
+ portTypes: {},
687
+ allowSelfConnection: false,
688
+ enforcePortTypes: true,
689
+ isValidConnection: undefined,
690
+ locked: false
691
+ };
692
+ /**
693
+ * The store for the enclosing `<xui-node-graph>`.
694
+ *
695
+ * Use it to drive the canvas from outside — `fitView()`, `zoomBy()`,
696
+ * `setSelection()` — from a toolbar or a host component.
697
+ */
698
+ function injectXuiNodeGraphStore() {
699
+ return inject(XuiNodeGraphStore);
700
+ }
701
+
702
+ const defaultConfig = {
703
+ routing: 'bezier',
704
+ background: 'dots',
705
+ marker: 'none',
706
+ gridSize: 16,
707
+ gridMajorEvery: 5,
708
+ snapToGrid: false,
709
+ minZoom: 0.15,
710
+ maxZoom: 4,
711
+ zoomStep: 1.15,
712
+ stubLength: 18,
713
+ cornerRadius: 8,
714
+ curvature: 0.5,
715
+ edgeWidth: 2,
716
+ portSize: 11,
717
+ portShape: 'circle',
718
+ portColor: 'var(--color-foreground-subtle)',
719
+ portTypes: {},
720
+ allowSelfConnection: false,
721
+ enforcePortTypes: true
722
+ };
723
+ const XuiNodeGraphConfigToken = new InjectionToken('XuiNodeGraphConfig');
724
+ function provideXuiNodeGraphConfig(config) {
725
+ return { provide: XuiNodeGraphConfigToken, useValue: { ...defaultConfig, ...config } };
726
+ }
727
+ function injectXuiNodeGraphConfig() {
728
+ return inject(XuiNodeGraphConfigToken, { optional: true }) ?? defaultConfig;
729
+ }
730
+ /**
731
+ * A ready-made data-type palette drawn from the categorical chart ramp, for
732
+ * graphs that need distinguishable typed ports without designing a palette.
733
+ *
734
+ * ```ts
735
+ * provideXuiNodeGraphConfig({ portTypes: { ...xuiGraphChartPortTypes(['float', 'vector', 'color']) } })
736
+ * ```
737
+ */
738
+ function xuiGraphChartPortTypes(types) {
739
+ return Object.fromEntries(types.map((type, index) => [type, { color: `var(--color-chart-${(index % 8) + 1})`, label: type }]));
740
+ }
741
+
742
+ /**
743
+ * Zoom and framing buttons for the enclosing canvas.
744
+ *
745
+ * ```html
746
+ * <xui-node-graph>
747
+ * …
748
+ * <xui-graph-controls class="bottom-4 left-4" />
749
+ * </xui-node-graph>
750
+ * ```
751
+ *
752
+ * Placed anywhere inside `<xui-node-graph>`; it is projected into the overlay
753
+ * layer, above the canvas and outside its transform, so it neither pans nor
754
+ * scales with the content.
755
+ */
756
+ class XuiGraphControls {
757
+ config = injectXuiNodeGraphConfig();
758
+ store = inject(XuiNodeGraphStore);
759
+ step = this.config.zoomStep;
760
+ buttonClass = 'text-foreground-muted hover:bg-hover-overlay hover:text-foreground focus-visible:ring-focus flex h-7 w-7 ' +
761
+ 'cursor-pointer items-center justify-center rounded transition-colors focus-visible:ring-2 focus-visible:outline-none';
762
+ class = input('', /* @ts-ignore */
763
+ ...(ngDevMode ? [{ debugName: "class" }] : /* istanbul ignore next */ []));
764
+ showFit = input(true, { ...(ngDevMode ? { debugName: "showFit" } : /* istanbul ignore next */ {}), transform: booleanAttribute });
765
+ showZoomLevel = input(true, { ...(ngDevMode ? { debugName: "showZoomLevel" } : /* istanbul ignore next */ {}), transform: booleanAttribute });
766
+ /** Lay the buttons out in a row rather than a column. */
767
+ horizontal = input(false, { ...(ngDevMode ? { debugName: "horizontal" } : /* istanbul ignore next */ {}), transform: booleanAttribute });
768
+ zoomPercent = computed(() => Math.round(this.store.viewport().zoom * 100), /* @ts-ignore */
769
+ ...(ngDevMode ? [{ debugName: "zoomPercent" }] : /* istanbul ignore next */ []));
770
+ computedClass = computed(() => xui('bg-surface-overlay border-border shadow-elevation-2 pointer-events-auto absolute z-10 flex gap-0.5 rounded-md border p-1 select-none', this.horizontal() ? 'flex-row items-center' : 'flex-col',
771
+ // A sensible default corner; override with `class` to move it.
772
+ 'bottom-4 left-4', this.class()), /* @ts-ignore */
773
+ ...(ngDevMode ? [{ debugName: "computedClass" }] : /* istanbul ignore next */ []));
774
+ /** @nocollapse */ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: XuiGraphControls, deps: [], target: i0.ɵɵFactoryTarget.Component });
775
+ /** @nocollapse */ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.0.8", type: XuiGraphControls, isStandalone: true, selector: "xui-graph-controls", inputs: { class: { classPropertyName: "class", publicName: "class", isSignal: true, isRequired: false, transformFunction: null }, showFit: { classPropertyName: "showFit", publicName: "showFit", isSignal: true, isRequired: false, transformFunction: null }, showZoomLevel: { classPropertyName: "showZoomLevel", publicName: "showZoomLevel", isSignal: true, isRequired: false, transformFunction: null }, horizontal: { classPropertyName: "horizontal", publicName: "horizontal", isSignal: true, isRequired: false, transformFunction: null } }, host: { attributes: { "data-xui-graph-overlay": "" }, properties: { "class": "computedClass()" } }, ngImport: i0, template: `
776
+ <button type="button" [class]="buttonClass" aria-label="Zoom in" (click)="store.zoomBy(step)">
777
+ <svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true">
778
+ <path d="M7 2 V12 M2 7 H12" [attr.stroke]="'currentColor'" stroke-width="1.5" stroke-linecap="round" />
779
+ </svg>
780
+ </button>
781
+
782
+ <button type="button" [class]="buttonClass" aria-label="Zoom out" (click)="store.zoomBy(1 / step)">
783
+ <svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true">
784
+ <path d="M2 7 H12" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
785
+ </svg>
786
+ </button>
787
+
788
+ @if (showFit()) {
789
+ <button type="button" [class]="buttonClass" aria-label="Fit view" (click)="store.fitView()">
790
+ <svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
791
+ <path
792
+ d="M2 5 V2 H5 M9 2 H12 V5 M12 9 V12 H9 M5 12 H2 V9"
793
+ stroke="currentColor"
794
+ stroke-width="1.5"
795
+ stroke-linecap="round"
796
+ stroke-linejoin="round"
797
+ />
798
+ </svg>
799
+ </button>
800
+ }
801
+
802
+ @if (showZoomLevel()) {
803
+ <span class="text-foreground-subtle px-1 text-center text-[10px] tabular-nums">{{ zoomPercent() }}%</span>
804
+ }
805
+
806
+ <ng-content />
807
+ `, isInline: true, changeDetection: i0.ChangeDetectionStrategy.OnPush, encapsulation: i0.ViewEncapsulation.None });
808
+ }
809
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: XuiGraphControls, decorators: [{
810
+ type: Component,
811
+ args: [{
812
+ selector: 'xui-graph-controls',
813
+ template: `
814
+ <button type="button" [class]="buttonClass" aria-label="Zoom in" (click)="store.zoomBy(step)">
815
+ <svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true">
816
+ <path d="M7 2 V12 M2 7 H12" [attr.stroke]="'currentColor'" stroke-width="1.5" stroke-linecap="round" />
817
+ </svg>
818
+ </button>
819
+
820
+ <button type="button" [class]="buttonClass" aria-label="Zoom out" (click)="store.zoomBy(1 / step)">
821
+ <svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true">
822
+ <path d="M2 7 H12" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
823
+ </svg>
824
+ </button>
825
+
826
+ @if (showFit()) {
827
+ <button type="button" [class]="buttonClass" aria-label="Fit view" (click)="store.fitView()">
828
+ <svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
829
+ <path
830
+ d="M2 5 V2 H5 M9 2 H12 V5 M12 9 V12 H9 M5 12 H2 V9"
831
+ stroke="currentColor"
832
+ stroke-width="1.5"
833
+ stroke-linecap="round"
834
+ stroke-linejoin="round"
835
+ />
836
+ </svg>
837
+ </button>
838
+ }
839
+
840
+ @if (showZoomLevel()) {
841
+ <span class="text-foreground-subtle px-1 text-center text-[10px] tabular-nums">{{ zoomPercent() }}%</span>
842
+ }
843
+
844
+ <ng-content />
845
+ `,
846
+ host: { '[class]': 'computedClass()', 'data-xui-graph-overlay': '' },
847
+ changeDetection: ChangeDetectionStrategy.OnPush,
848
+ encapsulation: ViewEncapsulation.None
849
+ }]
850
+ }], propDecorators: { class: [{ type: i0.Input, args: [{ isSignal: true, alias: "class", required: false }] }], showFit: [{ type: i0.Input, args: [{ isSignal: true, alias: "showFit", required: false }] }], showZoomLevel: [{ type: i0.Input, args: [{ isSignal: true, alias: "showZoomLevel", required: false }] }], horizontal: [{ type: i0.Input, args: [{ isSignal: true, alias: "horizontal", required: false }] }] } });
851
+
852
+ /** Room reserved above the members for the title bar, in graph units. */
853
+ const HEADER_HEIGHT = 26;
854
+ /**
855
+ * A frame around a set of nodes that moves them all together.
856
+ *
857
+ * ```html
858
+ * <xui-node-graph>
859
+ * <xui-graph-group groupId="filter" label="Filter stage" color="var(--color-chart-1)" />
860
+ *
861
+ * <xui-graph-node nodeId="lp" group="filter" [(position)]="a">…</xui-graph-node>
862
+ * <xui-graph-node nodeId="hp" group="filter" [(position)]="b">…</xui-graph-node>
863
+ * </xui-node-graph>
864
+ * ```
865
+ *
866
+ * Membership is declared by the nodes, not by nesting them inside the frame:
867
+ * a node keeps its own place in the template and its own `[(position)]`, and the
868
+ * frame is derived from wherever its members happen to be. That is what lets a
869
+ * group be added, removed or re-membered without moving anything in the DOM, and
870
+ * why dragging a member out of a frame is just an ordinary node drag.
871
+ *
872
+ * A group with no members has no box, so it renders nothing.
873
+ *
874
+ * To let a node join a frame by being dropped on it, read `group` off the graph's
875
+ * `nodeMove` event and reassign the node's own `group` input.
876
+ */
877
+ class XuiGraphGroup {
878
+ store = inject(XuiNodeGraphStore);
879
+ element = inject(ElementRef).nativeElement;
880
+ dragOrigin = signal(null, /* @ts-ignore */
881
+ ...(ngDevMode ? [{ debugName: "dragOrigin" }] : /* istanbul ignore next */ []));
882
+ /** Matches the `group` input on the nodes that belong to this frame. */
883
+ groupId = input.required(/* @ts-ignore */
884
+ ...(ngDevMode ? [{ debugName: "groupId" }] : /* istanbul ignore next */ []));
885
+ class = input('', /* @ts-ignore */
886
+ ...(ngDevMode ? [{ debugName: "class" }] : /* istanbul ignore next */ []));
887
+ label = input('', /* @ts-ignore */
888
+ ...(ngDevMode ? [{ debugName: "label" }] : /* istanbul ignore next */ []));
889
+ /** Border and title colour; a tint of it fills the frame. Any CSS colour. */
890
+ color = input('var(--color-border-strong)', /* @ts-ignore */
891
+ ...(ngDevMode ? [{ debugName: "color" }] : /* istanbul ignore next */ []));
892
+ /** Blank margin between the members' bounding box and the frame. */
893
+ padding = input(24, { ...(ngDevMode ? { debugName: "padding" } : /* istanbul ignore next */ {}), transform: numberAttribute });
894
+ selectable = input(true, { ...(ngDevMode ? { debugName: "selectable" } : /* istanbul ignore next */ {}), transform: booleanAttribute });
895
+ draggable = input(true, { ...(ngDevMode ? { debugName: "draggable" } : /* istanbul ignore next */ {}), transform: booleanAttribute });
896
+ /** Pins the frame and everything in it. */
897
+ locked = input(false, { ...(ngDevMode ? { debugName: "locked" } : /* istanbul ignore next */ {}), transform: booleanAttribute });
898
+ /**
899
+ * `group` drags from anywhere on the frame, as a node editor's frames do.
900
+ * `header` restricts it to the title bar, which leaves the frame's interior
901
+ * free for panning and marquee selection.
902
+ */
903
+ dragHandle = input('group', /* @ts-ignore */
904
+ ...(ngDevMode ? [{ debugName: "dragHandle" }] : /* istanbul ignore next */ []));
905
+ /**
906
+ * @internal Part of {@link XuiGraphGroupHandle}.
907
+ *
908
+ * A frame only reserves room for a title bar when it has a label — an unlabelled
909
+ * frame is a plain box, and projected content rides along inside the labelled one.
910
+ */
911
+ headerHeight = computed(() => (this.label() ? HEADER_HEIGHT : 0), /* @ts-ignore */
912
+ ...(ngDevMode ? [{ debugName: "headerHeight" }] : /* istanbul ignore next */ []));
913
+ /**
914
+ * The frame, in graph space. `null` while the group has no members.
915
+ *
916
+ * Pinned to where it stood at the start of a node drag, so a member dragged out
917
+ * leaves a box that stays still instead of stretching it and snapping back.
918
+ */
919
+ bounds = computed(() => this.store.groupFrame(this.groupId()), /* @ts-ignore */
920
+ ...(ngDevMode ? [{ debugName: "bounds" }] : /* istanbul ignore next */ []));
921
+ /** `true` while a dragged node hovers over this frame and would land in it. */
922
+ dropTarget = computed(() => this.store.dropTargetGroups().has(this.groupId()), /* @ts-ignore */
923
+ ...(ngDevMode ? [{ debugName: "dropTarget" }] : /* istanbul ignore next */ []));
924
+ members = computed(() => this.store.nodesInGroup(this.groupId()).map(node => node.nodeId()), /* @ts-ignore */
925
+ ...(ngDevMode ? [{ debugName: "members" }] : /* istanbul ignore next */ []));
926
+ /** A frame reads as selected only when its whole membership is. */
927
+ selected = computed(() => {
928
+ const members = this.members();
929
+ return members.length > 0 && members.every(id => this.store.isNodeSelected(id));
930
+ }, /* @ts-ignore */
931
+ ...(ngDevMode ? [{ debugName: "selected" }] : /* istanbul ignore next */ []));
932
+ immovable = computed(() => this.locked() || !this.draggable() || this.store.settings().locked, /* @ts-ignore */
933
+ ...(ngDevMode ? [{ debugName: "immovable" }] : /* istanbul ignore next */ []));
934
+ transform = computed(() => {
935
+ const bounds = this.bounds();
936
+ return bounds ? `translate(${bounds.x}px, ${bounds.y}px)` : null;
937
+ }, /* @ts-ignore */
938
+ ...(ngDevMode ? [{ debugName: "transform" }] : /* istanbul ignore next */ []));
939
+ cursor = computed(() => (this.dragOrigin() ? 'grabbing' : this.immovable() ? null : 'grab'), /* @ts-ignore */
940
+ ...(ngDevMode ? [{ debugName: "cursor" }] : /* istanbul ignore next */ []));
941
+ // Deepen the tint when the frame is about to claim a node, so the answer to
942
+ // "which group is this going into?" is visible before the button comes up.
943
+ background = computed(() => `color-mix(in oklab, ${this.color()} ${this.dropTarget() ? 22 : 10}%, transparent)`, /* @ts-ignore */
944
+ ...(ngDevMode ? [{ debugName: "background" }] : /* istanbul ignore next */ []));
945
+ computedClass = computed(() => xui(
946
+ // z-index 0 puts the frame under every node, so pressing a member still
947
+ // moves that member and only the gaps between them belong to the group.
948
+ 'absolute top-0 left-0 z-0 flex flex-col rounded-xl border-2 border-dashed transition-colors select-none', (this.selected() || this.dropTarget()) && 'border-solid', this.class()), /* @ts-ignore */
949
+ ...(ngDevMode ? [{ debugName: "computedClass" }] : /* istanbul ignore next */ []));
950
+ constructor() {
951
+ inject(DestroyRef).onDestroy(() => this.store.unregisterGroup(this));
952
+ }
953
+ ngOnInit() {
954
+ this.store.registerGroup(this);
955
+ }
956
+ onPointerDown(event) {
957
+ const target = event.target;
958
+ if (event.button !== 0) {
959
+ return;
960
+ }
961
+ // When the frame only answers to its title bar, a press on its interior is
962
+ // left to bubble so the canvas can still pan or marquee through it.
963
+ if (this.dragHandle() === 'header' && !target.closest('[data-xui-graph-group-header]')) {
964
+ return;
965
+ }
966
+ event.stopPropagation();
967
+ if (this.selectable()) {
968
+ const additive = event.shiftKey || event.ctrlKey || event.metaKey;
969
+ this.store.setSelection(additive ? [...this.store.selectedNodes(), ...this.members()] : this.members());
970
+ }
971
+ if (this.immovable()) {
972
+ return;
973
+ }
974
+ event.preventDefault();
975
+ this.element.setPointerCapture(event.pointerId);
976
+ this.dragOrigin.set({ x: event.clientX, y: event.clientY });
977
+ this.store.beginGroupDrag(this.groupId());
978
+ }
979
+ onPointerMove(event) {
980
+ const origin = this.dragOrigin();
981
+ if (!origin) {
982
+ return;
983
+ }
984
+ const { zoom } = this.store.viewport();
985
+ this.store.dragNodesBy({ x: (event.clientX - origin.x) / zoom, y: (event.clientY - origin.y) / zoom });
986
+ }
987
+ onPointerUp(event) {
988
+ if (!this.dragOrigin()) {
989
+ return;
990
+ }
991
+ this.dragOrigin.set(null);
992
+ this.element.releasePointerCapture(event.pointerId);
993
+ this.store.endNodeDrag();
994
+ }
995
+ /** @nocollapse */ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: XuiGraphGroup, deps: [], target: i0.ɵɵFactoryTarget.Component });
996
+ /** @nocollapse */ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.0.8", type: XuiGraphGroup, isStandalone: true, selector: "xui-graph-group", inputs: { groupId: { classPropertyName: "groupId", publicName: "groupId", isSignal: true, isRequired: true, transformFunction: null }, class: { classPropertyName: "class", publicName: "class", isSignal: true, isRequired: false, transformFunction: null }, label: { classPropertyName: "label", publicName: "label", isSignal: true, isRequired: false, transformFunction: null }, color: { classPropertyName: "color", publicName: "color", isSignal: true, isRequired: false, transformFunction: null }, padding: { classPropertyName: "padding", publicName: "padding", isSignal: true, isRequired: false, transformFunction: null }, selectable: { classPropertyName: "selectable", publicName: "selectable", isSignal: true, isRequired: false, transformFunction: null }, draggable: { classPropertyName: "draggable", publicName: "draggable", isSignal: true, isRequired: false, transformFunction: null }, locked: { classPropertyName: "locked", publicName: "locked", isSignal: true, isRequired: false, transformFunction: null }, dragHandle: { classPropertyName: "dragHandle", publicName: "dragHandle", isSignal: true, isRequired: false, transformFunction: null } }, host: { listeners: { "pointerdown": "onPointerDown($event)", "pointermove": "onPointerMove($event)", "pointerup": "onPointerUp($event)", "pointercancel": "onPointerUp($event)" }, properties: { "class": "computedClass()", "attr.data-xui-graph-group": "groupId()", "style.transform": "transform()", "style.width.px": "bounds()?.width", "style.height.px": "bounds()?.height", "style.display": "bounds() ? null : \"none\"", "style.border-color": "color()", "style.background": "background()", "style.cursor": "cursor()" } }, ngImport: i0, template: `
997
+ @if (label()) {
998
+ <div
999
+ data-xui-graph-group-header
1000
+ class="flex items-center gap-2 truncate px-2.5 text-xs font-medium"
1001
+ [style.height.px]="headerHeight()"
1002
+ [style.color]="color()"
1003
+ >
1004
+ <span class="truncate">{{ label() }}</span>
1005
+ <ng-content />
1006
+ </div>
1007
+ }
1008
+ `, isInline: true, changeDetection: i0.ChangeDetectionStrategy.OnPush, encapsulation: i0.ViewEncapsulation.None });
1009
+ }
1010
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: XuiGraphGroup, decorators: [{
1011
+ type: Component,
1012
+ args: [{
1013
+ selector: 'xui-graph-group',
1014
+ template: `
1015
+ @if (label()) {
1016
+ <div
1017
+ data-xui-graph-group-header
1018
+ class="flex items-center gap-2 truncate px-2.5 text-xs font-medium"
1019
+ [style.height.px]="headerHeight()"
1020
+ [style.color]="color()"
1021
+ >
1022
+ <span class="truncate">{{ label() }}</span>
1023
+ <ng-content />
1024
+ </div>
1025
+ }
1026
+ `,
1027
+ host: {
1028
+ '[class]': 'computedClass()',
1029
+ '[attr.data-xui-graph-group]': 'groupId()',
1030
+ '[style.transform]': 'transform()',
1031
+ '[style.width.px]': 'bounds()?.width',
1032
+ '[style.height.px]': 'bounds()?.height',
1033
+ '[style.display]': 'bounds() ? null : "none"',
1034
+ '[style.border-color]': 'color()',
1035
+ '[style.background]': 'background()',
1036
+ '[style.cursor]': 'cursor()',
1037
+ '(pointerdown)': 'onPointerDown($event)',
1038
+ '(pointermove)': 'onPointerMove($event)',
1039
+ '(pointerup)': 'onPointerUp($event)',
1040
+ '(pointercancel)': 'onPointerUp($event)'
1041
+ },
1042
+ changeDetection: ChangeDetectionStrategy.OnPush,
1043
+ encapsulation: ViewEncapsulation.None
1044
+ }]
1045
+ }], ctorParameters: () => [], propDecorators: { groupId: [{ type: i0.Input, args: [{ isSignal: true, alias: "groupId", required: true }] }], class: [{ type: i0.Input, args: [{ isSignal: true, alias: "class", required: false }] }], label: [{ type: i0.Input, args: [{ isSignal: true, alias: "label", required: false }] }], color: [{ type: i0.Input, args: [{ isSignal: true, alias: "color", required: false }] }], padding: [{ type: i0.Input, args: [{ isSignal: true, alias: "padding", required: false }] }], selectable: [{ type: i0.Input, args: [{ isSignal: true, alias: "selectable", required: false }] }], draggable: [{ type: i0.Input, args: [{ isSignal: true, alias: "draggable", required: false }] }], locked: [{ type: i0.Input, args: [{ isSignal: true, alias: "locked", required: false }] }], dragHandle: [{ type: i0.Input, args: [{ isSignal: true, alias: "dragHandle", required: false }] }] } });
1046
+
1047
+ /**
1048
+ * An overview of the whole graph with the current viewport drawn on it.
1049
+ *
1050
+ * ```html
1051
+ * <xui-node-graph>
1052
+ * …
1053
+ * <xui-graph-minimap class="right-4 bottom-4" />
1054
+ * </xui-node-graph>
1055
+ * ```
1056
+ *
1057
+ * Click or drag inside it to move the view. Like the controls, it is projected
1058
+ * into the overlay layer and so stays put while the canvas pans.
1059
+ */
1060
+ class XuiGraphMinimap {
1061
+ store = inject(XuiNodeGraphStore);
1062
+ element = inject(ElementRef).nativeElement;
1063
+ dragging = false;
1064
+ class = input('', /* @ts-ignore */
1065
+ ...(ngDevMode ? [{ debugName: "class" }] : /* istanbul ignore next */ []));
1066
+ /** Blank margin around the content, in graph units. */
1067
+ padding = input(40, { ...(ngDevMode ? { debugName: "padding" } : /* istanbul ignore next */ {}), transform: numberAttribute });
1068
+ nodes = computed(() => this.store.nodes().map(node => {
1069
+ const { x, y } = node.position();
1070
+ const { width, height } = node.size();
1071
+ return { id: node.nodeId(), x, y, width, height, selected: this.store.isNodeSelected(node.nodeId()) };
1072
+ }), /* @ts-ignore */
1073
+ ...(ngDevMode ? [{ debugName: "nodes" }] : /* istanbul ignore next */ []));
1074
+ /**
1075
+ * The extent the minimap shows: everything drawn, unioned with wherever the
1076
+ * viewport currently is, so panning off into empty space still tells you where
1077
+ * you are instead of silently clamping.
1078
+ */
1079
+ extent = computed(() => {
1080
+ const content = this.store.contentBounds();
1081
+ const view = this.viewportRect();
1082
+ const padding = this.padding();
1083
+ if (!content && !view) {
1084
+ return { x: 0, y: 0, width: 1, height: 1 };
1085
+ }
1086
+ const boxes = [content, view].filter((box) => !!box);
1087
+ const minX = Math.min(...boxes.map(box => box.x)) - padding;
1088
+ const minY = Math.min(...boxes.map(box => box.y)) - padding;
1089
+ const maxX = Math.max(...boxes.map(box => box.x + box.width)) + padding;
1090
+ const maxY = Math.max(...boxes.map(box => box.y + box.height)) + padding;
1091
+ return { x: minX, y: minY, width: Math.max(maxX - minX, 1), height: Math.max(maxY - minY, 1) };
1092
+ }, /* @ts-ignore */
1093
+ ...(ngDevMode ? [{ debugName: "extent" }] : /* istanbul ignore next */ []));
1094
+ viewportRect = computed(() => {
1095
+ const { x, y, zoom } = this.store.viewport();
1096
+ const { width, height } = this.store.surfaceSize();
1097
+ if (width === 0 || height === 0) {
1098
+ return null;
1099
+ }
1100
+ return { x: -x / zoom, y: -y / zoom, width: width / zoom, height: height / zoom };
1101
+ }, /* @ts-ignore */
1102
+ ...(ngDevMode ? [{ debugName: "viewportRect" }] : /* istanbul ignore next */ []));
1103
+ viewBox = computed(() => {
1104
+ const { x, y, width, height } = this.extent();
1105
+ return `${x} ${y} ${width} ${height}`;
1106
+ }, /* @ts-ignore */
1107
+ ...(ngDevMode ? [{ debugName: "viewBox" }] : /* istanbul ignore next */ []));
1108
+ /** Keep hairlines and corners visually constant however far the extent zooms out. */
1109
+ unitScale = computed(() => this.extent().width / Math.max(this.element.clientWidth, 1), /* @ts-ignore */
1110
+ ...(ngDevMode ? [{ debugName: "unitScale" }] : /* istanbul ignore next */ []));
1111
+ strokeWidth = computed(() => this.unitScale(), /* @ts-ignore */
1112
+ ...(ngDevMode ? [{ debugName: "strokeWidth" }] : /* istanbul ignore next */ []));
1113
+ cornerRadius = computed(() => this.unitScale() * 2, /* @ts-ignore */
1114
+ ...(ngDevMode ? [{ debugName: "cornerRadius" }] : /* istanbul ignore next */ []));
1115
+ computedClass = computed(() => xui('bg-surface-overlay/90 border-border shadow-elevation-2 pointer-events-auto absolute z-10 block h-32 w-48 ' +
1116
+ 'overflow-hidden rounded-md border p-1 select-none', 'right-4 bottom-4', this.class()), /* @ts-ignore */
1117
+ ...(ngDevMode ? [{ debugName: "computedClass" }] : /* istanbul ignore next */ []));
1118
+ onPointerDown(event) {
1119
+ if (event.button !== 0) {
1120
+ return;
1121
+ }
1122
+ event.stopPropagation();
1123
+ this.dragging = true;
1124
+ event.target.setPointerCapture(event.pointerId);
1125
+ this.centerOnPointer(event);
1126
+ }
1127
+ onPointerMove(event) {
1128
+ if (this.dragging) {
1129
+ this.centerOnPointer(event);
1130
+ }
1131
+ }
1132
+ onPointerUp(event) {
1133
+ this.dragging = false;
1134
+ event.target.releasePointerCapture(event.pointerId);
1135
+ }
1136
+ /**
1137
+ * Map the pointer through the SVG's `preserveAspectRatio` fit and centre the
1138
+ * canvas there. The fit letterboxes on one axis, so the scale has to be taken
1139
+ * from the tighter of the two — using the element's own aspect ratio would put
1140
+ * the target off by the size of the letterbox.
1141
+ */
1142
+ centerOnPointer(event) {
1143
+ const rect = this.element.getBoundingClientRect();
1144
+ const extent = this.extent();
1145
+ if (rect.width === 0 || rect.height === 0) {
1146
+ return;
1147
+ }
1148
+ const scale = Math.min(rect.width / extent.width, rect.height / extent.height);
1149
+ const offsetX = (rect.width - extent.width * scale) / 2;
1150
+ const offsetY = (rect.height - extent.height * scale) / 2;
1151
+ const graphX = (event.clientX - rect.left - offsetX) / scale + extent.x;
1152
+ const graphY = (event.clientY - rect.top - offsetY) / scale + extent.y;
1153
+ const surface = this.store.surfaceSize();
1154
+ const { zoom } = this.store.viewport();
1155
+ this.store.panBy(surface.width / 2 - (graphX * zoom + this.store.viewport().x), surface.height / 2 - (graphY * zoom + this.store.viewport().y));
1156
+ }
1157
+ /** @nocollapse */ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: XuiGraphMinimap, deps: [], target: i0.ɵɵFactoryTarget.Component });
1158
+ /** @nocollapse */ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.0.8", type: XuiGraphMinimap, isStandalone: true, selector: "xui-graph-minimap", inputs: { class: { classPropertyName: "class", publicName: "class", isSignal: true, isRequired: false, transformFunction: null }, padding: { classPropertyName: "padding", publicName: "padding", isSignal: true, isRequired: false, transformFunction: null } }, host: { attributes: { "data-xui-graph-overlay": "" }, properties: { "class": "computedClass()" } }, ngImport: i0, template: `
1159
+ <svg
1160
+ class="h-full w-full cursor-pointer"
1161
+ [attr.viewBox]="viewBox()"
1162
+ preserveAspectRatio="xMidYMid meet"
1163
+ role="img"
1164
+ aria-label="Graph overview"
1165
+ (pointerdown)="onPointerDown($event)"
1166
+ (pointermove)="onPointerMove($event)"
1167
+ (pointerup)="onPointerUp($event)"
1168
+ >
1169
+ @for (node of nodes(); track node.id) {
1170
+ <rect
1171
+ [attr.x]="node.x"
1172
+ [attr.y]="node.y"
1173
+ [attr.width]="node.width"
1174
+ [attr.height]="node.height"
1175
+ [attr.rx]="cornerRadius()"
1176
+ [attr.fill]="node.selected ? 'var(--color-primary)' : 'var(--color-border-strong)'"
1177
+ />
1178
+ }
1179
+
1180
+ @if (viewportRect(); as rect) {
1181
+ <rect
1182
+ [attr.x]="rect.x"
1183
+ [attr.y]="rect.y"
1184
+ [attr.width]="rect.width"
1185
+ [attr.height]="rect.height"
1186
+ fill="var(--color-selection)"
1187
+ stroke="var(--color-primary)"
1188
+ [attr.stroke-width]="strokeWidth()"
1189
+ />
1190
+ }
1191
+ </svg>
1192
+ `, isInline: true, changeDetection: i0.ChangeDetectionStrategy.OnPush, encapsulation: i0.ViewEncapsulation.None });
1193
+ }
1194
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: XuiGraphMinimap, decorators: [{
1195
+ type: Component,
1196
+ args: [{
1197
+ selector: 'xui-graph-minimap',
1198
+ template: `
1199
+ <svg
1200
+ class="h-full w-full cursor-pointer"
1201
+ [attr.viewBox]="viewBox()"
1202
+ preserveAspectRatio="xMidYMid meet"
1203
+ role="img"
1204
+ aria-label="Graph overview"
1205
+ (pointerdown)="onPointerDown($event)"
1206
+ (pointermove)="onPointerMove($event)"
1207
+ (pointerup)="onPointerUp($event)"
1208
+ >
1209
+ @for (node of nodes(); track node.id) {
1210
+ <rect
1211
+ [attr.x]="node.x"
1212
+ [attr.y]="node.y"
1213
+ [attr.width]="node.width"
1214
+ [attr.height]="node.height"
1215
+ [attr.rx]="cornerRadius()"
1216
+ [attr.fill]="node.selected ? 'var(--color-primary)' : 'var(--color-border-strong)'"
1217
+ />
1218
+ }
1219
+
1220
+ @if (viewportRect(); as rect) {
1221
+ <rect
1222
+ [attr.x]="rect.x"
1223
+ [attr.y]="rect.y"
1224
+ [attr.width]="rect.width"
1225
+ [attr.height]="rect.height"
1226
+ fill="var(--color-selection)"
1227
+ stroke="var(--color-primary)"
1228
+ [attr.stroke-width]="strokeWidth()"
1229
+ />
1230
+ }
1231
+ </svg>
1232
+ `,
1233
+ host: { '[class]': 'computedClass()', 'data-xui-graph-overlay': '' },
1234
+ changeDetection: ChangeDetectionStrategy.OnPush,
1235
+ encapsulation: ViewEncapsulation.None
1236
+ }]
1237
+ }], propDecorators: { class: [{ type: i0.Input, args: [{ isSignal: true, alias: "class", required: false }] }], padding: [{ type: i0.Input, args: [{ isSignal: true, alias: "padding", required: false }] }] } });
1238
+
1239
+ /** @internal */
1240
+ const XUI_GRAPH_NODE = new InjectionToken('XuiGraphNode');
1241
+
1242
+ /** Side a port takes when the author states only its direction. */
1243
+ const SIDE_FOR_DIRECTION = {
1244
+ input: 'left',
1245
+ output: 'right',
1246
+ inout: 'right'
1247
+ };
1248
+ /**
1249
+ * A connector on a node, together with the row of content that belongs to it.
1250
+ *
1251
+ * ```html
1252
+ * <xui-graph-node nodeId="mul" label="Multiply" [(position)]="position">
1253
+ * <xui-graph-port portId="a" direction="input" dataType="float" label="A" />
1254
+ * <xui-graph-port portId="b" direction="input" dataType="float" label="B" />
1255
+ * <xui-graph-port portId="out" direction="output" dataType="float" label="Result" />
1256
+ * </xui-graph-node>
1257
+ * ```
1258
+ *
1259
+ * The host element is the whole row, so anything projected into it — a numeric
1260
+ * input, a colour swatch, a dropdown — sits inline with the connector, the way a
1261
+ * shader or VFX editor lays out its sockets. The connector itself is pinned to
1262
+ * the node's border, because that is what a wire attaches to.
1263
+ *
1264
+ * A hollow connector has nothing attached to it; a filled one is wired up.
1265
+ */
1266
+ class XuiGraphPort {
1267
+ store = inject(XuiNodeGraphStore);
1268
+ node = inject(XUI_GRAPH_NODE);
1269
+ document = inject(ElementRef).nativeElement.ownerDocument;
1270
+ connectorRef = viewChild.required('connector', /* @ts-ignore */
1271
+ ...(ngDevMode ? [{ debugName: "connectorRef" }] : /* istanbul ignore next */ []));
1272
+ offsetState = signal({ x: 0, y: 0 }, /* @ts-ignore */
1273
+ ...(ngDevMode ? [{ debugName: "offsetState" }] : /* istanbul ignore next */ []));
1274
+ /**
1275
+ * Registry identity. Empty until `ngOnInit`, because a required input is not
1276
+ * readable during construction.
1277
+ */
1278
+ key = '';
1279
+ /** Unique within the owning node — the node id supplies the rest of the identity. */
1280
+ portId = input.required(/* @ts-ignore */
1281
+ ...(ngDevMode ? [{ debugName: "portId" }] : /* istanbul ignore next */ []));
1282
+ class = input('', /* @ts-ignore */
1283
+ ...(ngDevMode ? [{ debugName: "class" }] : /* istanbul ignore next */ []));
1284
+ /**
1285
+ * Which way data flows. `inout` covers terminals that are neither source nor
1286
+ * sink — a component lead in a schematic, where either end of a wire may be
1287
+ * drawn first.
1288
+ */
1289
+ direction = input('input', /* @ts-ignore */
1290
+ ...(ngDevMode ? [{ debugName: "direction" }] : /* istanbul ignore next */ []));
1291
+ label = input('', /* @ts-ignore */
1292
+ ...(ngDevMode ? [{ debugName: "label" }] : /* istanbul ignore next */ []));
1293
+ /**
1294
+ * The compatibility key. Two ports may only be wired together when their data
1295
+ * types match, and the type also picks the connector's colour — see
1296
+ * `provideXuiNodeGraphConfig({ portTypes })`. Leaving it unset makes the port a
1297
+ * wildcard that connects to anything.
1298
+ */
1299
+ dataType = input(/* @ts-ignore */
1300
+ ...(ngDevMode ? [undefined, { debugName: "dataType" }] : /* istanbul ignore next */ []));
1301
+ /** Overrides the colour inherited from {@link dataType}. */
1302
+ color = input(/* @ts-ignore */
1303
+ ...(ngDevMode ? [undefined, { debugName: "color" }] : /* istanbul ignore next */ []));
1304
+ disabled = input(false, { ...(ngDevMode ? { debugName: "disabled" } : /* istanbul ignore next */ {}), transform: booleanAttribute });
1305
+ /**
1306
+ * Which node edge the connector sits on. Leave unset to take it from
1307
+ * {@link direction} — inputs on the left, outputs on the right.
1308
+ */
1309
+ side = input(/* @ts-ignore */
1310
+ ...(ngDevMode ? [undefined, { debugName: "side" }] : /* istanbul ignore next */ []));
1311
+ shape = input(/* @ts-ignore */
1312
+ ...(ngDevMode ? [undefined, { debugName: "shape" }] : /* istanbul ignore next */ []));
1313
+ /**
1314
+ * How many wires may terminate here. Defaults to 1 for an input — the usual
1315
+ * rule that a value has exactly one producer — and unlimited for outputs and
1316
+ * `inout` terminals, which fan out freely.
1317
+ */
1318
+ maxConnections = input(NaN, { ...(ngDevMode ? { debugName: "maxConnections" } : /* istanbul ignore next */ {}), transform: value => numberAttribute(value, NaN) });
1319
+ nodeId = this.node.nodeId;
1320
+ resolvedSide = computed(() => this.side() ?? SIDE_FOR_DIRECTION[this.direction()], /* @ts-ignore */
1321
+ ...(ngDevMode ? [{ debugName: "resolvedSide" }] : /* istanbul ignore next */ []));
1322
+ resolvedMaxConnections = computed(() => {
1323
+ const explicit = this.maxConnections();
1324
+ return Number.isNaN(explicit) ? (this.direction() === 'input' ? 1 : Infinity) : explicit;
1325
+ }, /* @ts-ignore */
1326
+ ...(ngDevMode ? [{ debugName: "resolvedMaxConnections" }] : /* istanbul ignore next */ []));
1327
+ /** Connector centre relative to the node's top-left corner, in graph units. */
1328
+ offset = this.offsetState.asReadonly();
1329
+ resolvedColor = computed(() => {
1330
+ const explicit = this.color();
1331
+ if (explicit) {
1332
+ return explicit;
1333
+ }
1334
+ const settings = this.store.settings();
1335
+ const type = this.dataType();
1336
+ return (type ? settings.portTypes[type]?.color : undefined) ?? settings.portColor;
1337
+ }, /* @ts-ignore */
1338
+ ...(ngDevMode ? [{ debugName: "resolvedColor" }] : /* istanbul ignore next */ []));
1339
+ resolvedShape = computed(() => {
1340
+ const settings = this.store.settings();
1341
+ const type = this.dataType();
1342
+ return this.shape() ?? (type ? settings.portTypes[type]?.shape : undefined) ?? settings.portShape;
1343
+ }, /* @ts-ignore */
1344
+ ...(ngDevMode ? [{ debugName: "resolvedShape" }] : /* istanbul ignore next */ []));
1345
+ horizontal = computed(() => this.resolvedSide() === 'left' || this.resolvedSide() === 'right', /* @ts-ignore */
1346
+ ...(ngDevMode ? [{ debugName: "horizontal" }] : /* istanbul ignore next */ []));
1347
+ size = computed(() => this.store.settings().portSize, /* @ts-ignore */
1348
+ ...(ngDevMode ? [{ debugName: "size" }] : /* istanbul ignore next */ []));
1349
+ // A pin is a lead rather than a socket: stretched along the node's normal.
1350
+ connectorWidth = computed(() => this.resolvedShape() === 'pin' && this.horizontal() ? this.size() * 2 : this.size(), /* @ts-ignore */
1351
+ ...(ngDevMode ? [{ debugName: "connectorWidth" }] : /* istanbul ignore next */ []));
1352
+ connectorHeight = computed(() => this.resolvedShape() === 'pin' && !this.horizontal() ? this.size() * 2 : this.size(), /* @ts-ignore */
1353
+ ...(ngDevMode ? [{ debugName: "connectorHeight" }] : /* istanbul ignore next */ []));
1354
+ connected = computed(() => this.store.connectionCount(this.key) > 0, /* @ts-ignore */
1355
+ ...(ngDevMode ? [{ debugName: "connected" }] : /* istanbul ignore next */ []));
1356
+ linkState = computed(() => this.store.linkState(this.key), /* @ts-ignore */
1357
+ ...(ngDevMode ? [{ debugName: "linkState" }] : /* istanbul ignore next */ []));
1358
+ /** Position among the ports sharing this side; drives rails and column rows. */
1359
+ indexOnSide = computed(() => {
1360
+ const side = this.resolvedSide();
1361
+ const index = this.node
1362
+ .portSlots()
1363
+ .filter(slot => slot.resolvedSide() === side)
1364
+ .findIndex(slot => slot.key === this.key);
1365
+ return Math.max(index, 0);
1366
+ }, /* @ts-ignore */
1367
+ ...(ngDevMode ? [{ debugName: "indexOnSide" }] : /* istanbul ignore next */ []));
1368
+ countOnSide = computed(() => this.node.portSlots().filter(slot => slot.resolvedSide() === this.resolvedSide()).length, /* @ts-ignore */
1369
+ ...(ngDevMode ? [{ debugName: "countOnSide" }] : /* istanbul ignore next */ []));
1370
+ /**
1371
+ * Top and bottom ports spread evenly along their edge instead of stacking,
1372
+ * because a schematic reads those terminals as a rail, not as a list.
1373
+ */
1374
+ railOffset = computed(() => this.horizontal() ? null : ((this.indexOnSide() + 1) / (this.countOnSide() + 1)) * 100, /* @ts-ignore */
1375
+ ...(ngDevMode ? [{ debugName: "railOffset" }] : /* istanbul ignore next */ []));
1376
+ order = computed(() => this.node.portLayout() === 'stacked' && this.horizontal() ? (this.resolvedSide() === 'right' ? 0 : 1) : null, /* @ts-ignore */
1377
+ ...(ngDevMode ? [{ debugName: "order" }] : /* istanbul ignore next */ []));
1378
+ gridColumn = computed(() => this.node.portLayout() === 'columns' && this.horizontal() ? (this.resolvedSide() === 'left' ? 1 : 2) : null, /* @ts-ignore */
1379
+ ...(ngDevMode ? [{ debugName: "gridColumn" }] : /* istanbul ignore next */ []));
1380
+ gridRow = computed(() => this.node.portLayout() === 'columns' && this.horizontal() ? this.indexOnSide() + 1 : null, /* @ts-ignore */
1381
+ ...(ngDevMode ? [{ debugName: "gridRow" }] : /* istanbul ignore next */ []));
1382
+ tooltip = computed(() => {
1383
+ const type = this.dataType();
1384
+ const typeLabel = type ? (this.store.settings().portTypes[type]?.label ?? type) : '';
1385
+ return [this.label(), typeLabel && `(${typeLabel})`].filter(Boolean).join(' ') || null;
1386
+ }, /* @ts-ignore */
1387
+ ...(ngDevMode ? [{ debugName: "tooltip" }] : /* istanbul ignore next */ []));
1388
+ ariaLabel = computed(() => {
1389
+ const role = this.direction() === 'output' ? 'Output' : this.direction() === 'input' ? 'Input' : 'Terminal';
1390
+ return `${role} ${this.label() || this.portId()}`;
1391
+ }, /* @ts-ignore */
1392
+ ...(ngDevMode ? [{ debugName: "ariaLabel" }] : /* istanbul ignore next */ []));
1393
+ computedClass = computed(() => xui('relative flex min-h-7 items-center gap-2 text-xs leading-tight', this.horizontal() ? 'px-3' : 'absolute w-max -translate-x-1/2 flex-col gap-1', this.resolvedSide() === 'left' && 'justify-start text-left', this.resolvedSide() === 'right' && 'flex-row-reverse justify-start text-right', this.resolvedSide() === 'top' && 'top-0 -translate-y-full flex-col-reverse pb-1', this.resolvedSide() === 'bottom' && 'bottom-0 translate-y-full pt-1',
1394
+ // Collapsed: every connector converges on the node's midline and the row's
1395
+ // own content is hidden, leaving the wiring intact but the card folded.
1396
+ // `inset-x-0` resolves against the node, since the ports container is not
1397
+ // itself positioned.
1398
+ this.node.collapsed() &&
1399
+ this.horizontal() &&
1400
+ 'absolute inset-x-0 top-1/2 min-h-0 -translate-y-1/2 px-0 [&>:not(:first-child)]:hidden', this.disabled() ? 'text-foreground-subtle' : 'text-foreground-muted', this.class()), /* @ts-ignore */
1401
+ ...(ngDevMode ? [{ debugName: "computedClass" }] : /* istanbul ignore next */ []));
1402
+ connectorClass = computed(() => {
1403
+ const shape = this.resolvedShape();
1404
+ const state = this.linkState();
1405
+ return xui('absolute box-border border-2 transition-transform duration-100',
1406
+ // Pinned to the node's border box. The ports container carries no
1407
+ // horizontal padding, so `left-0` really is the node's own edge.
1408
+ this.resolvedSide() === 'left' && 'top-1/2 left-0 -translate-x-1/2 -translate-y-1/2', this.resolvedSide() === 'right' && 'top-1/2 right-0 translate-x-1/2 -translate-y-1/2', this.resolvedSide() === 'top' && 'bottom-0 left-1/2 -translate-x-1/2 translate-y-1/2', this.resolvedSide() === 'bottom' && 'top-0 left-1/2 -translate-x-1/2 -translate-y-1/2', shape === 'circle' && 'rounded-full', shape === 'square' && 'rounded-xs', shape === 'diamond' && 'rounded-xs rotate-45', shape === 'triangle' && 'rounded-none border-0 [clip-path:polygon(0_0,100%_50%,0_100%)]', shape === 'pin' && 'rounded-full', this.disabled() ? 'cursor-not-allowed opacity-40' : 'cursor-crosshair hover:scale-125', state === 'valid' && 'scale-150', state === 'source' && 'ring-focus scale-125 ring-2 ring-offset-1', 'focus-visible:ring-focus focus-visible:ring-2 focus-visible:ring-offset-1 focus-visible:outline-none');
1409
+ }, /* @ts-ignore */
1410
+ ...(ngDevMode ? [{ debugName: "connectorClass" }] : /* istanbul ignore next */ []));
1411
+ constructor() {
1412
+ inject(DestroyRef).onDestroy(() => this.store.unregisterPort(this));
1413
+ // Re-measure whenever something that could move the connector changes. The
1414
+ // reads happen in the post-render read phase, so no layout is forced during
1415
+ // rendering.
1416
+ afterRenderEffect({
1417
+ read: () => {
1418
+ this.node.layoutEpoch();
1419
+ this.node.collapsed();
1420
+ this.node.portLayout();
1421
+ this.resolvedSide();
1422
+ this.connectorWidth();
1423
+ this.connectorHeight();
1424
+ this.indexOnSide();
1425
+ this.countOnSide();
1426
+ this.measure();
1427
+ }
1428
+ });
1429
+ }
1430
+ ngOnInit() {
1431
+ this.key = xuiGraphPortKey(untracked(this.nodeId), untracked(this.portId));
1432
+ this.store.registerPort(this);
1433
+ }
1434
+ measure() {
1435
+ const nodeRect = this.node.element.getBoundingClientRect();
1436
+ const rect = this.connectorRef().nativeElement.getBoundingClientRect();
1437
+ // Rects are post-transform, so undo the canvas scale to land in graph units.
1438
+ const zoom = untracked(this.store.viewport).zoom || 1;
1439
+ const next = {
1440
+ x: (rect.left + rect.width / 2 - nodeRect.left) / zoom,
1441
+ y: (rect.top + rect.height / 2 - nodeRect.top) / zoom
1442
+ };
1443
+ const current = untracked(this.offsetState);
1444
+ if (Math.abs(current.x - next.x) > 0.01 || Math.abs(current.y - next.y) > 0.01) {
1445
+ this.offsetState.set(next);
1446
+ }
1447
+ }
1448
+ onPointerDown(event) {
1449
+ if (event.button !== 0 || this.disabled() || this.store.settings().locked) {
1450
+ return;
1451
+ }
1452
+ // Keep the node from reading this as the start of a move.
1453
+ event.stopPropagation();
1454
+ event.preventDefault();
1455
+ this.connectorRef().nativeElement.setPointerCapture(event.pointerId);
1456
+ this.store.beginLink(this, this.store.toGraphPoint(event.clientX, event.clientY));
1457
+ }
1458
+ onPointerMove(event) {
1459
+ const linking = this.store.linking();
1460
+ if (!linking || linking.fromKey !== this.key) {
1461
+ return;
1462
+ }
1463
+ this.store.moveLink(this.store.toGraphPoint(event.clientX, event.clientY));
1464
+ this.store.setLinkHover(this.portKeyAt(event.clientX, event.clientY));
1465
+ }
1466
+ onPointerUp(event) {
1467
+ const linking = this.store.linking();
1468
+ if (!linking || linking.fromKey !== this.key) {
1469
+ return;
1470
+ }
1471
+ this.connectorRef().nativeElement.releasePointerCapture(event.pointerId);
1472
+ // Refresh the pointer before ending: a release with no preceding move would
1473
+ // otherwise report a create-on-drop position one event out of date.
1474
+ this.store.moveLink(this.store.toGraphPoint(event.clientX, event.clientY));
1475
+ this.store.endLink(this.portKeyAt(event.clientX, event.clientY));
1476
+ }
1477
+ /**
1478
+ * Keyboard equivalent of dragging a wire: commit on one port, move focus,
1479
+ * commit on the other. Without it the graph cannot be wired without a mouse.
1480
+ */
1481
+ onKeydown(event) {
1482
+ if (this.disabled() || this.store.settings().locked) {
1483
+ return;
1484
+ }
1485
+ const linking = this.store.linking();
1486
+ if (event.key === 'Escape' && linking) {
1487
+ event.preventDefault();
1488
+ this.store.endLink(null);
1489
+ return;
1490
+ }
1491
+ if (event.key !== 'Enter' && event.key !== ' ') {
1492
+ return;
1493
+ }
1494
+ event.preventDefault();
1495
+ if (!linking) {
1496
+ this.store.beginLink(this, this.store.portPoint(this.key) ?? { x: 0, y: 0 });
1497
+ }
1498
+ else {
1499
+ this.store.endLink(linking.fromKey === this.key ? null : this.key);
1500
+ }
1501
+ }
1502
+ /**
1503
+ * Which port, if any, lies under a screen point. Used for drop targeting.
1504
+ *
1505
+ * The two halves of the identity are read back as separate attributes rather
1506
+ * than as one composite key, so the registry is free to key ports however it
1507
+ * likes without that encoding having to survive a DOM round-trip.
1508
+ */
1509
+ portKeyAt(clientX, clientY) {
1510
+ const host = this.document.elementFromPoint(clientX, clientY)?.closest('[data-xui-graph-port]');
1511
+ const portId = host?.getAttribute('data-xui-graph-port');
1512
+ const nodeId = host?.getAttribute('data-xui-graph-port-node');
1513
+ if (portId == null || nodeId == null) {
1514
+ return null;
1515
+ }
1516
+ const key = xuiGraphPortKey(nodeId, portId);
1517
+ return key === this.key ? null : key;
1518
+ }
1519
+ /** @nocollapse */ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: XuiGraphPort, deps: [], target: i0.ɵɵFactoryTarget.Component });
1520
+ /** @nocollapse */ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.0.8", type: XuiGraphPort, isStandalone: true, selector: "xui-graph-port", inputs: { portId: { classPropertyName: "portId", publicName: "portId", isSignal: true, isRequired: true, transformFunction: null }, class: { classPropertyName: "class", publicName: "class", isSignal: true, isRequired: false, transformFunction: null }, direction: { classPropertyName: "direction", publicName: "direction", isSignal: true, isRequired: false, transformFunction: null }, label: { classPropertyName: "label", publicName: "label", isSignal: true, isRequired: false, transformFunction: null }, dataType: { classPropertyName: "dataType", publicName: "dataType", isSignal: true, isRequired: false, transformFunction: null }, color: { classPropertyName: "color", publicName: "color", isSignal: true, isRequired: false, transformFunction: null }, disabled: { classPropertyName: "disabled", publicName: "disabled", isSignal: true, isRequired: false, transformFunction: null }, side: { classPropertyName: "side", publicName: "side", isSignal: true, isRequired: false, transformFunction: null }, shape: { classPropertyName: "shape", publicName: "shape", isSignal: true, isRequired: false, transformFunction: null }, maxConnections: { classPropertyName: "maxConnections", publicName: "maxConnections", isSignal: true, isRequired: false, transformFunction: null } }, host: { properties: { "class": "computedClass()", "attr.data-xui-graph-port": "portId()", "attr.data-xui-graph-port-node": "nodeId()", "attr.data-side": "resolvedSide()", "attr.data-link-state": "linkState()", "style.order": "order()", "style.grid-column": "gridColumn()", "style.grid-row": "gridRow()", "style.left.%": "railOffset()", "style.opacity": "linkState() === 'invalid' ? 0.35 : null" } }, viewQueries: [{ propertyName: "connectorRef", first: true, predicate: ["connector"], descendants: true, isSignal: true }], ngImport: i0, template: `
1521
+ <div
1522
+ #connector
1523
+ role="button"
1524
+ [class]="connectorClass()"
1525
+ [style.width.px]="connectorWidth()"
1526
+ [style.height.px]="connectorHeight()"
1527
+ [style.background]="connected() ? resolvedColor() : 'var(--color-surface)'"
1528
+ [style.border-color]="resolvedColor()"
1529
+ [attr.aria-label]="ariaLabel()"
1530
+ [attr.aria-disabled]="disabled() || null"
1531
+ [attr.title]="tooltip()"
1532
+ [attr.tabindex]="disabled() ? null : 0"
1533
+ (pointerdown)="onPointerDown($event)"
1534
+ (pointermove)="onPointerMove($event)"
1535
+ (pointerup)="onPointerUp($event)"
1536
+ (pointercancel)="onPointerUp($event)"
1537
+ (keydown)="onKeydown($event)"
1538
+ ></div>
1539
+
1540
+ @if (label()) {
1541
+ <span class="truncate">{{ label() }}</span>
1542
+ }
1543
+
1544
+ <ng-content />
1545
+ `, isInline: true, changeDetection: i0.ChangeDetectionStrategy.OnPush, encapsulation: i0.ViewEncapsulation.None });
1546
+ }
1547
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: XuiGraphPort, decorators: [{
1548
+ type: Component,
1549
+ args: [{
1550
+ selector: 'xui-graph-port',
1551
+ template: `
1552
+ <div
1553
+ #connector
1554
+ role="button"
1555
+ [class]="connectorClass()"
1556
+ [style.width.px]="connectorWidth()"
1557
+ [style.height.px]="connectorHeight()"
1558
+ [style.background]="connected() ? resolvedColor() : 'var(--color-surface)'"
1559
+ [style.border-color]="resolvedColor()"
1560
+ [attr.aria-label]="ariaLabel()"
1561
+ [attr.aria-disabled]="disabled() || null"
1562
+ [attr.title]="tooltip()"
1563
+ [attr.tabindex]="disabled() ? null : 0"
1564
+ (pointerdown)="onPointerDown($event)"
1565
+ (pointermove)="onPointerMove($event)"
1566
+ (pointerup)="onPointerUp($event)"
1567
+ (pointercancel)="onPointerUp($event)"
1568
+ (keydown)="onKeydown($event)"
1569
+ ></div>
1570
+
1571
+ @if (label()) {
1572
+ <span class="truncate">{{ label() }}</span>
1573
+ }
1574
+
1575
+ <ng-content />
1576
+ `,
1577
+ host: {
1578
+ '[class]': 'computedClass()',
1579
+ '[attr.data-xui-graph-port]': 'portId()',
1580
+ '[attr.data-xui-graph-port-node]': 'nodeId()',
1581
+ '[attr.data-side]': 'resolvedSide()',
1582
+ '[attr.data-link-state]': 'linkState()',
1583
+ '[style.order]': 'order()',
1584
+ '[style.grid-column]': 'gridColumn()',
1585
+ '[style.grid-row]': 'gridRow()',
1586
+ '[style.left.%]': 'railOffset()',
1587
+ '[style.opacity]': "linkState() === 'invalid' ? 0.35 : null"
1588
+ },
1589
+ changeDetection: ChangeDetectionStrategy.OnPush,
1590
+ encapsulation: ViewEncapsulation.None
1591
+ }]
1592
+ }], ctorParameters: () => [], propDecorators: { connectorRef: [{ type: i0.ViewChild, args: ['connector', { isSignal: true }] }], portId: [{ type: i0.Input, args: [{ isSignal: true, alias: "portId", required: true }] }], class: [{ type: i0.Input, args: [{ isSignal: true, alias: "class", required: false }] }], direction: [{ type: i0.Input, args: [{ isSignal: true, alias: "direction", required: false }] }], label: [{ type: i0.Input, args: [{ isSignal: true, alias: "label", required: false }] }], dataType: [{ type: i0.Input, args: [{ isSignal: true, alias: "dataType", required: false }] }], color: [{ type: i0.Input, args: [{ isSignal: true, alias: "color", required: false }] }], disabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "disabled", required: false }] }], side: [{ type: i0.Input, args: [{ isSignal: true, alias: "side", required: false }] }], shape: [{ type: i0.Input, args: [{ isSignal: true, alias: "shape", required: false }] }], maxConnections: [{ type: i0.Input, args: [{ isSignal: true, alias: "maxConnections", required: false }] }] } });
1593
+
1594
+ const graphNodeVariants = cva('bg-surface-raised text-foreground absolute top-0 left-0 flex flex-col rounded-lg border select-none', {
1595
+ variants: {
1596
+ selected: {
1597
+ true: 'border-primary ring-primary/60 ring-2',
1598
+ false: 'border-border shadow-elevation-2'
1599
+ },
1600
+ locked: {
1601
+ true: 'cursor-default',
1602
+ false: ''
1603
+ },
1604
+ dimmed: {
1605
+ true: 'opacity-45',
1606
+ false: ''
1607
+ }
1608
+ },
1609
+ defaultVariants: { selected: false, locked: false, dimmed: false }
1610
+ });
1611
+ /**
1612
+ * A card on the canvas: a header, an optional body, and a set of ports.
1613
+ *
1614
+ * ```html
1615
+ * <xui-graph-node nodeId="osc" label="Oscillator" [(position)]="position" accent="var(--color-chart-4)">
1616
+ * <xui-graph-port portId="freq" direction="input" dataType="float" label="Frequency" />
1617
+ * <xui-graph-port portId="out" direction="output" dataType="signal" label="Out" />
1618
+ * </xui-graph-node>
1619
+ * ```
1620
+ *
1621
+ * The node is deliberately unopinionated about its body — projected content
1622
+ * renders below the ports, so the same component serves a shader node, a
1623
+ * two-terminal resistor and a form-like settings card. What it does own is the
1624
+ * geometry: its position drives every wire attached to it, and its border is
1625
+ * where connectors sit.
1626
+ *
1627
+ * `position` is a model, so `[(position)]` keeps the host's own state authoritative
1628
+ * — nothing is mutated behind the caller's back.
1629
+ */
1630
+ class XuiGraphNode {
1631
+ store = inject(XuiNodeGraphStore);
1632
+ sizeState = signal({ width: 0, height: 0 }, /* @ts-ignore */
1633
+ ...(ngDevMode ? [{ debugName: "sizeState" }] : /* istanbul ignore next */ []));
1634
+ layoutEpochState = signal(0, /* @ts-ignore */
1635
+ ...(ngDevMode ? [{ debugName: "layoutEpochState" }] : /* istanbul ignore next */ []));
1636
+ dragOrigin = null;
1637
+ /** Host element. Ports measure their connector offsets against this box. */
1638
+ element = inject(ElementRef).nativeElement;
1639
+ /** Identifies the node to edges and to the selection. Must be stable and unique. */
1640
+ nodeId = input.required(/* @ts-ignore */
1641
+ ...(ngDevMode ? [{ debugName: "nodeId" }] : /* istanbul ignore next */ []));
1642
+ class = input('', /* @ts-ignore */
1643
+ ...(ngDevMode ? [{ debugName: "class" }] : /* istanbul ignore next */ []));
1644
+ /** Top-left corner in graph space. Two-way bound so the host owns the value. */
1645
+ position = model({ x: 0, y: 0 }, /* @ts-ignore */
1646
+ ...(ngDevMode ? [{ debugName: "position" }] : /* istanbul ignore next */ []));
1647
+ label = input('', /* @ts-ignore */
1648
+ ...(ngDevMode ? [{ debugName: "label" }] : /* istanbul ignore next */ []));
1649
+ /** Fixed width in graph units. Leave unset to size to content. */
1650
+ width = input(undefined, { ...(ngDevMode ? { debugName: "width" } : /* istanbul ignore next */ {}), transform: value => (value == null || value === '' ? undefined : numberAttribute(value)) });
1651
+ /**
1652
+ * Accent colour for the header's top rule — the usual way a node editor shows
1653
+ * which category a node belongs to. Any CSS colour; prefer a theme token.
1654
+ */
1655
+ accent = input(/* @ts-ignore */
1656
+ ...(ngDevMode ? [undefined, { debugName: "accent" }] : /* istanbul ignore next */ []));
1657
+ /**
1658
+ * Id of the `<xui-graph-group>` this node belongs to. Membership lives on the
1659
+ * node rather than in the frame, so a node never has to move in the template to
1660
+ * join or leave one.
1661
+ */
1662
+ group = input(/* @ts-ignore */
1663
+ ...(ngDevMode ? [undefined, { debugName: "group" }] : /* istanbul ignore next */ []));
1664
+ /** Arrangement of the port rows. */
1665
+ portLayout = input('stacked', /* @ts-ignore */
1666
+ ...(ngDevMode ? [{ debugName: "portLayout" }] : /* istanbul ignore next */ []));
1667
+ /**
1668
+ * Hides the body and pulls every connector onto the header, so a finished
1669
+ * subtree can be folded away without breaking its wiring.
1670
+ */
1671
+ collapsed = model(false, /* @ts-ignore */
1672
+ ...(ngDevMode ? [{ debugName: "collapsed" }] : /* istanbul ignore next */ []));
1673
+ collapsible = input(false, { ...(ngDevMode ? { debugName: "collapsible" } : /* istanbul ignore next */ {}), transform: booleanAttribute });
1674
+ selectable = input(true, { ...(ngDevMode ? { debugName: "selectable" } : /* istanbul ignore next */ {}), transform: booleanAttribute });
1675
+ draggable = input(true, { ...(ngDevMode ? { debugName: "draggable" } : /* istanbul ignore next */ {}), transform: booleanAttribute });
1676
+ /** Pins the node in place while leaving it selectable. */
1677
+ locked = input(false, { ...(ngDevMode ? { debugName: "locked" } : /* istanbul ignore next */ {}), transform: booleanAttribute });
1678
+ /** Restrict dragging to the header, as a window manager does. */
1679
+ dragHandle = input('node', /* @ts-ignore */
1680
+ ...(ngDevMode ? [{ debugName: "dragHandle" }] : /* istanbul ignore next */ []));
1681
+ /** Renders the node faded — for a disabled branch or a bypassed effect. */
1682
+ dimmed = input(false, { ...(ngDevMode ? { debugName: "dimmed" } : /* istanbul ignore next */ {}), transform: booleanAttribute });
1683
+ /** Double-click on the header. The usual gesture for entering a subgraph. */
1684
+ activated = output();
1685
+ /** Measured border-box size in graph units, from a ResizeObserver. */
1686
+ size = this.sizeState.asReadonly();
1687
+ /** @internal Bumped on reflow so ports know to re-measure. */
1688
+ layoutEpoch = this.layoutEpochState.asReadonly();
1689
+ /** @internal Ports in DOM order, for side-relative indexing. */
1690
+ portSlots = contentChildren(XuiGraphPort, /* @ts-ignore */
1691
+ ...(ngDevMode ? [{ debugName: "portSlots" }] : /* istanbul ignore next */ []));
1692
+ selected = computed(() => this.store.isNodeSelected(this.nodeId()), /* @ts-ignore */
1693
+ ...(ngDevMode ? [{ debugName: "selected" }] : /* istanbul ignore next */ []));
1694
+ immovable = computed(() => this.locked() || !this.draggable() || this.store.settings().locked, /* @ts-ignore */
1695
+ ...(ngDevMode ? [{ debugName: "immovable" }] : /* istanbul ignore next */ []));
1696
+ transform = computed(() => {
1697
+ const { x, y } = this.position();
1698
+ return `translate(${x}px, ${y}px)`;
1699
+ }, /* @ts-ignore */
1700
+ ...(ngDevMode ? [{ debugName: "transform" }] : /* istanbul ignore next */ []));
1701
+ dragCursor = computed(() => this.immovable() ? null : this.dragHandle() === 'header' ? null : 'grab', /* @ts-ignore */
1702
+ ...(ngDevMode ? [{ debugName: "dragCursor" }] : /* istanbul ignore next */ []));
1703
+ computedClass = computed(() => xui(graphNodeVariants({ selected: this.selected(), locked: this.locked(), dimmed: this.dimmed() }), !this.width() && 'min-w-40', this.class()), /* @ts-ignore */
1704
+ ...(ngDevMode ? [{ debugName: "computedClass" }] : /* istanbul ignore next */ []));
1705
+ headerClass = computed(() => xui('flex items-center gap-2 rounded-t-lg px-3 py-2 text-sm font-medium',
1706
+ // The accent is painted as a thicker top rule, so it survives a dark theme
1707
+ // and never fights the node's own surface colour for contrast.
1708
+ this.accent() ? 'border-t-3' : 'border-t-3 border-t-transparent', !this.collapsed() && 'border-border-muted border-b', this.immovable() || this.dragHandle() === 'node' ? '' : 'cursor-grab'), /* @ts-ignore */
1709
+ ...(ngDevMode ? [{ debugName: "headerClass" }] : /* istanbul ignore next */ []));
1710
+ portsClass = computed(() => xui(this.portLayout() === 'columns' ? 'grid grid-cols-2' : 'flex flex-col',
1711
+ // Collapsed ports are absolute, so the container takes no room on its own.
1712
+ !this.collapsed() && this.portSlots().length > 0 && 'py-1.5'), /* @ts-ignore */
1713
+ ...(ngDevMode ? [{ debugName: "portsClass" }] : /* istanbul ignore next */ []));
1714
+ constructor() {
1715
+ const observer = new ResizeObserver(entries => {
1716
+ const box = entries[0]?.borderBoxSize?.[0];
1717
+ const width = box ? box.inlineSize : this.element.offsetWidth;
1718
+ const height = box ? box.blockSize : this.element.offsetHeight;
1719
+ const current = untracked(this.sizeState);
1720
+ if (Math.abs(current.width - width) > 0.5 || Math.abs(current.height - height) > 0.5) {
1721
+ this.sizeState.set({ width, height });
1722
+ }
1723
+ // Bump unconditionally: a reflow that leaves the node's own box unchanged
1724
+ // — a label swap, a row appearing and another disappearing — still moves
1725
+ // the ports inside it.
1726
+ this.layoutEpochState.update(epoch => epoch + 1);
1727
+ });
1728
+ observer.observe(this.element);
1729
+ inject(DestroyRef).onDestroy(() => {
1730
+ observer.disconnect();
1731
+ this.store.unregisterNode(this);
1732
+ });
1733
+ }
1734
+ ngOnInit() {
1735
+ this.store.registerNode(this);
1736
+ }
1737
+ /** @internal Called by the store while dragging a selection. */
1738
+ moveTo(point) {
1739
+ this.position.set(point);
1740
+ }
1741
+ onPointerDown(event) {
1742
+ if (event.button !== 0) {
1743
+ return;
1744
+ }
1745
+ // A press anywhere on a node belongs to the node, never to the canvas — even
1746
+ // when it lands on a control and starts no drag at all.
1747
+ event.stopPropagation();
1748
+ const target = event.target;
1749
+ if (this.selectable()) {
1750
+ const additive = event.shiftKey || event.ctrlKey || event.metaKey;
1751
+ if (additive || !this.store.isNodeSelected(this.nodeId())) {
1752
+ this.store.selectNode(this.nodeId(), additive);
1753
+ }
1754
+ }
1755
+ if (this.immovable() || target.closest(NON_DRAGGABLE)) {
1756
+ return;
1757
+ }
1758
+ if (this.dragHandle() === 'header' && !target.closest('[data-xui-graph-node-header]')) {
1759
+ return;
1760
+ }
1761
+ event.preventDefault();
1762
+ this.element.setPointerCapture(event.pointerId);
1763
+ this.dragOrigin = { x: event.clientX, y: event.clientY };
1764
+ this.store.beginNodeDrag(this.nodeId());
1765
+ }
1766
+ onPointerMove(event) {
1767
+ if (!this.dragOrigin) {
1768
+ return;
1769
+ }
1770
+ const { zoom } = this.store.viewport();
1771
+ this.store.dragNodesBy({
1772
+ x: (event.clientX - this.dragOrigin.x) / zoom,
1773
+ y: (event.clientY - this.dragOrigin.y) / zoom
1774
+ });
1775
+ }
1776
+ onPointerUp(event) {
1777
+ if (!this.dragOrigin) {
1778
+ return;
1779
+ }
1780
+ this.dragOrigin = null;
1781
+ this.element.releasePointerCapture(event.pointerId);
1782
+ this.store.endNodeDrag();
1783
+ }
1784
+ onHeaderDoubleClick(event) {
1785
+ event.stopPropagation();
1786
+ this.activated.emit();
1787
+ }
1788
+ /** @nocollapse */ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: XuiGraphNode, deps: [], target: i0.ɵɵFactoryTarget.Component });
1789
+ /** @nocollapse */ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.0.8", type: XuiGraphNode, isStandalone: true, selector: "xui-graph-node", inputs: { nodeId: { classPropertyName: "nodeId", publicName: "nodeId", isSignal: true, isRequired: true, transformFunction: null }, class: { classPropertyName: "class", publicName: "class", isSignal: true, isRequired: false, transformFunction: null }, position: { classPropertyName: "position", publicName: "position", isSignal: true, isRequired: false, transformFunction: null }, label: { classPropertyName: "label", publicName: "label", isSignal: true, isRequired: false, transformFunction: null }, width: { classPropertyName: "width", publicName: "width", isSignal: true, isRequired: false, transformFunction: null }, accent: { classPropertyName: "accent", publicName: "accent", isSignal: true, isRequired: false, transformFunction: null }, group: { classPropertyName: "group", publicName: "group", isSignal: true, isRequired: false, transformFunction: null }, portLayout: { classPropertyName: "portLayout", publicName: "portLayout", isSignal: true, isRequired: false, transformFunction: null }, collapsed: { classPropertyName: "collapsed", publicName: "collapsed", isSignal: true, isRequired: false, transformFunction: null }, collapsible: { classPropertyName: "collapsible", publicName: "collapsible", isSignal: true, isRequired: false, transformFunction: null }, selectable: { classPropertyName: "selectable", publicName: "selectable", isSignal: true, isRequired: false, transformFunction: null }, draggable: { classPropertyName: "draggable", publicName: "draggable", isSignal: true, isRequired: false, transformFunction: null }, locked: { classPropertyName: "locked", publicName: "locked", isSignal: true, isRequired: false, transformFunction: null }, dragHandle: { classPropertyName: "dragHandle", publicName: "dragHandle", isSignal: true, isRequired: false, transformFunction: null }, dimmed: { classPropertyName: "dimmed", publicName: "dimmed", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { position: "positionChange", collapsed: "collapsedChange", activated: "activated" }, host: { listeners: { "pointerdown": "onPointerDown($event)", "pointermove": "onPointerMove($event)", "pointerup": "onPointerUp($event)", "pointercancel": "onPointerUp($event)" }, properties: { "class": "computedClass()", "attr.data-xui-graph-node": "nodeId()", "attr.aria-selected": "selectable() ? selected() : null", "attr.role": "selectable() ? 'option' : null", "style.transform": "transform()", "style.width.px": "width()", "style.z-index": "selected() ? 2 : 1", "style.cursor": "dragCursor()" } }, providers: [{ provide: XUI_GRAPH_NODE, useExisting: XuiGraphNode }], queries: [{ propertyName: "portSlots", predicate: XuiGraphPort, isSignal: true }], ngImport: i0, template: `
1790
+ <div
1791
+ data-xui-graph-node-header
1792
+ [class]="headerClass()"
1793
+ [style.border-top-color]="accent()"
1794
+ (dblclick)="onHeaderDoubleClick($event)"
1795
+ >
1796
+ @if (collapsible()) {
1797
+ <button
1798
+ type="button"
1799
+ class="text-foreground-subtle hover:text-foreground -ml-1 shrink-0 cursor-pointer p-0.5 leading-none"
1800
+ [attr.aria-label]="collapsed() ? 'Expand node' : 'Collapse node'"
1801
+ [attr.aria-expanded]="!collapsed()"
1802
+ (click)="collapsed.set(!collapsed())"
1803
+ >
1804
+ <svg
1805
+ width="10"
1806
+ height="10"
1807
+ viewBox="0 0 10 10"
1808
+ aria-hidden="true"
1809
+ class="transition-transform duration-150"
1810
+ [style.transform]="collapsed() ? 'rotate(-90deg)' : null"
1811
+ >
1812
+ <path d="M1 3 L5 7 L9 3" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
1813
+ </svg>
1814
+ </button>
1815
+ }
1816
+
1817
+ <ng-content select="xui-graph-node-header" />
1818
+
1819
+ @if (label()) {
1820
+ <span class="min-w-0 flex-1 truncate">{{ label() }}</span>
1821
+ }
1822
+
1823
+ <ng-content select="xui-graph-node-actions" />
1824
+ </div>
1825
+
1826
+ @if (!collapsed()) {
1827
+ <ng-content select="xui-graph-node-preview" />
1828
+ }
1829
+
1830
+ <!--
1831
+ Deliberately not positioned: left/right connectors resolve their inset
1832
+ against this box (full node width, no horizontal padding), while top and
1833
+ bottom ports are absolute and must resolve against the node itself.
1834
+ -->
1835
+ <div [class]="portsClass()">
1836
+ <ng-content select="xui-graph-port" />
1837
+ </div>
1838
+
1839
+ @if (!collapsed()) {
1840
+ <ng-content />
1841
+ }
1842
+ `, isInline: true, changeDetection: i0.ChangeDetectionStrategy.OnPush, encapsulation: i0.ViewEncapsulation.None });
1843
+ }
1844
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: XuiGraphNode, decorators: [{
1845
+ type: Component,
1846
+ args: [{
1847
+ selector: 'xui-graph-node',
1848
+ template: `
1849
+ <div
1850
+ data-xui-graph-node-header
1851
+ [class]="headerClass()"
1852
+ [style.border-top-color]="accent()"
1853
+ (dblclick)="onHeaderDoubleClick($event)"
1854
+ >
1855
+ @if (collapsible()) {
1856
+ <button
1857
+ type="button"
1858
+ class="text-foreground-subtle hover:text-foreground -ml-1 shrink-0 cursor-pointer p-0.5 leading-none"
1859
+ [attr.aria-label]="collapsed() ? 'Expand node' : 'Collapse node'"
1860
+ [attr.aria-expanded]="!collapsed()"
1861
+ (click)="collapsed.set(!collapsed())"
1862
+ >
1863
+ <svg
1864
+ width="10"
1865
+ height="10"
1866
+ viewBox="0 0 10 10"
1867
+ aria-hidden="true"
1868
+ class="transition-transform duration-150"
1869
+ [style.transform]="collapsed() ? 'rotate(-90deg)' : null"
1870
+ >
1871
+ <path d="M1 3 L5 7 L9 3" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
1872
+ </svg>
1873
+ </button>
1874
+ }
1875
+
1876
+ <ng-content select="xui-graph-node-header" />
1877
+
1878
+ @if (label()) {
1879
+ <span class="min-w-0 flex-1 truncate">{{ label() }}</span>
1880
+ }
1881
+
1882
+ <ng-content select="xui-graph-node-actions" />
1883
+ </div>
1884
+
1885
+ @if (!collapsed()) {
1886
+ <ng-content select="xui-graph-node-preview" />
1887
+ }
1888
+
1889
+ <!--
1890
+ Deliberately not positioned: left/right connectors resolve their inset
1891
+ against this box (full node width, no horizontal padding), while top and
1892
+ bottom ports are absolute and must resolve against the node itself.
1893
+ -->
1894
+ <div [class]="portsClass()">
1895
+ <ng-content select="xui-graph-port" />
1896
+ </div>
1897
+
1898
+ @if (!collapsed()) {
1899
+ <ng-content />
1900
+ }
1901
+ `,
1902
+ host: {
1903
+ '[class]': 'computedClass()',
1904
+ '[attr.data-xui-graph-node]': 'nodeId()',
1905
+ '[attr.aria-selected]': 'selectable() ? selected() : null',
1906
+ '[attr.role]': "selectable() ? 'option' : null",
1907
+ '[style.transform]': 'transform()',
1908
+ '[style.width.px]': 'width()',
1909
+ '[style.z-index]': 'selected() ? 2 : 1',
1910
+ '[style.cursor]': 'dragCursor()',
1911
+ '(pointerdown)': 'onPointerDown($event)',
1912
+ '(pointermove)': 'onPointerMove($event)',
1913
+ '(pointerup)': 'onPointerUp($event)',
1914
+ '(pointercancel)': 'onPointerUp($event)'
1915
+ },
1916
+ providers: [{ provide: XUI_GRAPH_NODE, useExisting: XuiGraphNode }],
1917
+ changeDetection: ChangeDetectionStrategy.OnPush,
1918
+ encapsulation: ViewEncapsulation.None
1919
+ }]
1920
+ }], ctorParameters: () => [], propDecorators: { nodeId: [{ type: i0.Input, args: [{ isSignal: true, alias: "nodeId", required: true }] }], class: [{ type: i0.Input, args: [{ isSignal: true, alias: "class", required: false }] }], position: [{ type: i0.Input, args: [{ isSignal: true, alias: "position", required: false }] }, { type: i0.Output, args: ["positionChange"] }], label: [{ type: i0.Input, args: [{ isSignal: true, alias: "label", required: false }] }], width: [{ type: i0.Input, args: [{ isSignal: true, alias: "width", required: false }] }], accent: [{ type: i0.Input, args: [{ isSignal: true, alias: "accent", required: false }] }], group: [{ type: i0.Input, args: [{ isSignal: true, alias: "group", required: false }] }], portLayout: [{ type: i0.Input, args: [{ isSignal: true, alias: "portLayout", required: false }] }], collapsed: [{ type: i0.Input, args: [{ isSignal: true, alias: "collapsed", required: false }] }, { type: i0.Output, args: ["collapsedChange"] }], collapsible: [{ type: i0.Input, args: [{ isSignal: true, alias: "collapsible", required: false }] }], selectable: [{ type: i0.Input, args: [{ isSignal: true, alias: "selectable", required: false }] }], draggable: [{ type: i0.Input, args: [{ isSignal: true, alias: "draggable", required: false }] }], locked: [{ type: i0.Input, args: [{ isSignal: true, alias: "locked", required: false }] }], dragHandle: [{ type: i0.Input, args: [{ isSignal: true, alias: "dragHandle", required: false }] }], dimmed: [{ type: i0.Input, args: [{ isSignal: true, alias: "dimmed", required: false }] }], activated: [{ type: i0.Output, args: ["activated"] }], portSlots: [{ type: i0.ContentChildren, args: [i0.forwardRef(() => XuiGraphPort), { isSignal: true }] }] } });
1921
+ /**
1922
+ * Descendants that own their own pointer gestures. Ports link, native controls
1923
+ * take input, and anything marked `xuiGraphNoDrag` opts out explicitly.
1924
+ */
1925
+ const NON_DRAGGABLE = '[data-xui-graph-port], [data-xui-graph-no-drag], input, textarea, select, button, a, [contenteditable="true"]';
1926
+
1927
+ /*
1928
+ * The slots are elements rather than attribute directives on purpose.
1929
+ *
1930
+ * Content projection matches `ng-content select` against the template's own
1931
+ * markup, and an attribute selector that fails to match raises nothing — the
1932
+ * element quietly lands in the default slot instead. For a node header that means
1933
+ * rendering below the ports; for an overlay it means being placed inside the
1934
+ * canvas transform, where it pans and zooms with the content and sizes itself
1935
+ * against a zero-height box. Element selectors match reliably, so a slot cannot
1936
+ * end up in the wrong layer without anyone noticing.
1937
+ */
1938
+ /**
1939
+ * Replaces a node's default header. Use it when the title needs an icon, a
1940
+ * badge, or anything beyond the `label` input.
1941
+ *
1942
+ * ```html
1943
+ * <xui-graph-node nodeId="n1" [(position)]="position">
1944
+ * <xui-graph-node-header><ng-icon name="matBolt" /> Amplifier</xui-graph-node-header>
1945
+ * </xui-graph-node>
1946
+ * ```
1947
+ */
1948
+ class XuiGraphNodeHeader {
1949
+ class = input('', /* @ts-ignore */
1950
+ ...(ngDevMode ? [{ debugName: "class" }] : /* istanbul ignore next */ []));
1951
+ computedClass = computed(() => xui('flex min-w-0 flex-1 items-center gap-2 truncate', this.class()), /* @ts-ignore */
1952
+ ...(ngDevMode ? [{ debugName: "computedClass" }] : /* istanbul ignore next */ []));
1953
+ /** @nocollapse */ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: XuiGraphNodeHeader, deps: [], target: i0.ɵɵFactoryTarget.Component });
1954
+ /** @nocollapse */ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.1.0", version: "22.0.8", type: XuiGraphNodeHeader, isStandalone: true, selector: "xui-graph-node-header", inputs: { class: { classPropertyName: "class", publicName: "class", isSignal: true, isRequired: false, transformFunction: null } }, host: { properties: { "class": "computedClass()" } }, ngImport: i0, template: '<ng-content />', isInline: true, changeDetection: i0.ChangeDetectionStrategy.OnPush, encapsulation: i0.ViewEncapsulation.None });
1955
+ }
1956
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: XuiGraphNodeHeader, decorators: [{
1957
+ type: Component,
1958
+ args: [{
1959
+ selector: 'xui-graph-node-header',
1960
+ template: '<ng-content />',
1961
+ host: { '[class]': 'computedClass()' },
1962
+ changeDetection: ChangeDetectionStrategy.OnPush,
1963
+ encapsulation: ViewEncapsulation.None
1964
+ }]
1965
+ }], propDecorators: { class: [{ type: i0.Input, args: [{ isSignal: true, alias: "class", required: false }] }] } });
1966
+ /** Trailing controls in a node header — a menu button, a toggle, a status dot. */
1967
+ class XuiGraphNodeActions {
1968
+ class = input('', /* @ts-ignore */
1969
+ ...(ngDevMode ? [{ debugName: "class" }] : /* istanbul ignore next */ []));
1970
+ computedClass = computed(() => xui('flex shrink-0 items-center gap-1', this.class()), /* @ts-ignore */
1971
+ ...(ngDevMode ? [{ debugName: "computedClass" }] : /* istanbul ignore next */ []));
1972
+ /** @nocollapse */ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: XuiGraphNodeActions, deps: [], target: i0.ɵɵFactoryTarget.Component });
1973
+ /** @nocollapse */ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.1.0", version: "22.0.8", type: XuiGraphNodeActions, isStandalone: true, selector: "xui-graph-node-actions", inputs: { class: { classPropertyName: "class", publicName: "class", isSignal: true, isRequired: false, transformFunction: null } }, host: { properties: { "class": "computedClass()" } }, ngImport: i0, template: '<ng-content />', isInline: true, changeDetection: i0.ChangeDetectionStrategy.OnPush, encapsulation: i0.ViewEncapsulation.None });
1974
+ }
1975
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: XuiGraphNodeActions, decorators: [{
1976
+ type: Component,
1977
+ args: [{
1978
+ selector: 'xui-graph-node-actions',
1979
+ template: '<ng-content />',
1980
+ host: { '[class]': 'computedClass()' },
1981
+ changeDetection: ChangeDetectionStrategy.OnPush,
1982
+ encapsulation: ViewEncapsulation.None
1983
+ }]
1984
+ }], propDecorators: { class: [{ type: i0.Input, args: [{ isSignal: true, alias: "class", required: false }] }] } });
1985
+ /**
1986
+ * A full-bleed block between the header and the ports — a thumbnail, a preview
1987
+ * render, a waveform. Sits above the port rows, as it does in a VFX graph.
1988
+ */
1989
+ class XuiGraphNodePreview {
1990
+ class = input('', /* @ts-ignore */
1991
+ ...(ngDevMode ? [{ debugName: "class" }] : /* istanbul ignore next */ []));
1992
+ computedClass = computed(() => xui('border-border-muted block overflow-hidden border-b', this.class()), /* @ts-ignore */
1993
+ ...(ngDevMode ? [{ debugName: "computedClass" }] : /* istanbul ignore next */ []));
1994
+ /** @nocollapse */ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: XuiGraphNodePreview, deps: [], target: i0.ɵɵFactoryTarget.Component });
1995
+ /** @nocollapse */ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.1.0", version: "22.0.8", type: XuiGraphNodePreview, isStandalone: true, selector: "xui-graph-node-preview", inputs: { class: { classPropertyName: "class", publicName: "class", isSignal: true, isRequired: false, transformFunction: null } }, host: { properties: { "class": "computedClass()" } }, ngImport: i0, template: '<ng-content />', isInline: true, changeDetection: i0.ChangeDetectionStrategy.OnPush, encapsulation: i0.ViewEncapsulation.None });
1996
+ }
1997
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: XuiGraphNodePreview, decorators: [{
1998
+ type: Component,
1999
+ args: [{
2000
+ selector: 'xui-graph-node-preview',
2001
+ template: '<ng-content />',
2002
+ host: { '[class]': 'computedClass()' },
2003
+ changeDetection: ChangeDetectionStrategy.OnPush,
2004
+ encapsulation: ViewEncapsulation.None
2005
+ }]
2006
+ }], propDecorators: { class: [{ type: i0.Input, args: [{ isSignal: true, alias: "class", required: false }] }] } });
2007
+ /**
2008
+ * Furniture layered over the canvas — a legend, a toolbar, a context menu.
2009
+ *
2010
+ * It sits outside the canvas transform, so it neither pans nor scales with the
2011
+ * content, and positions itself against the graph's own box:
2012
+ *
2013
+ * ```html
2014
+ * <xui-node-graph>
2015
+ * …
2016
+ * <xui-graph-overlay class="top-4 right-4">Read only</xui-graph-overlay>
2017
+ * </xui-node-graph>
2018
+ * ```
2019
+ *
2020
+ * Keep the element itself outside any `@if` or `@for`, and put the condition on
2021
+ * its contents instead. A control-flow block is projected as one embedded view,
2022
+ * so anything wrapped in one goes to the canvas layer whatever its own selector
2023
+ * says — an overlay put there pans and zooms away with the content:
2024
+ *
2025
+ * ```html
2026
+ * <xui-graph-overlay class="inset-0" [class.pointer-events-none]="!menu()">
2027
+ * @if (menu(); as open) { … }
2028
+ * </xui-graph-overlay>
2029
+ * ```
2030
+ */
2031
+ class XuiGraphOverlay {
2032
+ class = input('', /* @ts-ignore */
2033
+ ...(ngDevMode ? [{ debugName: "class" }] : /* istanbul ignore next */ []));
2034
+ computedClass = computed(() => xui('pointer-events-auto absolute z-10 block', this.class()), /* @ts-ignore */
2035
+ ...(ngDevMode ? [{ debugName: "computedClass" }] : /* istanbul ignore next */ []));
2036
+ /** @nocollapse */ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: XuiGraphOverlay, deps: [], target: i0.ɵɵFactoryTarget.Component });
2037
+ /** @nocollapse */ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.1.0", version: "22.0.8", type: XuiGraphOverlay, isStandalone: true, selector: "xui-graph-overlay", inputs: { class: { classPropertyName: "class", publicName: "class", isSignal: true, isRequired: false, transformFunction: null } }, host: { attributes: { "data-xui-graph-overlay": "" }, properties: { "class": "computedClass()" } }, ngImport: i0, template: '<ng-content />', isInline: true, changeDetection: i0.ChangeDetectionStrategy.OnPush, encapsulation: i0.ViewEncapsulation.None });
2038
+ }
2039
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: XuiGraphOverlay, decorators: [{
2040
+ type: Component,
2041
+ args: [{
2042
+ selector: 'xui-graph-overlay',
2043
+ template: '<ng-content />',
2044
+ host: {
2045
+ '[class]': 'computedClass()',
2046
+ // Tells the canvas to keep its hands off presses that land here. Without it
2047
+ // the canvas captures the pointer for a pan and the release never reaches
2048
+ // whatever was pressed, so buttons in an overlay never see a click.
2049
+ 'data-xui-graph-overlay': ''
2050
+ },
2051
+ changeDetection: ChangeDetectionStrategy.OnPush,
2052
+ encapsulation: ViewEncapsulation.None
2053
+ }]
2054
+ }], propDecorators: { class: [{ type: i0.Input, args: [{ isSignal: true, alias: "class", required: false }] }] } });
2055
+ /**
2056
+ * Marks a subtree inside a node as not draggable, so pointer gestures on it
2057
+ * belong to the control rather than moving the node.
2058
+ *
2059
+ * Native form controls, buttons and links are exempt already; this is for
2060
+ * anything custom — a slider, a curve editor, a colour wheel.
2061
+ */
2062
+ class XuiGraphNoDrag {
2063
+ /** @nocollapse */ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: XuiGraphNoDrag, deps: [], target: i0.ɵɵFactoryTarget.Directive });
2064
+ /** @nocollapse */ static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "22.0.8", type: XuiGraphNoDrag, isStandalone: true, selector: "[xuiGraphNoDrag]", host: { attributes: { "data-xui-graph-no-drag": "" } }, ngImport: i0 });
2065
+ }
2066
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: XuiGraphNoDrag, decorators: [{
2067
+ type: Directive,
2068
+ args: [{
2069
+ selector: '[xuiGraphNoDrag]',
2070
+ host: { 'data-xui-graph-no-drag': '' }
2071
+ }]
2072
+ }] });
2073
+
2074
+ /** Unit vector pointing away from the node, per port side. */
2075
+ const NORMALS = {
2076
+ left: { x: -1, y: 0 },
2077
+ right: { x: 1, y: 0 },
2078
+ top: { x: 0, y: -1 },
2079
+ bottom: { x: 0, y: 1 }
2080
+ };
2081
+ function xuiGraphPortNormal(side) {
2082
+ return NORMALS[side];
2083
+ }
2084
+ const XUI_GRAPH_DEFAULT_ROUTE_OPTIONS = {
2085
+ stubLength: 18,
2086
+ cornerRadius: 8,
2087
+ curvature: 0.5
2088
+ };
2089
+ /**
2090
+ * Build the `d` attribute for a wire between two ports.
2091
+ *
2092
+ * The two sides matter as much as the two points: an edge leaving a right-hand
2093
+ * port must set off rightwards even when its target sits to the left, otherwise
2094
+ * it appears to pass through the node it came from.
2095
+ */
2096
+ function xuiGraphEdgePath(source, sourceSide, target, targetSide, routing, options = XUI_GRAPH_DEFAULT_ROUTE_OPTIONS) {
2097
+ switch (routing) {
2098
+ case 'straight':
2099
+ return `M ${round(source.x)} ${round(source.y)} L ${round(target.x)} ${round(target.y)}`;
2100
+ case 'orthogonal':
2101
+ return polylinePath(orthogonalPoints(source, sourceSide, target, targetSide, options.stubLength), 0);
2102
+ case 'smoothstep':
2103
+ return polylinePath(orthogonalPoints(source, sourceSide, target, targetSide, options.stubLength), options.cornerRadius);
2104
+ case 'bezier':
2105
+ default:
2106
+ return bezierPath(source, sourceSide, target, targetSide, options.curvature);
2107
+ }
2108
+ }
2109
+ /**
2110
+ * The point at the middle of a route, used to place edge labels.
2111
+ *
2112
+ * For a bezier this is the curve's t=0.5 point; for the orthogonal families it
2113
+ * is the point half the total run from the source. Picking the longest segment
2114
+ * instead would flip the label between two legs of equal length as the wire
2115
+ * moved, and the halfway point is where a reader looks anyway.
2116
+ */
2117
+ function xuiGraphEdgeMidpoint(source, sourceSide, target, targetSide, routing, options = XUI_GRAPH_DEFAULT_ROUTE_OPTIONS) {
2118
+ if (routing === 'straight') {
2119
+ return { x: (source.x + target.x) / 2, y: (source.y + target.y) / 2 };
2120
+ }
2121
+ if (routing === 'bezier') {
2122
+ const [c1, c2] = bezierControlPoints(source, sourceSide, target, targetSide, options.curvature);
2123
+ // Cubic Bezier at t = 0.5 reduces to the average of the four weights 1:3:3:1.
2124
+ return {
2125
+ x: (source.x + 3 * c1.x + 3 * c2.x + target.x) / 8,
2126
+ y: (source.y + 3 * c1.y + 3 * c2.y + target.y) / 8
2127
+ };
2128
+ }
2129
+ const points = orthogonalPoints(source, sourceSide, target, targetSide, options.stubLength);
2130
+ const lengths = points.slice(1).map((point, index) => distance(points[index], point));
2131
+ const half = lengths.reduce((sum, length) => sum + length, 0) / 2;
2132
+ let travelled = 0;
2133
+ for (let i = 0; i < lengths.length; i++) {
2134
+ if (travelled + lengths[i] >= half) {
2135
+ return lerp(points[i], points[i + 1], lengths[i] === 0 ? 0 : (half - travelled) / lengths[i]);
2136
+ }
2137
+ travelled += lengths[i];
2138
+ }
2139
+ return { x: (source.x + target.x) / 2, y: (source.y + target.y) / 2 };
2140
+ }
2141
+ function bezierControlPoints(source, sourceSide, target, targetSide, curvature) {
2142
+ const sourceNormal = NORMALS[sourceSide];
2143
+ const targetNormal = NORMALS[targetSide];
2144
+ // Pull along each normal, scaled by the separation *on that normal's axis*.
2145
+ // A floor keeps near-coincident ports from collapsing into a straight stub,
2146
+ // which is what makes a self-connection or a tight back-link still legible.
2147
+ const dx = Math.abs(target.x - source.x);
2148
+ const dy = Math.abs(target.y - source.y);
2149
+ const sourcePull = Math.max(30, (sourceNormal.x !== 0 ? dx : dy) * curvature);
2150
+ const targetPull = Math.max(30, (targetNormal.x !== 0 ? dx : dy) * curvature);
2151
+ return [
2152
+ { x: source.x + sourceNormal.x * sourcePull, y: source.y + sourceNormal.y * sourcePull },
2153
+ { x: target.x + targetNormal.x * targetPull, y: target.y + targetNormal.y * targetPull }
2154
+ ];
2155
+ }
2156
+ function bezierPath(source, sourceSide, target, targetSide, curvature) {
2157
+ const [c1, c2] = bezierControlPoints(source, sourceSide, target, targetSide, curvature);
2158
+ return (`M ${round(source.x)} ${round(source.y)} ` +
2159
+ `C ${round(c1.x)} ${round(c1.y)}, ${round(c2.x)} ${round(c2.y)}, ${round(target.x)} ${round(target.y)}`);
2160
+ }
2161
+ /**
2162
+ * Manhattan route between two ports.
2163
+ *
2164
+ * Both ends first travel `stub` along their normal; the remaining gap is closed
2165
+ * with one or two turns. Which turn pattern applies depends on whether the two
2166
+ * normals share an axis and whether the far stub is already "ahead" of the near
2167
+ * one — when it is not, the wire has to detour on the cross axis to get around.
2168
+ */
2169
+ function orthogonalPoints(source, sourceSide, target, targetSide, stub) {
2170
+ const sourceNormal = NORMALS[sourceSide];
2171
+ const targetNormal = NORMALS[targetSide];
2172
+ const a = { x: source.x + sourceNormal.x * stub, y: source.y + sourceNormal.y * stub };
2173
+ const b = { x: target.x + targetNormal.x * stub, y: target.y + targetNormal.y * stub };
2174
+ const sourceHorizontal = sourceNormal.x !== 0;
2175
+ const targetHorizontal = targetNormal.x !== 0;
2176
+ const middle = [];
2177
+ if (sourceHorizontal && targetHorizontal) {
2178
+ // Facing each other with room between them: split the gap and turn twice.
2179
+ const facing = sourceNormal.x !== targetNormal.x;
2180
+ const hasRoom = facing && (b.x - a.x) * sourceNormal.x > 0;
2181
+ if (hasRoom) {
2182
+ const midX = (a.x + b.x) / 2;
2183
+ middle.push({ x: midX, y: a.y }, { x: midX, y: b.y });
2184
+ }
2185
+ else if (facing) {
2186
+ // Facing but overlapping — go around on the cross axis.
2187
+ const midY = (a.y + b.y) / 2;
2188
+ middle.push({ x: a.x, y: midY }, { x: b.x, y: midY });
2189
+ }
2190
+ else {
2191
+ // Same direction — run both stubs out to the furthest of the two.
2192
+ const x = sourceNormal.x > 0 ? Math.max(a.x, b.x) : Math.min(a.x, b.x);
2193
+ middle.push({ x, y: a.y }, { x, y: b.y });
2194
+ }
2195
+ }
2196
+ else if (!sourceHorizontal && !targetHorizontal) {
2197
+ const facing = sourceNormal.y !== targetNormal.y;
2198
+ const hasRoom = facing && (b.y - a.y) * sourceNormal.y > 0;
2199
+ if (hasRoom) {
2200
+ const midY = (a.y + b.y) / 2;
2201
+ middle.push({ x: a.x, y: midY }, { x: b.x, y: midY });
2202
+ }
2203
+ else if (facing) {
2204
+ const midX = (a.x + b.x) / 2;
2205
+ middle.push({ x: midX, y: a.y }, { x: midX, y: b.y });
2206
+ }
2207
+ else {
2208
+ const y = sourceNormal.y > 0 ? Math.max(a.y, b.y) : Math.min(a.y, b.y);
2209
+ middle.push({ x: a.x, y }, { x: b.x, y });
2210
+ }
2211
+ }
2212
+ else if (sourceHorizontal) {
2213
+ // Horizontal into vertical: a single elbow, turning under/over the target.
2214
+ middle.push({ x: b.x, y: a.y });
2215
+ }
2216
+ else {
2217
+ middle.push({ x: a.x, y: b.y });
2218
+ }
2219
+ return simplify([source, a, ...middle, b, target]);
2220
+ }
2221
+ /** Drop duplicate and collinear vertices so corner rounding never sees a zero-length leg. */
2222
+ function simplify(points) {
2223
+ const result = [];
2224
+ for (const point of points) {
2225
+ const last = result[result.length - 1];
2226
+ if (last && Math.abs(last.x - point.x) < 0.01 && Math.abs(last.y - point.y) < 0.01) {
2227
+ continue;
2228
+ }
2229
+ const previous = result[result.length - 2];
2230
+ if (previous && last && isCollinear(previous, last, point)) {
2231
+ result[result.length - 1] = point;
2232
+ continue;
2233
+ }
2234
+ result.push(point);
2235
+ }
2236
+ return result;
2237
+ }
2238
+ function isCollinear(a, b, c) {
2239
+ return ((Math.abs(a.x - b.x) < 0.01 && Math.abs(b.x - c.x) < 0.01) ||
2240
+ (Math.abs(a.y - b.y) < 0.01 && Math.abs(b.y - c.y) < 0.01));
2241
+ }
2242
+ /**
2243
+ * Turn a polyline into a path, optionally rounding each interior corner.
2244
+ *
2245
+ * The rounding radius is clamped to half the shorter of the two legs meeting at
2246
+ * the corner, so a tight zig-zag degrades to sharp corners instead of producing
2247
+ * arcs that overshoot their own segments.
2248
+ */
2249
+ function polylinePath(points, radius) {
2250
+ if (points.length === 0) {
2251
+ return '';
2252
+ }
2253
+ if (radius <= 0 || points.length < 3) {
2254
+ return points.map((p, i) => `${i === 0 ? 'M' : 'L'} ${round(p.x)} ${round(p.y)}`).join(' ');
2255
+ }
2256
+ let path = `M ${round(points[0].x)} ${round(points[0].y)}`;
2257
+ for (let i = 1; i < points.length - 1; i++) {
2258
+ const previous = points[i - 1];
2259
+ const corner = points[i];
2260
+ const next = points[i + 1];
2261
+ const inLength = distance(previous, corner);
2262
+ const outLength = distance(corner, next);
2263
+ const r = Math.min(radius, inLength / 2, outLength / 2);
2264
+ if (r < 0.5) {
2265
+ path += ` L ${round(corner.x)} ${round(corner.y)}`;
2266
+ continue;
2267
+ }
2268
+ const enter = lerp(corner, previous, r / inLength);
2269
+ const exit = lerp(corner, next, r / outLength);
2270
+ path += ` L ${round(enter.x)} ${round(enter.y)}`;
2271
+ path += ` Q ${round(corner.x)} ${round(corner.y)}, ${round(exit.x)} ${round(exit.y)}`;
2272
+ }
2273
+ const last = points[points.length - 1];
2274
+ return `${path} L ${round(last.x)} ${round(last.y)}`;
2275
+ }
2276
+ function distance(a, b) {
2277
+ return Math.hypot(b.x - a.x, b.y - a.y);
2278
+ }
2279
+ function lerp(from, to, t) {
2280
+ return { x: from.x + (to.x - from.x) * t, y: from.y + (to.y - from.y) * t };
2281
+ }
2282
+ function round(value) {
2283
+ return Math.round(value * 100) / 100;
2284
+ }
2285
+
2286
+ let nextGraphId = 0;
2287
+ /**
2288
+ * A pannable, zoomable canvas for node-based editors and schematics.
2289
+ *
2290
+ * ```html
2291
+ * <xui-node-graph class="h-[600px]" [edges]="edges()" routing="orthogonal" snapToGrid (connect)="add($event)">
2292
+ * @for (node of nodes(); track node.id) {
2293
+ * <xui-graph-node [nodeId]="node.id" [label]="node.label" [(position)]="node.position">
2294
+ * <xui-graph-port portId="in" direction="input" />
2295
+ * <xui-graph-port portId="out" direction="output" />
2296
+ * </xui-graph-node>
2297
+ * }
2298
+ * </xui-node-graph>
2299
+ * ```
2300
+ *
2301
+ * Nodes are ordinary projected content, so they keep Angular's template model —
2302
+ * `@for`, `@if`, your own components — instead of being described by a
2303
+ * configuration object. Edges are an input rather than content because a wire
2304
+ * has no content of its own: its whole geometry is derived from the two ports it
2305
+ * joins, which the graph already tracks.
2306
+ *
2307
+ * The graph never mutates `edges`. `connect` reports an approved connection and
2308
+ * the host decides what to add, which is what keeps undo/redo and persistence in
2309
+ * the host's hands.
2310
+ */
2311
+ class XuiNodeGraph {
2312
+ config = injectXuiNodeGraphConfig();
2313
+ element = inject(ElementRef).nativeElement;
2314
+ uid = nextGraphId++;
2315
+ /** Pointer position where the current gesture began, in client coordinates. */
2316
+ gestureOrigin = { x: 0, y: 0 };
2317
+ gesture = 'none';
2318
+ gestureMoved = false;
2319
+ marqueeState = signal(null, /* @ts-ignore */
2320
+ ...(ngDevMode ? [{ debugName: "marqueeState" }] : /* istanbul ignore next */ []));
2321
+ /** The store for this canvas. Use it to drive the view from a host component. */
2322
+ store = inject(XuiNodeGraphStore);
2323
+ class = input('', /* @ts-ignore */
2324
+ ...(ngDevMode ? [{ debugName: "class" }] : /* istanbul ignore next */ []));
2325
+ /** The wires to draw. Never mutated — see `connect`. */
2326
+ edges = input([], /* @ts-ignore */
2327
+ ...(ngDevMode ? [{ debugName: "edges" }] : /* istanbul ignore next */ []));
2328
+ /** Pan and zoom. Two-way bindable, so a host can save and restore the view. */
2329
+ viewport = model({ x: 0, y: 0, zoom: 1 }, /* @ts-ignore */
2330
+ ...(ngDevMode ? [{ debugName: "viewport" }] : /* istanbul ignore next */ []));
2331
+ routing = input(this.config.routing, /* @ts-ignore */
2332
+ ...(ngDevMode ? [{ debugName: "routing" }] : /* istanbul ignore next */ []));
2333
+ background = input(this.config.background, /* @ts-ignore */
2334
+ ...(ngDevMode ? [{ debugName: "background" }] : /* istanbul ignore next */ []));
2335
+ marker = input(this.config.marker, /* @ts-ignore */
2336
+ ...(ngDevMode ? [{ debugName: "marker" }] : /* istanbul ignore next */ []));
2337
+ gridSize = input(this.config.gridSize, { ...(ngDevMode ? { debugName: "gridSize" } : /* istanbul ignore next */ {}), transform: numberAttribute });
2338
+ snapToGrid = input(this.config.snapToGrid, { ...(ngDevMode ? { debugName: "snapToGrid" } : /* istanbul ignore next */ {}), transform: booleanAttribute });
2339
+ minZoom = input(this.config.minZoom, { ...(ngDevMode ? { debugName: "minZoom" } : /* istanbul ignore next */ {}), transform: numberAttribute });
2340
+ maxZoom = input(this.config.maxZoom, { ...(ngDevMode ? { debugName: "maxZoom" } : /* istanbul ignore next */ {}), transform: numberAttribute });
2341
+ edgeWidth = input(this.config.edgeWidth, { ...(ngDevMode ? { debugName: "edgeWidth" } : /* istanbul ignore next */ {}), transform: numberAttribute });
2342
+ portSize = input(this.config.portSize, { ...(ngDevMode ? { debugName: "portSize" } : /* istanbul ignore next */ {}), transform: numberAttribute });
2343
+ portShape = input(this.config.portShape, /* @ts-ignore */
2344
+ ...(ngDevMode ? [{ debugName: "portShape" }] : /* istanbul ignore next */ []));
2345
+ portColor = input(this.config.portColor, /* @ts-ignore */
2346
+ ...(ngDevMode ? [{ debugName: "portColor" }] : /* istanbul ignore next */ []));
2347
+ /** Data-type registry: colour, glyph and label per `dataType`. */
2348
+ portTypes = input(this.config.portTypes, /* @ts-ignore */
2349
+ ...(ngDevMode ? [{ debugName: "portTypes" }] : /* istanbul ignore next */ []));
2350
+ allowSelfConnection = input(this.config.allowSelfConnection, { ...(ngDevMode ? { debugName: "allowSelfConnection" } : /* istanbul ignore next */ {}), transform: booleanAttribute });
2351
+ enforcePortTypes = input(this.config.enforcePortTypes, { ...(ngDevMode ? { debugName: "enforcePortTypes" } : /* istanbul ignore next */ {}), transform: booleanAttribute });
2352
+ /** Last say on whether a connection may be made. Runs after the built-in rules. */
2353
+ isValidConnection = input(/* @ts-ignore */
2354
+ ...(ngDevMode ? [undefined, { debugName: "isValidConnection" }] : /* istanbul ignore next */ []));
2355
+ /** Read-only canvas: pan and zoom still work, editing does not. */
2356
+ locked = input(false, { ...(ngDevMode ? { debugName: "locked" } : /* istanbul ignore next */ {}), transform: booleanAttribute });
2357
+ /** Dragging empty canvas pans. Turn it off to make dragging a marquee select instead. */
2358
+ panOnDrag = input(true, { ...(ngDevMode ? { debugName: "panOnDrag" } : /* istanbul ignore next */ {}), transform: booleanAttribute });
2359
+ /**
2360
+ * `wheel` zooms on a bare wheel, as node editors do. `ctrl-wheel` reserves the
2361
+ * bare wheel for panning and zooms only with the modifier, as design tools do.
2362
+ */
2363
+ zoomActivation = input('wheel', /* @ts-ignore */
2364
+ ...(ngDevMode ? [{ debugName: "zoomActivation" }] : /* istanbul ignore next */ []));
2365
+ zoomOnScroll = input(true, { ...(ngDevMode ? { debugName: "zoomOnScroll" } : /* istanbul ignore next */ {}), transform: booleanAttribute });
2366
+ /** A connection that passed every rule. The host decides whether to add it. */
2367
+ connect = output();
2368
+ /** A wire dropped on empty canvas — the hook for "drag out to create a node". */
2369
+ connectionDrop = output();
2370
+ edgeClick = output();
2371
+ /** Fired once per drag, when the pointer is released. */
2372
+ nodeMove = output();
2373
+ selectionChange = output();
2374
+ /** Delete or Backspace over the canvas. The host performs the removal. */
2375
+ deleteSelection = output();
2376
+ canvasContextMenu = output();
2377
+ Math = Math;
2378
+ arrowId = `xui-graph-arrow-${this.uid}`;
2379
+ arrowClosedId = `xui-graph-arrow-closed-${this.uid}`;
2380
+ dotId = `xui-graph-dot-${this.uid}`;
2381
+ marqueeRect = computed(() => {
2382
+ const rect = this.marqueeState();
2383
+ if (!rect) {
2384
+ return null;
2385
+ }
2386
+ const origin = this.store.toSurfacePoint(rect);
2387
+ const { zoom } = this.viewport();
2388
+ return { x: origin.x, y: origin.y, width: rect.width * zoom, height: rect.height * zoom };
2389
+ }, /* @ts-ignore */
2390
+ ...(ngDevMode ? [{ debugName: "marqueeRect" }] : /* istanbul ignore next */ []));
2391
+ settings = computed(() => ({
2392
+ routing: this.routing(),
2393
+ gridSize: this.gridSize(),
2394
+ snapToGrid: this.snapToGrid(),
2395
+ minZoom: this.minZoom(),
2396
+ maxZoom: this.maxZoom(),
2397
+ stubLength: this.config.stubLength,
2398
+ cornerRadius: this.config.cornerRadius,
2399
+ curvature: this.config.curvature,
2400
+ edgeWidth: this.edgeWidth(),
2401
+ portSize: this.portSize(),
2402
+ portShape: this.portShape(),
2403
+ portColor: this.portColor(),
2404
+ portTypes: this.portTypes(),
2405
+ allowSelfConnection: this.allowSelfConnection(),
2406
+ enforcePortTypes: this.enforcePortTypes(),
2407
+ isValidConnection: this.isValidConnection(),
2408
+ locked: this.locked()
2409
+ }), /* @ts-ignore */
2410
+ ...(ngDevMode ? [{ debugName: "settings" }] : /* istanbul ignore next */ []));
2411
+ routeOptions = computed(() => ({
2412
+ stubLength: this.config.stubLength,
2413
+ cornerRadius: this.config.cornerRadius,
2414
+ curvature: this.config.curvature
2415
+ }), /* @ts-ignore */
2416
+ ...(ngDevMode ? [{ debugName: "routeOptions" }] : /* istanbul ignore next */ []));
2417
+ canvasTransform = computed(() => {
2418
+ const { x, y, zoom } = this.viewport();
2419
+ return `translate(${x}px, ${y}px) scale(${zoom})`;
2420
+ }, /* @ts-ignore */
2421
+ ...(ngDevMode ? [{ debugName: "canvasTransform" }] : /* istanbul ignore next */ []));
2422
+ renderedEdges = computed(() => {
2423
+ const routing = this.routing();
2424
+ const options = this.routeOptions();
2425
+ const defaultWidth = this.edgeWidth();
2426
+ const defaultMarker = this.marker();
2427
+ const selected = this.store.selectedEdges();
2428
+ const rendered = [];
2429
+ for (const edge of this.edges()) {
2430
+ const sourceKey = xuiGraphPortKey(edge.source.nodeId, edge.source.portId);
2431
+ const targetKey = xuiGraphPortKey(edge.target.nodeId, edge.target.portId);
2432
+ const sourcePort = this.store.port(sourceKey);
2433
+ const targetPort = this.store.port(targetKey);
2434
+ const sourcePoint = this.store.portPoint(sourceKey);
2435
+ const targetPoint = this.store.portPoint(targetKey);
2436
+ // An edge naming a port that has not rendered yet is skipped rather than
2437
+ // guessed at, so a lazily-created node never draws a wire to the origin.
2438
+ if (!sourcePort || !targetPort || !sourcePoint || !targetPoint) {
2439
+ continue;
2440
+ }
2441
+ const edgeRouting = edge.routing ?? routing;
2442
+ const sourceSide = sourcePort.resolvedSide();
2443
+ const targetSide = targetPort.resolvedSide();
2444
+ const markerType = edge.marker ?? defaultMarker;
2445
+ rendered.push({
2446
+ edge,
2447
+ d: xuiGraphEdgePath(sourcePoint, sourceSide, targetPoint, targetSide, edgeRouting, options),
2448
+ color: edge.color ?? sourcePort.resolvedColor(),
2449
+ width: edge.width ?? defaultWidth,
2450
+ selected: selected.has(edge.id),
2451
+ dashArray: edge.dashed && !edge.animated ? '5 5' : null,
2452
+ animated: !!edge.animated,
2453
+ markerId: this.markerUrl(markerType),
2454
+ label: edge.label ?? null,
2455
+ labelPosition: xuiGraphEdgeMidpoint(sourcePoint, sourceSide, targetPoint, targetSide, edgeRouting, options)
2456
+ });
2457
+ }
2458
+ return rendered;
2459
+ }, /* @ts-ignore */
2460
+ ...(ngDevMode ? [{ debugName: "renderedEdges" }] : /* istanbul ignore next */ []));
2461
+ /** The wire that follows the pointer while a connection is being drawn. */
2462
+ linkPreview = computed(() => {
2463
+ const linking = this.store.linking();
2464
+ if (!linking) {
2465
+ return null;
2466
+ }
2467
+ const port = this.store.port(linking.fromKey);
2468
+ const from = this.store.portPoint(linking.fromKey);
2469
+ if (!port || !from) {
2470
+ return null;
2471
+ }
2472
+ // Land on whichever port is hovered, so the preview snaps before release.
2473
+ const hoverPoint = linking.hoverKey ? this.store.portPoint(linking.hoverKey) : null;
2474
+ const hoverSide = linking.hoverKey ? this.store.port(linking.hoverKey)?.resolvedSide() : undefined;
2475
+ const to = hoverPoint ?? linking.pointer;
2476
+ const toSide = hoverSide ?? OPPOSITE[port.resolvedSide()];
2477
+ const valid = linking.hoverKey ? this.store.linkState(linking.hoverKey) === 'valid' : true;
2478
+ return {
2479
+ d: xuiGraphEdgePath(from, port.resolvedSide(), to, toSide, this.routing(), this.routeOptions()),
2480
+ color: valid ? port.resolvedColor() : 'var(--color-error)'
2481
+ };
2482
+ }, /* @ts-ignore */
2483
+ ...(ngDevMode ? [{ debugName: "linkPreview" }] : /* istanbul ignore next */ []));
2484
+ backgroundImage = computed(() => {
2485
+ const background = this.background();
2486
+ const step = this.gridSize() * this.viewport().zoom;
2487
+ // Below a few pixels a rule turns into noise, so it is dropped entirely.
2488
+ if (background === 'none' || step < 4) {
2489
+ return null;
2490
+ }
2491
+ if (background === 'dots') {
2492
+ return 'radial-gradient(circle, var(--color-border-strong) 1px, transparent 1px)';
2493
+ }
2494
+ return [
2495
+ 'linear-gradient(to right, var(--color-border-muted) 1px, transparent 1px)',
2496
+ 'linear-gradient(to bottom, var(--color-border-muted) 1px, transparent 1px)',
2497
+ 'linear-gradient(to right, var(--color-border) 1px, transparent 1px)',
2498
+ 'linear-gradient(to bottom, var(--color-border) 1px, transparent 1px)'
2499
+ ].join(', ');
2500
+ }, /* @ts-ignore */
2501
+ ...(ngDevMode ? [{ debugName: "backgroundImage" }] : /* istanbul ignore next */ []));
2502
+ backgroundSize = computed(() => {
2503
+ const step = this.gridSize() * this.viewport().zoom;
2504
+ const major = step * this.config.gridMajorEvery;
2505
+ return this.background() === 'dots'
2506
+ ? `${step}px ${step}px`
2507
+ : `${step}px ${step}px, ${step}px ${step}px, ${major}px ${major}px, ${major}px ${major}px`;
2508
+ }, /* @ts-ignore */
2509
+ ...(ngDevMode ? [{ debugName: "backgroundSize" }] : /* istanbul ignore next */ []));
2510
+ backgroundPosition = computed(() => {
2511
+ const { x, y } = this.viewport();
2512
+ const offset = `${x}px ${y}px`;
2513
+ return this.background() === 'dots' ? offset : `${offset}, ${offset}, ${offset}, ${offset}`;
2514
+ }, /* @ts-ignore */
2515
+ ...(ngDevMode ? [{ debugName: "backgroundPosition" }] : /* istanbul ignore next */ []));
2516
+ computedClass = computed(() => xui(
2517
+ // The canvas is focusable so its shortcuts work, but draws no focus ring:
2518
+ // a ring around the whole editor reads as a selection the user did not make,
2519
+ // and every element inside it that can hold focus rings on its own.
2520
+ 'bg-background relative block touch-none overflow-hidden outline-none', this.class()), /* @ts-ignore */
2521
+ ...(ngDevMode ? [{ debugName: "computedClass" }] : /* istanbul ignore next */ []));
2522
+ constructor() {
2523
+ this.store.configure({
2524
+ settings: this.settings,
2525
+ viewport: this.viewport,
2526
+ edges: this.edges,
2527
+ listeners: {
2528
+ connect: connection => this.connect.emit(connection),
2529
+ connectionDrop: drop => this.connectionDrop.emit(drop),
2530
+ nodeMove: move => this.nodeMove.emit(move),
2531
+ selectionChange: () => this.selectionChange.emit({
2532
+ nodes: [...this.store.selectedNodes()],
2533
+ edges: [...this.store.selectedEdges()]
2534
+ })
2535
+ }
2536
+ });
2537
+ this.store.setSurface(this.element);
2538
+ const observer = new ResizeObserver(() => this.store.surfaceSize.set({ width: this.element.clientWidth, height: this.element.clientHeight }));
2539
+ observer.observe(this.element);
2540
+ inject(DestroyRef).onDestroy(() => observer.disconnect());
2541
+ }
2542
+ /** Frame every node, leaving `padding` screen pixels of margin. */
2543
+ fitView(padding) {
2544
+ this.store.fitView(padding);
2545
+ }
2546
+ zoomIn() {
2547
+ this.store.zoomBy(this.config.zoomStep);
2548
+ }
2549
+ zoomOut() {
2550
+ this.store.zoomBy(1 / this.config.zoomStep);
2551
+ }
2552
+ /** Return to 1:1 without moving the centre of the view. */
2553
+ resetZoom() {
2554
+ this.store.zoomTo(1);
2555
+ }
2556
+ markerUrl(marker) {
2557
+ switch (marker) {
2558
+ case 'arrow':
2559
+ return `url(#${this.arrowId})`;
2560
+ case 'arrow-closed':
2561
+ return `url(#${this.arrowClosedId})`;
2562
+ case 'dot':
2563
+ return `url(#${this.dotId})`;
2564
+ default:
2565
+ return null;
2566
+ }
2567
+ }
2568
+ onEdgePointerDown(edge, event) {
2569
+ if (event.button !== 0 || this.locked()) {
2570
+ return;
2571
+ }
2572
+ event.stopPropagation();
2573
+ this.store.selectEdge(edge.id, event.shiftKey || event.ctrlKey || event.metaKey);
2574
+ this.edgeClick.emit(edge);
2575
+ }
2576
+ /**
2577
+ * Presses that reach here landed on empty canvas — nodes, ports and edges all
2578
+ * stop propagation — so this only ever starts a pan or a marquee.
2579
+ */
2580
+ onPointerDown(event) {
2581
+ const middle = event.button === 1;
2582
+ if (event.button !== 0 && !middle) {
2583
+ return;
2584
+ }
2585
+ // Overlay furniture sits above the canvas but still bubbles to it. Capturing
2586
+ // the pointer here would retarget the release and the button would never see
2587
+ // a click at all, so the whole overlay layer is left alone.
2588
+ if (event.target.closest('[data-xui-graph-overlay]')) {
2589
+ return;
2590
+ }
2591
+ this.element.focus({ preventScroll: true });
2592
+ this.element.setPointerCapture(event.pointerId);
2593
+ this.gestureOrigin = { x: event.clientX, y: event.clientY };
2594
+ this.gestureMoved = false;
2595
+ // Middle-drag always pans, whatever the primary gesture is bound to;
2596
+ // shift inverts the primary gesture, which is the convention everywhere.
2597
+ const pan = middle || this.panOnDrag() !== event.shiftKey;
2598
+ if (pan) {
2599
+ this.gesture = 'pan';
2600
+ return;
2601
+ }
2602
+ this.gesture = 'marquee';
2603
+ const start = this.store.toGraphPoint(event.clientX, event.clientY);
2604
+ this.marqueeState.set({ x: start.x, y: start.y, width: 0, height: 0 });
2605
+ }
2606
+ onPointerMove(event) {
2607
+ if (this.gesture === 'none') {
2608
+ return;
2609
+ }
2610
+ const dx = event.clientX - this.gestureOrigin.x;
2611
+ const dy = event.clientY - this.gestureOrigin.y;
2612
+ if (Math.abs(dx) > 2 || Math.abs(dy) > 2) {
2613
+ this.gestureMoved = true;
2614
+ }
2615
+ if (this.gesture === 'pan') {
2616
+ this.store.panBy(dx, dy);
2617
+ this.gestureOrigin = { x: event.clientX, y: event.clientY };
2618
+ return;
2619
+ }
2620
+ const start = this.store.toGraphPoint(this.gestureOrigin.x, this.gestureOrigin.y);
2621
+ const current = this.store.toGraphPoint(event.clientX, event.clientY);
2622
+ this.marqueeState.set({
2623
+ x: Math.min(start.x, current.x),
2624
+ y: Math.min(start.y, current.y),
2625
+ width: Math.abs(current.x - start.x),
2626
+ height: Math.abs(current.y - start.y)
2627
+ });
2628
+ }
2629
+ onPointerUp(event) {
2630
+ if (this.gesture === 'none') {
2631
+ return;
2632
+ }
2633
+ this.element.releasePointerCapture(event.pointerId);
2634
+ const rect = untracked(this.marqueeState);
2635
+ const gesture = this.gesture;
2636
+ this.gesture = 'none';
2637
+ this.marqueeState.set(null);
2638
+ if (gesture === 'marquee' && rect && this.gestureMoved) {
2639
+ this.store.selectInRect(rect, event.shiftKey || event.ctrlKey || event.metaKey);
2640
+ return;
2641
+ }
2642
+ // A press that never moved is a click on the backdrop: deselect.
2643
+ if (!this.gestureMoved) {
2644
+ this.store.clearSelection();
2645
+ }
2646
+ }
2647
+ onWheel(event) {
2648
+ if (!this.zoomOnScroll()) {
2649
+ return;
2650
+ }
2651
+ const rect = this.element.getBoundingClientRect();
2652
+ const anchor = { x: event.clientX - rect.left, y: event.clientY - rect.top };
2653
+ if (this.zoomActivation() === 'ctrl-wheel' && !event.ctrlKey && !event.metaKey) {
2654
+ event.preventDefault();
2655
+ this.store.panBy(-event.deltaX, -event.deltaY);
2656
+ return;
2657
+ }
2658
+ event.preventDefault();
2659
+ // Exponential in the raw delta so a trackpad's fine-grained events and a
2660
+ // mouse wheel's coarse notches both feel proportional.
2661
+ this.store.zoomBy(Math.exp(-clamp(event.deltaY, -120, 120) * 0.0025), anchor);
2662
+ }
2663
+ onKeydown(event) {
2664
+ const modifier = event.ctrlKey || event.metaKey;
2665
+ if (modifier && event.key.toLowerCase() === 'a') {
2666
+ event.preventDefault();
2667
+ this.store.selectAll();
2668
+ return;
2669
+ }
2670
+ if (event.key === 'Escape') {
2671
+ this.store.endLink(null);
2672
+ this.store.clearSelection();
2673
+ return;
2674
+ }
2675
+ if ((event.key === 'Delete' || event.key === 'Backspace') && !this.locked()) {
2676
+ const nodes = [...this.store.selectedNodes()];
2677
+ const edges = [...this.store.selectedEdges()];
2678
+ if (nodes.length || edges.length) {
2679
+ event.preventDefault();
2680
+ this.deleteSelection.emit({ nodes, edges });
2681
+ }
2682
+ return;
2683
+ }
2684
+ const nudge = ARROW_NUDGE[event.key];
2685
+ if (nudge && !this.locked() && this.store.selectedNodes().size > 0) {
2686
+ event.preventDefault();
2687
+ // Shift steps a whole grid cell; a bare arrow moves by one unit, or by the
2688
+ // grid when snapping is on and any smaller step would be discarded anyway.
2689
+ const step = event.shiftKey || this.snapToGrid() ? this.gridSize() : 1;
2690
+ const first = [...this.store.selectedNodes()][0];
2691
+ this.store.beginNodeDrag(first);
2692
+ this.store.dragNodesBy({ x: nudge.x * step, y: nudge.y * step });
2693
+ this.store.endNodeDrag();
2694
+ }
2695
+ }
2696
+ onContextMenu(event) {
2697
+ this.canvasContextMenu.emit({ event, position: this.store.toGraphPoint(event.clientX, event.clientY) });
2698
+ }
2699
+ /** @nocollapse */ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: XuiNodeGraph, deps: [], target: i0.ɵɵFactoryTarget.Component });
2700
+ /** @nocollapse */ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.0.8", type: XuiNodeGraph, isStandalone: true, selector: "xui-node-graph", inputs: { class: { classPropertyName: "class", publicName: "class", isSignal: true, isRequired: false, transformFunction: null }, edges: { classPropertyName: "edges", publicName: "edges", isSignal: true, isRequired: false, transformFunction: null }, viewport: { classPropertyName: "viewport", publicName: "viewport", isSignal: true, isRequired: false, transformFunction: null }, routing: { classPropertyName: "routing", publicName: "routing", isSignal: true, isRequired: false, transformFunction: null }, background: { classPropertyName: "background", publicName: "background", isSignal: true, isRequired: false, transformFunction: null }, marker: { classPropertyName: "marker", publicName: "marker", isSignal: true, isRequired: false, transformFunction: null }, gridSize: { classPropertyName: "gridSize", publicName: "gridSize", isSignal: true, isRequired: false, transformFunction: null }, snapToGrid: { classPropertyName: "snapToGrid", publicName: "snapToGrid", isSignal: true, isRequired: false, transformFunction: null }, minZoom: { classPropertyName: "minZoom", publicName: "minZoom", isSignal: true, isRequired: false, transformFunction: null }, maxZoom: { classPropertyName: "maxZoom", publicName: "maxZoom", isSignal: true, isRequired: false, transformFunction: null }, edgeWidth: { classPropertyName: "edgeWidth", publicName: "edgeWidth", isSignal: true, isRequired: false, transformFunction: null }, portSize: { classPropertyName: "portSize", publicName: "portSize", isSignal: true, isRequired: false, transformFunction: null }, portShape: { classPropertyName: "portShape", publicName: "portShape", isSignal: true, isRequired: false, transformFunction: null }, portColor: { classPropertyName: "portColor", publicName: "portColor", isSignal: true, isRequired: false, transformFunction: null }, portTypes: { classPropertyName: "portTypes", publicName: "portTypes", isSignal: true, isRequired: false, transformFunction: null }, allowSelfConnection: { classPropertyName: "allowSelfConnection", publicName: "allowSelfConnection", isSignal: true, isRequired: false, transformFunction: null }, enforcePortTypes: { classPropertyName: "enforcePortTypes", publicName: "enforcePortTypes", isSignal: true, isRequired: false, transformFunction: null }, isValidConnection: { classPropertyName: "isValidConnection", publicName: "isValidConnection", isSignal: true, isRequired: false, transformFunction: null }, locked: { classPropertyName: "locked", publicName: "locked", isSignal: true, isRequired: false, transformFunction: null }, panOnDrag: { classPropertyName: "panOnDrag", publicName: "panOnDrag", isSignal: true, isRequired: false, transformFunction: null }, zoomActivation: { classPropertyName: "zoomActivation", publicName: "zoomActivation", isSignal: true, isRequired: false, transformFunction: null }, zoomOnScroll: { classPropertyName: "zoomOnScroll", publicName: "zoomOnScroll", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { viewport: "viewportChange", connect: "connect", connectionDrop: "connectionDrop", edgeClick: "edgeClick", nodeMove: "nodeMove", selectionChange: "selectionChange", deleteSelection: "deleteSelection", canvasContextMenu: "canvasContextMenu" }, host: { attributes: { "role": "application" }, listeners: { "pointerdown": "onPointerDown($event)", "pointermove": "onPointerMove($event)", "pointerup": "onPointerUp($event)", "pointercancel": "onPointerUp($event)", "wheel": "onWheel($event)", "keydown": "onKeydown($event)", "contextmenu": "onContextMenu($event)" }, properties: { "class": "computedClass()", "attr.tabindex": "0" } }, providers: [XuiNodeGraphStore], ngImport: i0, template: `
2701
+ <div
2702
+ class="pointer-events-none absolute inset-0"
2703
+ [style.background-image]="backgroundImage()"
2704
+ [style.background-size]="backgroundSize()"
2705
+ [style.background-position]="backgroundPosition()"
2706
+ ></div>
2707
+
2708
+ <div class="absolute top-0 left-0 origin-top-left" [style.transform]="canvasTransform()">
2709
+ <svg class="pointer-events-none absolute overflow-visible" width="1" height="1" aria-hidden="true">
2710
+ <defs>
2711
+ <marker
2712
+ [id]="arrowId"
2713
+ markerWidth="10"
2714
+ markerHeight="10"
2715
+ refX="9"
2716
+ refY="5"
2717
+ orient="auto-start-reverse"
2718
+ markerUnits="userSpaceOnUse"
2719
+ >
2720
+ <path d="M 1 1 L 9 5 L 1 9" fill="none" stroke="context-stroke" stroke-width="1.5" stroke-linecap="round" />
2721
+ </marker>
2722
+ <marker
2723
+ [id]="arrowClosedId"
2724
+ markerWidth="10"
2725
+ markerHeight="10"
2726
+ refX="9"
2727
+ refY="5"
2728
+ orient="auto-start-reverse"
2729
+ markerUnits="userSpaceOnUse"
2730
+ >
2731
+ <path d="M 1 1 L 9 5 L 1 9 Z" fill="context-stroke" stroke="context-stroke" stroke-width="1" />
2732
+ </marker>
2733
+ <marker [id]="dotId" markerWidth="8" markerHeight="8" refX="4" refY="4" markerUnits="userSpaceOnUse">
2734
+ <circle cx="4" cy="4" r="3" fill="context-stroke" />
2735
+ </marker>
2736
+ </defs>
2737
+
2738
+ @for (item of renderedEdges(); track item.edge.id) {
2739
+ <g class="pointer-events-auto">
2740
+ @if (item.selected) {
2741
+ <path
2742
+ [attr.d]="item.d"
2743
+ fill="none"
2744
+ stroke="var(--color-selection)"
2745
+ [attr.stroke-width]="item.width + 8"
2746
+ stroke-linecap="round"
2747
+ stroke-linejoin="round"
2748
+ />
2749
+ }
2750
+ <path
2751
+ class="cursor-pointer"
2752
+ [attr.d]="item.d"
2753
+ fill="none"
2754
+ stroke="transparent"
2755
+ [attr.stroke-width]="Math.max(item.width * 4, 14)"
2756
+ [attr.pointer-events]="locked() ? 'none' : 'stroke'"
2757
+ (pointerdown)="onEdgePointerDown(item.edge, $event)"
2758
+ />
2759
+ <path
2760
+ [class]="item.animated ? 'xui-graph-edge-flow' : null"
2761
+ [attr.d]="item.d"
2762
+ fill="none"
2763
+ [attr.stroke]="item.color"
2764
+ [attr.stroke-width]="item.width"
2765
+ [attr.stroke-dasharray]="item.dashArray"
2766
+ stroke-linecap="round"
2767
+ stroke-linejoin="round"
2768
+ [attr.marker-end]="item.markerId"
2769
+ pointer-events="none"
2770
+ />
2771
+ </g>
2772
+ }
2773
+
2774
+ @if (linkPreview(); as preview) {
2775
+ <path
2776
+ [attr.d]="preview.d"
2777
+ fill="none"
2778
+ [attr.stroke]="preview.color"
2779
+ [attr.stroke-width]="edgeWidth()"
2780
+ stroke-dasharray="6 5"
2781
+ stroke-linecap="round"
2782
+ pointer-events="none"
2783
+ />
2784
+ }
2785
+ </svg>
2786
+
2787
+ <ng-content />
2788
+
2789
+ @for (item of renderedEdges(); track item.edge.id) {
2790
+ @if (item.label) {
2791
+ <div
2792
+ class="bg-surface-overlay text-foreground-muted border-border-muted pointer-events-none absolute rounded border px-1.5 py-0.5 text-[10px] whitespace-nowrap"
2793
+ [style.left.px]="item.labelPosition.x"
2794
+ [style.top.px]="item.labelPosition.y"
2795
+ style="transform: translate(-50%, -50%)"
2796
+ >
2797
+ {{ item.label }}
2798
+ </div>
2799
+ }
2800
+ }
2801
+ </div>
2802
+
2803
+ @if (marqueeRect(); as rect) {
2804
+ <div
2805
+ class="border-primary bg-primary/15 pointer-events-none absolute border"
2806
+ [style.left.px]="rect.x"
2807
+ [style.top.px]="rect.y"
2808
+ [style.width.px]="rect.width"
2809
+ [style.height.px]="rect.height"
2810
+ ></div>
2811
+ }
2812
+
2813
+ <ng-content select="xui-graph-overlay, xui-graph-controls, xui-graph-minimap" />
2814
+ `, isInline: true, styles: ["@keyframes xui-graph-edge-flow{to{stroke-dashoffset:-16}}.xui-graph-edge-flow{stroke-dasharray:8 8;animation:xui-graph-edge-flow .6s linear infinite}@media(prefers-reduced-motion:reduce){.xui-graph-edge-flow{animation:none}}\n"], changeDetection: i0.ChangeDetectionStrategy.OnPush, encapsulation: i0.ViewEncapsulation.None });
2815
+ }
2816
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: XuiNodeGraph, decorators: [{
2817
+ type: Component,
2818
+ args: [{ selector: 'xui-node-graph', template: `
2819
+ <div
2820
+ class="pointer-events-none absolute inset-0"
2821
+ [style.background-image]="backgroundImage()"
2822
+ [style.background-size]="backgroundSize()"
2823
+ [style.background-position]="backgroundPosition()"
2824
+ ></div>
2825
+
2826
+ <div class="absolute top-0 left-0 origin-top-left" [style.transform]="canvasTransform()">
2827
+ <svg class="pointer-events-none absolute overflow-visible" width="1" height="1" aria-hidden="true">
2828
+ <defs>
2829
+ <marker
2830
+ [id]="arrowId"
2831
+ markerWidth="10"
2832
+ markerHeight="10"
2833
+ refX="9"
2834
+ refY="5"
2835
+ orient="auto-start-reverse"
2836
+ markerUnits="userSpaceOnUse"
2837
+ >
2838
+ <path d="M 1 1 L 9 5 L 1 9" fill="none" stroke="context-stroke" stroke-width="1.5" stroke-linecap="round" />
2839
+ </marker>
2840
+ <marker
2841
+ [id]="arrowClosedId"
2842
+ markerWidth="10"
2843
+ markerHeight="10"
2844
+ refX="9"
2845
+ refY="5"
2846
+ orient="auto-start-reverse"
2847
+ markerUnits="userSpaceOnUse"
2848
+ >
2849
+ <path d="M 1 1 L 9 5 L 1 9 Z" fill="context-stroke" stroke="context-stroke" stroke-width="1" />
2850
+ </marker>
2851
+ <marker [id]="dotId" markerWidth="8" markerHeight="8" refX="4" refY="4" markerUnits="userSpaceOnUse">
2852
+ <circle cx="4" cy="4" r="3" fill="context-stroke" />
2853
+ </marker>
2854
+ </defs>
2855
+
2856
+ @for (item of renderedEdges(); track item.edge.id) {
2857
+ <g class="pointer-events-auto">
2858
+ @if (item.selected) {
2859
+ <path
2860
+ [attr.d]="item.d"
2861
+ fill="none"
2862
+ stroke="var(--color-selection)"
2863
+ [attr.stroke-width]="item.width + 8"
2864
+ stroke-linecap="round"
2865
+ stroke-linejoin="round"
2866
+ />
2867
+ }
2868
+ <path
2869
+ class="cursor-pointer"
2870
+ [attr.d]="item.d"
2871
+ fill="none"
2872
+ stroke="transparent"
2873
+ [attr.stroke-width]="Math.max(item.width * 4, 14)"
2874
+ [attr.pointer-events]="locked() ? 'none' : 'stroke'"
2875
+ (pointerdown)="onEdgePointerDown(item.edge, $event)"
2876
+ />
2877
+ <path
2878
+ [class]="item.animated ? 'xui-graph-edge-flow' : null"
2879
+ [attr.d]="item.d"
2880
+ fill="none"
2881
+ [attr.stroke]="item.color"
2882
+ [attr.stroke-width]="item.width"
2883
+ [attr.stroke-dasharray]="item.dashArray"
2884
+ stroke-linecap="round"
2885
+ stroke-linejoin="round"
2886
+ [attr.marker-end]="item.markerId"
2887
+ pointer-events="none"
2888
+ />
2889
+ </g>
2890
+ }
2891
+
2892
+ @if (linkPreview(); as preview) {
2893
+ <path
2894
+ [attr.d]="preview.d"
2895
+ fill="none"
2896
+ [attr.stroke]="preview.color"
2897
+ [attr.stroke-width]="edgeWidth()"
2898
+ stroke-dasharray="6 5"
2899
+ stroke-linecap="round"
2900
+ pointer-events="none"
2901
+ />
2902
+ }
2903
+ </svg>
2904
+
2905
+ <ng-content />
2906
+
2907
+ @for (item of renderedEdges(); track item.edge.id) {
2908
+ @if (item.label) {
2909
+ <div
2910
+ class="bg-surface-overlay text-foreground-muted border-border-muted pointer-events-none absolute rounded border px-1.5 py-0.5 text-[10px] whitespace-nowrap"
2911
+ [style.left.px]="item.labelPosition.x"
2912
+ [style.top.px]="item.labelPosition.y"
2913
+ style="transform: translate(-50%, -50%)"
2914
+ >
2915
+ {{ item.label }}
2916
+ </div>
2917
+ }
2918
+ }
2919
+ </div>
2920
+
2921
+ @if (marqueeRect(); as rect) {
2922
+ <div
2923
+ class="border-primary bg-primary/15 pointer-events-none absolute border"
2924
+ [style.left.px]="rect.x"
2925
+ [style.top.px]="rect.y"
2926
+ [style.width.px]="rect.width"
2927
+ [style.height.px]="rect.height"
2928
+ ></div>
2929
+ }
2930
+
2931
+ <ng-content select="xui-graph-overlay, xui-graph-controls, xui-graph-minimap" />
2932
+ `, host: {
2933
+ '[class]': 'computedClass()',
2934
+ '[attr.tabindex]': '0',
2935
+ role: 'application',
2936
+ '(pointerdown)': 'onPointerDown($event)',
2937
+ '(pointermove)': 'onPointerMove($event)',
2938
+ '(pointerup)': 'onPointerUp($event)',
2939
+ '(pointercancel)': 'onPointerUp($event)',
2940
+ '(wheel)': 'onWheel($event)',
2941
+ '(keydown)': 'onKeydown($event)',
2942
+ '(contextmenu)': 'onContextMenu($event)'
2943
+ }, providers: [XuiNodeGraphStore], changeDetection: ChangeDetectionStrategy.OnPush, encapsulation: ViewEncapsulation.None, styles: ["@keyframes xui-graph-edge-flow{to{stroke-dashoffset:-16}}.xui-graph-edge-flow{stroke-dasharray:8 8;animation:xui-graph-edge-flow .6s linear infinite}@media(prefers-reduced-motion:reduce){.xui-graph-edge-flow{animation:none}}\n"] }]
2944
+ }], ctorParameters: () => [], propDecorators: { class: [{ type: i0.Input, args: [{ isSignal: true, alias: "class", required: false }] }], edges: [{ type: i0.Input, args: [{ isSignal: true, alias: "edges", required: false }] }], viewport: [{ type: i0.Input, args: [{ isSignal: true, alias: "viewport", required: false }] }, { type: i0.Output, args: ["viewportChange"] }], routing: [{ type: i0.Input, args: [{ isSignal: true, alias: "routing", required: false }] }], background: [{ type: i0.Input, args: [{ isSignal: true, alias: "background", required: false }] }], marker: [{ type: i0.Input, args: [{ isSignal: true, alias: "marker", required: false }] }], gridSize: [{ type: i0.Input, args: [{ isSignal: true, alias: "gridSize", required: false }] }], snapToGrid: [{ type: i0.Input, args: [{ isSignal: true, alias: "snapToGrid", required: false }] }], minZoom: [{ type: i0.Input, args: [{ isSignal: true, alias: "minZoom", required: false }] }], maxZoom: [{ type: i0.Input, args: [{ isSignal: true, alias: "maxZoom", required: false }] }], edgeWidth: [{ type: i0.Input, args: [{ isSignal: true, alias: "edgeWidth", required: false }] }], portSize: [{ type: i0.Input, args: [{ isSignal: true, alias: "portSize", required: false }] }], portShape: [{ type: i0.Input, args: [{ isSignal: true, alias: "portShape", required: false }] }], portColor: [{ type: i0.Input, args: [{ isSignal: true, alias: "portColor", required: false }] }], portTypes: [{ type: i0.Input, args: [{ isSignal: true, alias: "portTypes", required: false }] }], allowSelfConnection: [{ type: i0.Input, args: [{ isSignal: true, alias: "allowSelfConnection", required: false }] }], enforcePortTypes: [{ type: i0.Input, args: [{ isSignal: true, alias: "enforcePortTypes", required: false }] }], isValidConnection: [{ type: i0.Input, args: [{ isSignal: true, alias: "isValidConnection", required: false }] }], locked: [{ type: i0.Input, args: [{ isSignal: true, alias: "locked", required: false }] }], panOnDrag: [{ type: i0.Input, args: [{ isSignal: true, alias: "panOnDrag", required: false }] }], zoomActivation: [{ type: i0.Input, args: [{ isSignal: true, alias: "zoomActivation", required: false }] }], zoomOnScroll: [{ type: i0.Input, args: [{ isSignal: true, alias: "zoomOnScroll", required: false }] }], connect: [{ type: i0.Output, args: ["connect"] }], connectionDrop: [{ type: i0.Output, args: ["connectionDrop"] }], edgeClick: [{ type: i0.Output, args: ["edgeClick"] }], nodeMove: [{ type: i0.Output, args: ["nodeMove"] }], selectionChange: [{ type: i0.Output, args: ["selectionChange"] }], deleteSelection: [{ type: i0.Output, args: ["deleteSelection"] }], canvasContextMenu: [{ type: i0.Output, args: ["canvasContextMenu"] }] } });
2945
+ const OPPOSITE = { left: 'right', right: 'left', top: 'bottom', bottom: 'top' };
2946
+ const ARROW_NUDGE = {
2947
+ ArrowLeft: { x: -1, y: 0 },
2948
+ ArrowRight: { x: 1, y: 0 },
2949
+ ArrowUp: { x: 0, y: -1 },
2950
+ ArrowDown: { x: 0, y: 1 }
2951
+ };
2952
+ function clamp(value, min, max) {
2953
+ return Math.min(max, Math.max(min, value));
2954
+ }
2955
+
2956
+ const XuiNodeGraphImports = [
2957
+ XuiNodeGraph,
2958
+ XuiGraphNode,
2959
+ XuiGraphPort,
2960
+ XuiGraphGroup,
2961
+ XuiGraphControls,
2962
+ XuiGraphMinimap,
2963
+ XuiGraphNodeHeader,
2964
+ XuiGraphNodeActions,
2965
+ XuiGraphNodePreview,
2966
+ XuiGraphNoDrag,
2967
+ XuiGraphOverlay
2968
+ ];
2969
+
2970
+ /**
2971
+ * Generated bundle index. Do not edit.
2972
+ */
2973
+
2974
+ export { XUI_GRAPH_DEFAULT_ROUTE_OPTIONS, XuiGraphControls, XuiGraphGroup, XuiGraphMinimap, XuiGraphNoDrag, XuiGraphNode, XuiGraphNodeActions, XuiGraphNodeHeader, XuiGraphNodePreview, XuiGraphOverlay, XuiGraphPort, XuiNodeGraph, XuiNodeGraphImports, XuiNodeGraphStore, graphNodeVariants, injectXuiNodeGraphConfig, injectXuiNodeGraphStore, provideXuiNodeGraphConfig, xuiGraphChartPortTypes, xuiGraphEdgeMidpoint, xuiGraphEdgePath, xuiGraphPortKey, xuiGraphPortNormal };
2975
+ //# sourceMappingURL=xui-node-graph.mjs.map