@bavuchoko/edge-node 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,1639 @@
1
+ import { useCallback, useEffect, useMemo, useReducer, useRef, useState, useSyncExternalStore } from "react";
2
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
3
+ //#region src/graph/dependencies.ts
4
+ function collect(edges, startId, direction) {
5
+ const next = /* @__PURE__ */ new Map();
6
+ for (const edge of edges) {
7
+ const from = direction === "upstream" ? edge.target : edge.source;
8
+ const to = direction === "upstream" ? edge.source : edge.target;
9
+ const list = next.get(from);
10
+ if (list) list.push(to);
11
+ else next.set(from, [to]);
12
+ }
13
+ const visited = /* @__PURE__ */ new Set();
14
+ const stack = [startId];
15
+ while (stack.length > 0) {
16
+ const id = stack.pop();
17
+ for (const neighbor of next.get(id) ?? []) {
18
+ if (neighbor === startId || visited.has(neighbor)) continue;
19
+ visited.add(neighbor);
20
+ stack.push(neighbor);
21
+ }
22
+ }
23
+ return visited;
24
+ }
25
+ /** Nodes that `nodeId` transitively depends on. */
26
+ function getUpstream(edges, nodeId) {
27
+ return collect(edges, nodeId, "upstream");
28
+ }
29
+ /** Nodes that transitively depend on `nodeId`. */
30
+ function getDownstream(edges, nodeId) {
31
+ return collect(edges, nodeId, "downstream");
32
+ }
33
+ /** True when adding `source → target` would close a dependency cycle. */
34
+ function wouldCreateCycle(edges, source, target) {
35
+ if (source === target) return true;
36
+ return getDownstream(edges, target).has(source);
37
+ }
38
+ /** Roles of every node/edge on a dependency chain through `focusId`. */
39
+ function getDependencyHighlight(edges, focusId) {
40
+ const upstream = getUpstream(edges, focusId);
41
+ const downstream = getDownstream(edges, focusId);
42
+ const nodes = /* @__PURE__ */ new Map();
43
+ for (const id of upstream) nodes.set(id, "upstream");
44
+ for (const id of downstream) nodes.set(id, "downstream");
45
+ nodes.set(focusId, "focus");
46
+ const edgeRoles = /* @__PURE__ */ new Map();
47
+ const relations = /* @__PURE__ */ new Map();
48
+ const addRelation = (id, entry) => {
49
+ const list = relations.get(id);
50
+ if (list) list.push(entry);
51
+ else relations.set(id, [entry]);
52
+ };
53
+ for (const edge of edges) if (upstream.has(edge.source) && (edge.target === focusId || upstream.has(edge.target))) {
54
+ edgeRoles.set(edge.id, "upstream");
55
+ if (edge.inverseRelation) addRelation(edge.source, {
56
+ relation: edge.inverseRelation,
57
+ otherId: edge.target
58
+ });
59
+ } else if (downstream.has(edge.target) && (edge.source === focusId || downstream.has(edge.source))) {
60
+ edgeRoles.set(edge.id, "downstream");
61
+ if (edge.relation) addRelation(edge.target, {
62
+ relation: edge.relation,
63
+ otherId: edge.source
64
+ });
65
+ }
66
+ return {
67
+ focusId,
68
+ nodes,
69
+ edges: edgeRoles,
70
+ relations
71
+ };
72
+ }
73
+ //#endregion
74
+ //#region src/graph/geometry.ts
75
+ /** Screen (client) → world coordinates given viewport transform. */
76
+ function screenToWorld(screen, viewportOrigin, viewport) {
77
+ return {
78
+ x: (screen.x - viewportOrigin.x - viewport.x) / viewport.zoom,
79
+ y: (screen.y - viewportOrigin.y - viewport.y) / viewport.zoom
80
+ };
81
+ }
82
+ function getHandlePosition(node, side) {
83
+ const y = node.position.y + node.height / 2;
84
+ if (side === "left") return {
85
+ x: node.position.x,
86
+ y
87
+ };
88
+ return {
89
+ x: node.position.x + node.width,
90
+ y
91
+ };
92
+ }
93
+ function getBezierControls(source, target) {
94
+ const dx = Math.abs(target.x - source.x);
95
+ const curvature = Math.max(dx * .5, 40);
96
+ return [{
97
+ x: source.x + curvature,
98
+ y: source.y
99
+ }, {
100
+ x: target.x - curvature,
101
+ y: target.y
102
+ }];
103
+ }
104
+ /** Cubic bezier path from source (right) to target (left). */
105
+ function getBezierPath(source, target) {
106
+ const [c1, c2] = getBezierControls(source, target);
107
+ return `M ${source.x} ${source.y} C ${c1.x} ${c1.y}, ${c2.x} ${c2.y}, ${target.x} ${target.y}`;
108
+ }
109
+ /** Point halfway along the curve drawn by `getBezierPath`. */
110
+ function getBezierMidpoint(source, target) {
111
+ const [c1, c2] = getBezierControls(source, target);
112
+ return {
113
+ x: (source.x + 3 * c1.x + 3 * c2.x + target.x) / 8,
114
+ y: (source.y + 3 * c1.y + 3 * c2.y + target.y) / 8
115
+ };
116
+ }
117
+ /**
118
+ * Edge geometry ending in an arrowhead at `target`. The curve stops at the
119
+ * arrow's notch so the stroke doesn't poke through the tip. Bezier tangents
120
+ * at the target are always horizontal, so the arrow points in +x.
121
+ */
122
+ function getArrowEdge(source, target, arrowLength) {
123
+ if (arrowLength <= 0) return {
124
+ path: getBezierPath(source, target),
125
+ arrow: null
126
+ };
127
+ const tip = {
128
+ x: target.x - 7,
129
+ y: target.y
130
+ };
131
+ const half = arrowLength * .55;
132
+ const notchX = tip.x - arrowLength * .75;
133
+ const back = tip.x - arrowLength;
134
+ return {
135
+ path: getBezierPath(source, {
136
+ x: notchX,
137
+ y: tip.y
138
+ }),
139
+ arrow: `M ${tip.x} ${tip.y} L ${back} ${tip.y - half} L ${notchX} ${tip.y} L ${back} ${tip.y + half} Z`
140
+ };
141
+ }
142
+ //#endregion
143
+ //#region src/graph/hooks/useColorMode.ts
144
+ var LIGHT_QUERY = "(prefers-color-scheme: light)";
145
+ function subscribeSystemColorMode(onChange) {
146
+ const media = window.matchMedia(LIGHT_QUERY);
147
+ media.addEventListener("change", onChange);
148
+ return () => media.removeEventListener("change", onChange);
149
+ }
150
+ function getSystemColorMode() {
151
+ return window.matchMedia(LIGHT_QUERY).matches ? "light" : "dark";
152
+ }
153
+ function getServerColorMode() {
154
+ return "dark";
155
+ }
156
+ /** Current OS color scheme, updated live. Returns `'dark'` during SSR. */
157
+ function useSystemColorMode() {
158
+ return useSyncExternalStore(subscribeSystemColorMode, getSystemColorMode, getServerColorMode);
159
+ }
160
+ function isColorModePreference(value) {
161
+ return value === "dark" || value === "light" || value === "system";
162
+ }
163
+ function readStoredMode(storageKey) {
164
+ if (!storageKey || typeof window === "undefined") return null;
165
+ try {
166
+ const stored = window.localStorage.getItem(storageKey);
167
+ return isColorModePreference(stored) ? stored : null;
168
+ } catch {
169
+ return null;
170
+ }
171
+ }
172
+ function writeStoredMode(storageKey, mode) {
173
+ if (!storageKey || typeof window === "undefined") return;
174
+ try {
175
+ window.localStorage.setItem(storageKey, mode);
176
+ } catch {}
177
+ }
178
+ /**
179
+ * Color mode state for a `GraphCanvas` host: tracks the user's preference,
180
+ * resolves `'system'` against the OS setting and optionally persists it.
181
+ */
182
+ function useColorMode({ defaultMode = "system", storageKey } = {}) {
183
+ const [mode, setModeState] = useState(() => readStoredMode(storageKey) ?? defaultMode);
184
+ const systemMode = useSystemColorMode();
185
+ const resolvedMode = mode === "system" ? systemMode : mode;
186
+ const setMode = useCallback((next) => {
187
+ setModeState(next);
188
+ writeStoredMode(storageKey, next);
189
+ }, [storageKey]);
190
+ return {
191
+ mode,
192
+ resolvedMode,
193
+ setMode,
194
+ toggle: useCallback(() => {
195
+ setMode(resolvedMode === "dark" ? "light" : "dark");
196
+ }, [resolvedMode, setMode])
197
+ };
198
+ }
199
+ //#endregion
200
+ //#region src/graph/hooks/useConnectionDrag.ts
201
+ function useConnectionDrag({ clientToWorld, onStart, onMove, onComplete, onCancel }) {
202
+ const clientToWorldRef = useRef(clientToWorld);
203
+ const onMoveRef = useRef(onMove);
204
+ const onCompleteRef = useRef(onComplete);
205
+ const onCancelRef = useRef(onCancel);
206
+ useEffect(() => {
207
+ clientToWorldRef.current = clientToWorld;
208
+ onMoveRef.current = onMove;
209
+ onCompleteRef.current = onComplete;
210
+ onCancelRef.current = onCancel;
211
+ });
212
+ return { startFromHandle: useCallback((sourceId, event) => {
213
+ event.stopPropagation();
214
+ event.preventDefault();
215
+ onStart(sourceId, clientToWorldRef.current(event.clientX, event.clientY));
216
+ const onWindowMove = (e) => {
217
+ onMoveRef.current(clientToWorldRef.current(e.clientX, e.clientY));
218
+ };
219
+ const onWindowUp = (e) => {
220
+ window.removeEventListener("pointermove", onWindowMove);
221
+ window.removeEventListener("pointerup", onWindowUp);
222
+ window.removeEventListener("pointercancel", onWindowUp);
223
+ const targetId = (document.elementFromPoint(e.clientX, e.clientY)?.closest("[data-handle=\"target\"]"))?.dataset.nodeId;
224
+ if (targetId) onCompleteRef.current(targetId);
225
+ else onCancelRef.current();
226
+ };
227
+ window.addEventListener("pointermove", onWindowMove);
228
+ window.addEventListener("pointerup", onWindowUp);
229
+ window.addEventListener("pointercancel", onWindowUp);
230
+ }, [onStart]) };
231
+ }
232
+ //#endregion
233
+ //#region src/graph/hooks/useGraphPersistence.ts
234
+ function hasEdgeChanged(before, after) {
235
+ return (before.kind ?? "required") !== (after.kind ?? "required") || (before.label ?? "") !== (after.label ?? "") || (before.relation ?? "") !== (after.relation ?? "") || (before.inverseRelation ?? "") !== (after.inverseRelation ?? "");
236
+ }
237
+ /**
238
+ * Reports graph changes to the host (e.g. to save them through an API) and
239
+ * rolls the local change back when the host rejects it.
240
+ */
241
+ function useGraphPersistence({ state, dispatch, onConnect, onEdgeUpdate, onEdgeDelete, onNodeMove }) {
242
+ const callbacks = useRef({
243
+ onConnect,
244
+ onEdgeUpdate,
245
+ onEdgeDelete,
246
+ onNodeMove
247
+ });
248
+ callbacks.current = {
249
+ onConnect,
250
+ onEdgeUpdate,
251
+ onEdgeDelete,
252
+ onNodeMove
253
+ };
254
+ const stateRef = useRef(state);
255
+ stateRef.current = state;
256
+ const [savingEdgeIds, setSavingEdgeIds] = useState(/* @__PURE__ */ new Set());
257
+ const persist = useCallback(async (name, edgeId, call, rollback) => {
258
+ const setSaving = (saving) => edgeId && setSavingEdgeIds((current) => {
259
+ const next = new Set(current);
260
+ if (saving) next.add(edgeId);
261
+ else next.delete(edgeId);
262
+ return next;
263
+ });
264
+ setSaving(true);
265
+ try {
266
+ if (await call() === false) rollback();
267
+ } catch (error) {
268
+ console.error(`[edge-node] ${name} failed; change rolled back`, error);
269
+ rollback();
270
+ } finally {
271
+ setSaving(false);
272
+ }
273
+ }, []);
274
+ /** The edge whose editor is open, with its value when the editor opened. */
275
+ const openEdge = useRef(null);
276
+ useEffect(() => {
277
+ const sel = state.selection;
278
+ const open = openEdge.current;
279
+ if (open && !(sel?.type === "edge" && sel.id === open.id)) {
280
+ openEdge.current = null;
281
+ const edge = stateRef.current.edges[open.id];
282
+ if (edge && open.created) {
283
+ const handler = callbacks.current.onConnect;
284
+ if (handler) persist("onConnect", edge.id, () => handler(edge), () => dispatch({
285
+ type: "removeEdge",
286
+ id: edge.id
287
+ }));
288
+ } else if (edge && hasEdgeChanged(open.snapshot, edge)) {
289
+ const handler = callbacks.current.onEdgeUpdate;
290
+ const previous = open.snapshot;
291
+ if (handler) persist("onEdgeUpdate", edge.id, () => handler(edge, previous), () => dispatch({
292
+ type: "updateEdge",
293
+ id: edge.id,
294
+ patch: {
295
+ kind: previous.kind,
296
+ label: previous.label,
297
+ relation: previous.relation,
298
+ inverseRelation: previous.inverseRelation
299
+ }
300
+ }));
301
+ }
302
+ }
303
+ if (sel?.type === "edge" && openEdge.current?.id !== sel.id) {
304
+ const edge = stateRef.current.edges[sel.id];
305
+ if (edge) openEdge.current = {
306
+ id: sel.id,
307
+ created: Boolean(sel.created),
308
+ snapshot: edge
309
+ };
310
+ }
311
+ }, [
312
+ state.selection,
313
+ dispatch,
314
+ persist
315
+ ]);
316
+ return {
317
+ savingEdgeIds,
318
+ deleteSelection: useCallback(() => {
319
+ const { selection, edges } = stateRef.current;
320
+ if (!selection) return;
321
+ const removed = selection.type === "edge" ? [edges[selection.id]].filter(Boolean) : Object.values(edges).filter((edge) => edge.source === selection.id || edge.target === selection.id);
322
+ const unconfirmedId = openEdge.current?.created ? openEdge.current.id : null;
323
+ if (selection.type === "edge" && openEdge.current?.id === selection.id) openEdge.current = null;
324
+ dispatch({ type: "deleteSelection" });
325
+ const handler = callbacks.current.onEdgeDelete;
326
+ if (!handler) return;
327
+ for (const edge of removed) {
328
+ if (edge.id === unconfirmedId) continue;
329
+ persist("onEdgeDelete", edge.id, () => handler(edge), () => dispatch({
330
+ type: "addEdge",
331
+ edge
332
+ }));
333
+ }
334
+ }, [dispatch, persist]),
335
+ reportNodeMove: useCallback((id, from) => {
336
+ const handler = callbacks.current.onNodeMove;
337
+ const node = stateRef.current.nodes[id];
338
+ if (!handler || !node) return;
339
+ if (node.position.x === from.x && node.position.y === from.y) return;
340
+ persist("onNodeMove", null, () => handler(node, from), () => dispatch({
341
+ type: "moveNode",
342
+ id,
343
+ position: from
344
+ }));
345
+ }, [dispatch, persist])
346
+ };
347
+ }
348
+ //#endregion
349
+ //#region src/graph/hooks/useViewport.ts
350
+ var MIN_ZOOM = .25;
351
+ var MAX_ZOOM = 2.5;
352
+ function useViewport({ viewport, onViewportChange }) {
353
+ const containerRef = useRef(null);
354
+ const [isPanning, setIsPanning] = useState(false);
355
+ const viewportRef = useRef(viewport);
356
+ viewportRef.current = viewport;
357
+ const onViewportChangeRef = useRef(onViewportChange);
358
+ onViewportChangeRef.current = onViewportChange;
359
+ const panStart = useRef(null);
360
+ const getOrigin = useCallback(() => {
361
+ const el = containerRef.current;
362
+ if (!el) return {
363
+ x: 0,
364
+ y: 0
365
+ };
366
+ const rect = el.getBoundingClientRect();
367
+ return {
368
+ x: rect.left,
369
+ y: rect.top
370
+ };
371
+ }, []);
372
+ const clientToWorld = useCallback((clientX, clientY) => {
373
+ return screenToWorld({
374
+ x: clientX,
375
+ y: clientY
376
+ }, getOrigin(), viewportRef.current);
377
+ }, [getOrigin]);
378
+ useEffect(() => {
379
+ const el = containerRef.current;
380
+ if (!el) return;
381
+ const onWheel = (event) => {
382
+ event.preventDefault();
383
+ const current = viewportRef.current;
384
+ const origin = getOrigin();
385
+ const mouseX = event.clientX - origin.x;
386
+ const mouseY = event.clientY - origin.y;
387
+ const zoomFactor = event.deltaY < 0 ? 1.08 : 1 / 1.08;
388
+ const nextZoom = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, current.zoom * zoomFactor));
389
+ if (nextZoom === current.zoom) return;
390
+ const worldX = (mouseX - current.x) / current.zoom;
391
+ const worldY = (mouseY - current.y) / current.zoom;
392
+ onViewportChangeRef.current({
393
+ zoom: nextZoom,
394
+ x: mouseX - worldX * nextZoom,
395
+ y: mouseY - worldY * nextZoom
396
+ });
397
+ };
398
+ el.addEventListener("wheel", onWheel, { passive: false });
399
+ return () => el.removeEventListener("wheel", onWheel);
400
+ }, [getOrigin]);
401
+ return {
402
+ containerRef,
403
+ isPanning,
404
+ clientToWorld,
405
+ getOrigin,
406
+ startPan: useCallback((event) => {
407
+ if (event.button !== 0) return;
408
+ event.currentTarget.setPointerCapture(event.pointerId);
409
+ panStart.current = {
410
+ pointerId: event.pointerId,
411
+ screen: {
412
+ x: event.clientX,
413
+ y: event.clientY
414
+ },
415
+ viewport: { ...viewportRef.current }
416
+ };
417
+ setIsPanning(true);
418
+ }, []),
419
+ movePan: useCallback((event) => {
420
+ const start = panStart.current;
421
+ if (!start || start.pointerId !== event.pointerId) return;
422
+ const dx = event.clientX - start.screen.x;
423
+ const dy = event.clientY - start.screen.y;
424
+ onViewportChangeRef.current({
425
+ ...start.viewport,
426
+ x: start.viewport.x + dx,
427
+ y: start.viewport.y + dy
428
+ });
429
+ }, []),
430
+ endPan: useCallback((event) => {
431
+ const start = panStart.current;
432
+ if (!start || start.pointerId !== event.pointerId) return;
433
+ panStart.current = null;
434
+ setIsPanning(false);
435
+ try {
436
+ event.currentTarget.releasePointerCapture(event.pointerId);
437
+ } catch {}
438
+ }, [])
439
+ };
440
+ }
441
+ //#endregion
442
+ //#region src/graph/store.ts
443
+ function createEdgeId(source, target) {
444
+ return `${source}->${target}`;
445
+ }
446
+ function createInitialState(nodes, edges, viewport = {
447
+ x: 40,
448
+ y: 40,
449
+ zoom: 1
450
+ }) {
451
+ const nodeMap = {};
452
+ for (const node of nodes) nodeMap[node.id] = node;
453
+ const edgeMap = {};
454
+ for (const edge of edges) {
455
+ const id = createEdgeId(edge.source, edge.target);
456
+ edgeMap[id] = {
457
+ ...edge,
458
+ id
459
+ };
460
+ }
461
+ return {
462
+ nodes: nodeMap,
463
+ edges: edgeMap,
464
+ viewport,
465
+ selection: null,
466
+ connectionDraft: null
467
+ };
468
+ }
469
+ function graphReducer(state, action) {
470
+ switch (action.type) {
471
+ case "setViewport": return {
472
+ ...state,
473
+ viewport: action.viewport
474
+ };
475
+ case "moveNode": {
476
+ const node = state.nodes[action.id];
477
+ if (!node) return state;
478
+ return {
479
+ ...state,
480
+ nodes: {
481
+ ...state.nodes,
482
+ [action.id]: {
483
+ ...node,
484
+ position: action.position
485
+ }
486
+ }
487
+ };
488
+ }
489
+ case "resizeNode": {
490
+ const node = state.nodes[action.id];
491
+ if (!node) return state;
492
+ if (node.width === action.width && node.height === action.height) return state;
493
+ return {
494
+ ...state,
495
+ nodes: {
496
+ ...state.nodes,
497
+ [action.id]: {
498
+ ...node,
499
+ width: action.width,
500
+ height: action.height
501
+ }
502
+ }
503
+ };
504
+ }
505
+ case "setSelection": return {
506
+ ...state,
507
+ selection: action.selection
508
+ };
509
+ case "startConnection": return {
510
+ ...state,
511
+ connectionDraft: {
512
+ sourceId: action.sourceId,
513
+ pointer: action.pointer
514
+ },
515
+ selection: null
516
+ };
517
+ case "updateConnection":
518
+ if (!state.connectionDraft) return state;
519
+ return {
520
+ ...state,
521
+ connectionDraft: {
522
+ ...state.connectionDraft,
523
+ pointer: action.pointer
524
+ }
525
+ };
526
+ case "cancelConnection": return {
527
+ ...state,
528
+ connectionDraft: null
529
+ };
530
+ case "completeConnection": {
531
+ const draft = state.connectionDraft;
532
+ if (!draft) return state;
533
+ const { sourceId } = draft;
534
+ const targetId = action.targetId;
535
+ if (sourceId === targetId) return {
536
+ ...state,
537
+ connectionDraft: null
538
+ };
539
+ if (!state.nodes[sourceId] || !state.nodes[targetId]) return {
540
+ ...state,
541
+ connectionDraft: null
542
+ };
543
+ const id = createEdgeId(sourceId, targetId);
544
+ if (state.edges[id]) return {
545
+ ...state,
546
+ connectionDraft: null
547
+ };
548
+ if (!action.allowCycles && wouldCreateCycle(Object.values(state.edges), sourceId, targetId)) return {
549
+ ...state,
550
+ connectionDraft: null
551
+ };
552
+ return {
553
+ ...state,
554
+ connectionDraft: null,
555
+ edges: {
556
+ ...state.edges,
557
+ [id]: {
558
+ id,
559
+ source: sourceId,
560
+ target: targetId
561
+ }
562
+ },
563
+ selection: {
564
+ type: "edge",
565
+ id,
566
+ created: true
567
+ }
568
+ };
569
+ }
570
+ case "updateEdge": {
571
+ const edge = state.edges[action.id];
572
+ if (!edge) return state;
573
+ const next = {
574
+ ...edge,
575
+ ...action.patch
576
+ };
577
+ if (!next.label) delete next.label;
578
+ if (!next.relation) delete next.relation;
579
+ if (!next.inverseRelation) delete next.inverseRelation;
580
+ return {
581
+ ...state,
582
+ edges: {
583
+ ...state.edges,
584
+ [action.id]: next
585
+ }
586
+ };
587
+ }
588
+ case "addEdge": {
589
+ const { edge } = action;
590
+ if (state.edges[edge.id]) return state;
591
+ if (!state.nodes[edge.source] || !state.nodes[edge.target]) return state;
592
+ return {
593
+ ...state,
594
+ edges: {
595
+ ...state.edges,
596
+ [edge.id]: edge
597
+ }
598
+ };
599
+ }
600
+ case "removeEdge": {
601
+ if (!state.edges[action.id]) return state;
602
+ const { [action.id]: _, ...rest } = state.edges;
603
+ const selection = state.selection?.type === "edge" && state.selection.id === action.id ? null : state.selection;
604
+ return {
605
+ ...state,
606
+ edges: rest,
607
+ selection
608
+ };
609
+ }
610
+ case "deleteSelection": {
611
+ const sel = state.selection;
612
+ if (!sel) return state;
613
+ if (sel.type === "edge") {
614
+ const { [sel.id]: _, ...rest } = state.edges;
615
+ return {
616
+ ...state,
617
+ edges: rest,
618
+ selection: null
619
+ };
620
+ }
621
+ const nodeId = sel.id;
622
+ const { [nodeId]: __, ...restNodes } = state.nodes;
623
+ const restEdges = {};
624
+ for (const edge of Object.values(state.edges)) if (edge.source !== nodeId && edge.target !== nodeId) restEdges[edge.id] = edge;
625
+ return {
626
+ ...state,
627
+ nodes: restNodes,
628
+ edges: restEdges,
629
+ selection: null,
630
+ connectionDraft: null
631
+ };
632
+ }
633
+ default: return state;
634
+ }
635
+ }
636
+ function getNodes(state) {
637
+ return Object.values(state.nodes);
638
+ }
639
+ function getEdges(state) {
640
+ return Object.values(state.edges);
641
+ }
642
+ //#endregion
643
+ //#region src/graph/theme.ts
644
+ var darkGraphTheme = {
645
+ background: "#141414",
646
+ gridColor: "rgba(255, 255, 255, 0.04)",
647
+ gridSize: 24,
648
+ node: {
649
+ background: "#1e1e1e",
650
+ border: "#2e2e2e",
651
+ borderHover: "#3f3f46",
652
+ borderSelected: "#60a5fa",
653
+ title: "#f9fafb",
654
+ subtitle: "#6b7280",
655
+ shadow: "0 8px 24px rgba(0, 0, 0, 0.35)",
656
+ radius: 6,
657
+ handle: "#9ca3af",
658
+ handleActive: "#2563eb",
659
+ badgeBackground: "#2563eb",
660
+ badgeText: "#eff6ff",
661
+ statusSuccess: "#22c55e",
662
+ statusError: "#ef4444",
663
+ statusIdle: "#9ca3af"
664
+ },
665
+ edge: {
666
+ color: "#6b7280",
667
+ hoverColor: "#9ca3af",
668
+ selectedColor: "#93c5fd",
669
+ width: 2,
670
+ hoverWidth: 2.75,
671
+ selectedWidth: 3,
672
+ hitWidth: 32,
673
+ draftColor: "#60a5fa",
674
+ draftWidth: 2,
675
+ draftDash: "6 4",
676
+ arrowSize: 9,
677
+ optionalDash: "5 5",
678
+ labelColor: "#9ca3af"
679
+ },
680
+ hud: { color: "#6b7280" },
681
+ dependency: {
682
+ upstreamColor: "#a78bfa",
683
+ downstreamColor: "#f59e0b",
684
+ dimOpacity: .25
685
+ }
686
+ };
687
+ var lightGraphTheme = {
688
+ background: "#f5f5f5",
689
+ gridColor: "rgba(0, 0, 0, 0.08)",
690
+ gridSize: 24,
691
+ node: {
692
+ background: "#ffffff",
693
+ border: "#e5e7eb",
694
+ borderHover: "#d1d5db",
695
+ borderSelected: "#3b82f6",
696
+ title: "#111827",
697
+ subtitle: "#6b7280",
698
+ shadow: "0 4px 16px rgba(15, 23, 42, 0.08)",
699
+ radius: 6,
700
+ handle: "#9ca3af",
701
+ handleActive: "#2563eb",
702
+ badgeBackground: "#2563eb",
703
+ badgeText: "#eff6ff",
704
+ statusSuccess: "#16a34a",
705
+ statusError: "#dc2626",
706
+ statusIdle: "#9ca3af"
707
+ },
708
+ edge: {
709
+ color: "#9ca3af",
710
+ hoverColor: "#6b7280",
711
+ selectedColor: "#3b82f6",
712
+ width: 2,
713
+ hoverWidth: 2.75,
714
+ selectedWidth: 3,
715
+ hitWidth: 32,
716
+ draftColor: "#3b82f6",
717
+ draftWidth: 2,
718
+ draftDash: "6 4",
719
+ arrowSize: 9,
720
+ optionalDash: "5 5",
721
+ labelColor: "#6b7280"
722
+ },
723
+ hud: { color: "#6b7280" },
724
+ dependency: {
725
+ upstreamColor: "#7c3aed",
726
+ downstreamColor: "#d97706",
727
+ dimOpacity: .3
728
+ }
729
+ };
730
+ /** Alias of `darkGraphTheme`, the theme used when no color mode is given. */
731
+ var defaultGraphTheme = darkGraphTheme;
732
+ var graphThemes = {
733
+ dark: darkGraphTheme,
734
+ light: lightGraphTheme
735
+ };
736
+ /** Fills a partial theme from the preset for `mode`. */
737
+ function resolveGraphTheme(partial, mode = "dark") {
738
+ const base = graphThemes[mode];
739
+ return {
740
+ background: partial?.background ?? base.background,
741
+ gridColor: partial?.gridColor ?? base.gridColor,
742
+ gridSize: partial?.gridSize ?? base.gridSize,
743
+ node: {
744
+ ...base.node,
745
+ ...partial?.node
746
+ },
747
+ edge: {
748
+ ...base.edge,
749
+ ...partial?.edge
750
+ },
751
+ hud: {
752
+ ...base.hud,
753
+ ...partial?.hud
754
+ },
755
+ dependency: {
756
+ ...base.dependency,
757
+ ...partial?.dependency
758
+ }
759
+ };
760
+ }
761
+ function themeToCssVars(theme) {
762
+ return {
763
+ ["--graph-bg"]: theme.background,
764
+ ["--graph-grid-color"]: theme.gridColor,
765
+ ["--graph-grid-size"]: `${theme.gridSize}px`,
766
+ ["--graph-node-bg"]: theme.node.background,
767
+ ["--graph-node-border"]: theme.node.border,
768
+ ["--graph-node-border-hover"]: theme.node.borderHover,
769
+ ["--graph-node-border-selected"]: theme.node.borderSelected,
770
+ ["--graph-node-title"]: theme.node.title,
771
+ ["--graph-node-subtitle"]: theme.node.subtitle,
772
+ ["--graph-node-shadow"]: theme.node.shadow,
773
+ ["--graph-node-radius"]: `${theme.node.radius}px`,
774
+ ["--graph-node-handle"]: theme.node.handle,
775
+ ["--graph-node-handle-active"]: theme.node.handleActive,
776
+ ["--graph-node-badge-bg"]: theme.node.badgeBackground,
777
+ ["--graph-node-badge-text"]: theme.node.badgeText,
778
+ ["--graph-status-success"]: theme.node.statusSuccess,
779
+ ["--graph-status-error"]: theme.node.statusError,
780
+ ["--graph-status-idle"]: theme.node.statusIdle,
781
+ ["--graph-edge-color"]: theme.edge.color,
782
+ ["--graph-edge-hover-color"]: theme.edge.hoverColor,
783
+ ["--graph-edge-selected-color"]: theme.edge.selectedColor,
784
+ ["--graph-edge-width"]: String(theme.edge.width),
785
+ ["--graph-edge-hover-width"]: String(theme.edge.hoverWidth),
786
+ ["--graph-edge-selected-width"]: String(theme.edge.selectedWidth),
787
+ ["--graph-edge-draft-color"]: theme.edge.draftColor,
788
+ ["--graph-edge-draft-width"]: String(theme.edge.draftWidth),
789
+ ["--graph-edge-draft-dash"]: theme.edge.draftDash,
790
+ ["--graph-hud-color"]: theme.hud.color,
791
+ ["--graph-edge-optional-dash"]: theme.edge.optionalDash,
792
+ ["--graph-edge-label-color"]: theme.edge.labelColor,
793
+ ["--graph-dep-upstream"]: theme.dependency.upstreamColor,
794
+ ["--graph-dep-downstream"]: theme.dependency.downstreamColor,
795
+ ["--graph-dep-dim-opacity"]: String(theme.dependency.dimOpacity)
796
+ };
797
+ }
798
+ //#endregion
799
+ //#region src/graph/components/ConnectionLine.tsx
800
+ function ConnectionLine({ source, target, arrowLength }) {
801
+ const { path, arrow } = getArrowEdge(source, target, arrowLength);
802
+ return /* @__PURE__ */ jsxs("g", {
803
+ className: "graph-connection-line",
804
+ children: [/* @__PURE__ */ jsx("path", {
805
+ d: path,
806
+ className: "graph-connection-line__path"
807
+ }), arrow ? /* @__PURE__ */ jsx("path", {
808
+ d: arrow,
809
+ className: "graph-connection-line__arrow"
810
+ }) : null]
811
+ });
812
+ }
813
+ //#endregion
814
+ //#region src/graph/components/EdgeEditor.tsx
815
+ var KINDS = [{
816
+ kind: "required",
817
+ title: "Required"
818
+ }, {
819
+ kind: "optional",
820
+ title: "Optional",
821
+ dash: "3 3"
822
+ }];
823
+ function EdgeEditor({ edge, sourceName, targetName, position, autoFocus = false, onChange, onDelete, onClose }) {
824
+ const kind = edge.kind ?? "required";
825
+ const closeOnEnterOrEscape = (event) => {
826
+ if (event.key === "Enter" || event.key === "Escape") {
827
+ event.preventDefault();
828
+ onClose();
829
+ }
830
+ };
831
+ return /* @__PURE__ */ jsxs("div", {
832
+ className: "graph-edge-editor",
833
+ style: {
834
+ left: position.x,
835
+ top: position.y
836
+ },
837
+ onPointerDown: (event) => event.stopPropagation(),
838
+ children: [
839
+ /* @__PURE__ */ jsxs("div", {
840
+ className: "graph-edge-editor__row",
841
+ children: [/* @__PURE__ */ jsx("div", {
842
+ className: "graph-edge-editor__kinds",
843
+ role: "radiogroup",
844
+ "aria-label": "Line style",
845
+ children: KINDS.map((option) => /* @__PURE__ */ jsxs("button", {
846
+ type: "button",
847
+ role: "radio",
848
+ "aria-checked": kind === option.kind,
849
+ className: `graph-edge-editor__kind${kind === option.kind ? " is-active" : ""}`,
850
+ onClick: () => onChange({ kind: option.kind }),
851
+ children: [/* @__PURE__ */ jsx("svg", {
852
+ width: "18",
853
+ height: "6",
854
+ viewBox: "0 0 18 6",
855
+ "aria-hidden": true,
856
+ children: /* @__PURE__ */ jsx("line", {
857
+ x1: "1",
858
+ y1: "3",
859
+ x2: "17",
860
+ y2: "3",
861
+ stroke: "currentColor",
862
+ strokeWidth: "2",
863
+ strokeDasharray: option.dash
864
+ })
865
+ }), option.title]
866
+ }, option.kind))
867
+ }), /* @__PURE__ */ jsx("button", {
868
+ type: "button",
869
+ className: "graph-edge-editor__delete",
870
+ "aria-label": "Delete connection",
871
+ title: "Delete connection",
872
+ onClick: onDelete,
873
+ children: /* @__PURE__ */ jsx("svg", {
874
+ width: "14",
875
+ height: "14",
876
+ viewBox: "0 0 16 16",
877
+ "aria-hidden": true,
878
+ children: /* @__PURE__ */ jsx("path", {
879
+ d: "M3 4.5h10M6.5 4.5V3h3v1.5M5 4.5l.6 8.5h4.8l.6-8.5",
880
+ fill: "none",
881
+ stroke: "currentColor",
882
+ strokeWidth: "1.4",
883
+ strokeLinecap: "round",
884
+ strokeLinejoin: "round"
885
+ })
886
+ })
887
+ })]
888
+ }),
889
+ /* @__PURE__ */ jsxs("label", {
890
+ className: "graph-edge-editor__field",
891
+ children: [/* @__PURE__ */ jsx("span", { children: "Label" }), /* @__PURE__ */ jsx("input", {
892
+ type: "text",
893
+ placeholder: "Text on the line",
894
+ maxLength: 40,
895
+ value: edge.label ?? "",
896
+ autoFocus,
897
+ onChange: (event) => onChange({ label: event.target.value }),
898
+ onKeyDown: closeOnEnterOrEscape
899
+ })]
900
+ }),
901
+ /* @__PURE__ */ jsxs("fieldset", {
902
+ className: "graph-edge-editor__relations",
903
+ children: [
904
+ /* @__PURE__ */ jsx("legend", { children: "Relation" }),
905
+ /* @__PURE__ */ jsxs("label", {
906
+ className: "graph-edge-editor__relation",
907
+ title: `${sourceName}'s relation — shown on it while ${targetName} is hovered`,
908
+ children: [/* @__PURE__ */ jsx("span", { children: sourceName }), /* @__PURE__ */ jsx("input", {
909
+ type: "text",
910
+ placeholder: "e.g. hosts",
911
+ maxLength: 40,
912
+ value: edge.inverseRelation ?? "",
913
+ onChange: (event) => onChange({ inverseRelation: event.target.value }),
914
+ onKeyDown: closeOnEnterOrEscape
915
+ })]
916
+ }),
917
+ /* @__PURE__ */ jsx("svg", {
918
+ className: "graph-edge-editor__arrow",
919
+ width: "16",
920
+ height: "10",
921
+ viewBox: "0 0 16 10",
922
+ "aria-hidden": true,
923
+ children: /* @__PURE__ */ jsx("path", {
924
+ d: "M1 5h13M10 1l4 4-4 4",
925
+ fill: "none",
926
+ stroke: "currentColor",
927
+ strokeWidth: "1.5",
928
+ strokeLinecap: "round",
929
+ strokeLinejoin: "round"
930
+ })
931
+ }),
932
+ /* @__PURE__ */ jsxs("label", {
933
+ className: "graph-edge-editor__relation",
934
+ title: `${targetName}'s relation — shown on it while ${sourceName} is hovered`,
935
+ children: [/* @__PURE__ */ jsx("span", { children: targetName }), /* @__PURE__ */ jsx("input", {
936
+ type: "text",
937
+ placeholder: "e.g. runs on",
938
+ maxLength: 40,
939
+ value: edge.relation ?? "",
940
+ onChange: (event) => onChange({ relation: event.target.value }),
941
+ onKeyDown: closeOnEnterOrEscape
942
+ })]
943
+ })
944
+ ]
945
+ })
946
+ ]
947
+ });
948
+ }
949
+ //#endregion
950
+ //#region src/graph/components/GraphEdge.tsx
951
+ function GraphEdge({ id, source, target, kind = "required", label, selected, editable = true, role, dimmed = false, pending = false, zoom, hitWidthPx = 32, arrowSizePx = 9, onSelect }) {
952
+ const scale = 1 / Math.max(zoom, .01);
953
+ const { path, arrow } = getArrowEdge(source, target, arrowSizePx * scale);
954
+ const hitWidth = hitWidthPx * scale;
955
+ const mid = label ? getBezierMidpoint(source, target) : null;
956
+ return /* @__PURE__ */ jsxs("g", {
957
+ className: [
958
+ "graph-edge",
959
+ `graph-edge--${kind}`,
960
+ selected && "is-selected",
961
+ role && `is-${role}`,
962
+ dimmed && "is-dimmed",
963
+ pending && "is-pending"
964
+ ].filter(Boolean).join(" "),
965
+ "data-edge-id": id,
966
+ onPointerDown: editable ? (event) => {
967
+ event.stopPropagation();
968
+ onSelect(id);
969
+ } : void 0,
970
+ children: [
971
+ /* @__PURE__ */ jsx("path", {
972
+ d: path,
973
+ className: "graph-edge__hit",
974
+ strokeWidth: hitWidth
975
+ }),
976
+ /* @__PURE__ */ jsx("path", {
977
+ d: path,
978
+ className: "graph-edge__path"
979
+ }),
980
+ arrow ? /* @__PURE__ */ jsx("path", {
981
+ d: arrow,
982
+ className: "graph-edge__arrow"
983
+ }) : null,
984
+ mid ? /* @__PURE__ */ jsx("text", {
985
+ x: mid.x,
986
+ y: mid.y,
987
+ dy: -8,
988
+ className: "graph-edge__label",
989
+ children: label
990
+ }) : null
991
+ ]
992
+ });
993
+ }
994
+ //#endregion
995
+ //#region src/graph/components/GraphNode.tsx
996
+ /** Pointer travel (screen px) beyond which a press counts as a drag, not a click. */
997
+ var CLICK_SLOP = 4;
998
+ /** Clicks on these never toggle the details panel. */
999
+ var NO_TOGGLE_SELECTOR = "[data-handle], .graph-node__details, button, a, input, select, textarea, label, [data-no-toggle]";
1000
+ /** The built-in card reads `NodeData`; with custom data any field may be missing. */
1001
+ function asNodeData(data) {
1002
+ return data && typeof data === "object" ? data : {};
1003
+ }
1004
+ function DefaultDetails({ data }) {
1005
+ return /* @__PURE__ */ jsx("dl", {
1006
+ className: "graph-node__detail-list",
1007
+ children: (data.details ?? []).map((row) => /* @__PURE__ */ jsxs("div", {
1008
+ className: "graph-node__detail-row",
1009
+ children: [/* @__PURE__ */ jsx("dt", { children: row.label }), /* @__PURE__ */ jsx("dd", { children: row.value })]
1010
+ }, row.label))
1011
+ });
1012
+ }
1013
+ function DefaultCard({ title, data, ctx, expandable, detailsId }) {
1014
+ const { role, relation, expanded } = ctx;
1015
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
1016
+ /* @__PURE__ */ jsxs("div", {
1017
+ className: "graph-node__header",
1018
+ children: [/* @__PURE__ */ jsxs("div", {
1019
+ className: "graph-node__header-row",
1020
+ children: [/* @__PURE__ */ jsx(StatusIcon, { status: data.status ?? "idle" }), /* @__PURE__ */ jsxs("div", {
1021
+ className: "graph-node__header-end",
1022
+ children: [relation && (role === "upstream" || role === "downstream") ? /* @__PURE__ */ jsx("span", {
1023
+ className: `graph-node__role graph-node__role--${role}`,
1024
+ title: relation,
1025
+ children: relation
1026
+ }) : null, expandable ? /* @__PURE__ */ jsx("button", {
1027
+ type: "button",
1028
+ className: "graph-node__toggle",
1029
+ "aria-expanded": expanded,
1030
+ "aria-controls": detailsId,
1031
+ "aria-label": expanded ? `Hide ${title} details` : `Show ${title} details`,
1032
+ onClick: ctx.toggleExpanded,
1033
+ children: /* @__PURE__ */ jsx("svg", {
1034
+ viewBox: "0 0 16 16",
1035
+ width: "14",
1036
+ height: "14",
1037
+ "aria-hidden": true,
1038
+ children: /* @__PURE__ */ jsx("path", {
1039
+ d: "M4 6l4 4 4-4",
1040
+ fill: "none",
1041
+ stroke: "currentColor",
1042
+ strokeWidth: "1.6",
1043
+ strokeLinecap: "round",
1044
+ strokeLinejoin: "round"
1045
+ })
1046
+ })
1047
+ }) : null]
1048
+ })]
1049
+ }), data.stats ? /* @__PURE__ */ jsx("p", {
1050
+ className: "graph-node__stats",
1051
+ children: data.stats
1052
+ }) : null]
1053
+ }),
1054
+ /* @__PURE__ */ jsx("h3", {
1055
+ className: "graph-node__title",
1056
+ children: title
1057
+ }),
1058
+ data.subtitle ? /* @__PURE__ */ jsx("p", {
1059
+ className: "graph-node__subtitle",
1060
+ children: data.subtitle
1061
+ }) : null,
1062
+ data.badge ? /* @__PURE__ */ jsx("div", {
1063
+ className: "graph-node__footer",
1064
+ children: /* @__PURE__ */ jsx("span", {
1065
+ className: "graph-node__badge",
1066
+ children: data.badge
1067
+ })
1068
+ }) : null
1069
+ ] });
1070
+ }
1071
+ function StatusIcon({ status }) {
1072
+ if (status === "success") return /* @__PURE__ */ jsx("span", {
1073
+ className: "graph-node__status graph-node__status--success",
1074
+ "aria-hidden": true,
1075
+ children: /* @__PURE__ */ jsxs("svg", {
1076
+ viewBox: "0 0 16 16",
1077
+ width: "14",
1078
+ height: "14",
1079
+ children: [/* @__PURE__ */ jsx("circle", {
1080
+ cx: "8",
1081
+ cy: "8",
1082
+ r: "7",
1083
+ fill: "currentColor"
1084
+ }), /* @__PURE__ */ jsx("path", {
1085
+ d: "M4.8 8.2l2.1 2.1 4.3-4.4",
1086
+ fill: "none",
1087
+ stroke: "#0d1a12",
1088
+ strokeWidth: "1.6",
1089
+ strokeLinecap: "round",
1090
+ strokeLinejoin: "round"
1091
+ })]
1092
+ })
1093
+ });
1094
+ if (status === "error") return /* @__PURE__ */ jsx("span", {
1095
+ className: "graph-node__status graph-node__status--error",
1096
+ "aria-hidden": true,
1097
+ children: /* @__PURE__ */ jsxs("svg", {
1098
+ viewBox: "0 0 16 16",
1099
+ width: "14",
1100
+ height: "14",
1101
+ children: [/* @__PURE__ */ jsx("circle", {
1102
+ cx: "8",
1103
+ cy: "8",
1104
+ r: "7",
1105
+ fill: "currentColor"
1106
+ }), /* @__PURE__ */ jsx("path", {
1107
+ d: "M8 4.5v4.2M8 11.2h.01",
1108
+ fill: "none",
1109
+ stroke: "#1a0d0d",
1110
+ strokeWidth: "1.8",
1111
+ strokeLinecap: "round"
1112
+ })]
1113
+ })
1114
+ });
1115
+ return /* @__PURE__ */ jsx("span", {
1116
+ className: "graph-node__status graph-node__status--idle",
1117
+ "aria-hidden": true,
1118
+ children: /* @__PURE__ */ jsxs("svg", {
1119
+ viewBox: "0 0 16 16",
1120
+ width: "14",
1121
+ height: "14",
1122
+ children: [/* @__PURE__ */ jsx("circle", {
1123
+ cx: "8",
1124
+ cy: "8",
1125
+ r: "6.5",
1126
+ fill: "none",
1127
+ stroke: "currentColor",
1128
+ strokeWidth: "1.5"
1129
+ }), /* @__PURE__ */ jsx("circle", {
1130
+ cx: "8",
1131
+ cy: "8",
1132
+ r: "2",
1133
+ fill: "currentColor"
1134
+ })]
1135
+ })
1136
+ });
1137
+ }
1138
+ function GraphNode({ node, selected, editable, role, relation, dimmed = false, onSelect, onHoverChange, onDragStart, onSourceHandleDown, onSizeChange, expanded, onToggleExpanded, renderNode, renderDetails }) {
1139
+ const data = asNodeData(node.data);
1140
+ const title = data.title ?? node.id;
1141
+ const ref = useRef(null);
1142
+ const pressStart = useRef(null);
1143
+ const hasDetails = Boolean(renderDetails) || (data.details?.length ?? 0) > 0;
1144
+ const detailsId = `graph-node-details-${node.id}`;
1145
+ const ctx = {
1146
+ editable,
1147
+ selected,
1148
+ role,
1149
+ relation,
1150
+ dimmed,
1151
+ expanded: !editable && expanded,
1152
+ toggleExpanded: () => onToggleExpanded(node.id)
1153
+ };
1154
+ const customContent = renderNode?.(node, ctx);
1155
+ const isCustom = customContent !== void 0;
1156
+ const expandable = !editable && (isCustom || hasDetails);
1157
+ const statusClass = data.status === "success" ? "is-success" : data.status === "error" ? "is-error" : "is-idle";
1158
+ useEffect(() => {
1159
+ const el = ref.current;
1160
+ if (!el) return;
1161
+ const report = () => {
1162
+ const width = Math.ceil(el.offsetWidth);
1163
+ const height = Math.ceil(el.offsetHeight);
1164
+ if (width > 0 && height > 0) onSizeChange(node.id, {
1165
+ width,
1166
+ height
1167
+ });
1168
+ };
1169
+ report();
1170
+ const observer = new ResizeObserver(report);
1171
+ observer.observe(el);
1172
+ return () => observer.disconnect();
1173
+ }, [node.id, onSizeChange]);
1174
+ return /* @__PURE__ */ jsxs("div", {
1175
+ ref,
1176
+ className: [
1177
+ "graph-node",
1178
+ isCustom ? "graph-node--custom" : statusClass,
1179
+ selected && "is-selected",
1180
+ role && role !== "focus" && `is-${role}`,
1181
+ dimmed && "is-dimmed",
1182
+ ctx.expanded && "is-expanded",
1183
+ expandable && "is-expandable"
1184
+ ].filter(Boolean).join(" "),
1185
+ style: {
1186
+ left: node.position.x,
1187
+ top: node.position.y,
1188
+ width: node.width
1189
+ },
1190
+ "data-node-id": node.id,
1191
+ onPointerEnter: () => onHoverChange(node.id, true),
1192
+ onPointerLeave: () => onHoverChange(node.id, false),
1193
+ onPointerDown: (event) => {
1194
+ if (event.target.closest("[data-handle]")) return;
1195
+ event.stopPropagation();
1196
+ pressStart.current = {
1197
+ x: event.clientX,
1198
+ y: event.clientY
1199
+ };
1200
+ if (!editable) return;
1201
+ onSelect(node.id);
1202
+ onDragStart(node.id, event);
1203
+ },
1204
+ onClick: (event) => {
1205
+ const start = pressStart.current;
1206
+ pressStart.current = null;
1207
+ if (!expandable || !start) return;
1208
+ if (event.target.closest(NO_TOGGLE_SELECTOR)) return;
1209
+ if (Math.hypot(event.clientX - start.x, event.clientY - start.y) > CLICK_SLOP) return;
1210
+ onToggleExpanded(node.id);
1211
+ },
1212
+ children: [
1213
+ /* @__PURE__ */ jsx("button", {
1214
+ type: "button",
1215
+ className: "graph-node__handle graph-node__handle--target",
1216
+ "data-handle": "target",
1217
+ "data-node-id": node.id,
1218
+ "aria-label": `Connect to ${title}`,
1219
+ "aria-hidden": !editable || void 0,
1220
+ tabIndex: editable ? void 0 : -1,
1221
+ onPointerDown: (event) => event.stopPropagation()
1222
+ }),
1223
+ isCustom ? customContent : /* @__PURE__ */ jsx(DefaultCard, {
1224
+ title,
1225
+ data,
1226
+ ctx,
1227
+ expandable,
1228
+ detailsId
1229
+ }),
1230
+ ctx.expanded && hasDetails ? /* @__PURE__ */ jsx("div", {
1231
+ id: detailsId,
1232
+ className: "graph-node__details",
1233
+ children: renderDetails ? renderDetails(node) : /* @__PURE__ */ jsx(DefaultDetails, { data })
1234
+ }) : null,
1235
+ /* @__PURE__ */ jsx("button", {
1236
+ type: "button",
1237
+ className: "graph-node__handle graph-node__handle--source",
1238
+ "data-handle": "source",
1239
+ "data-node-id": node.id,
1240
+ "aria-label": `Connect from ${title}`,
1241
+ "aria-hidden": !editable || void 0,
1242
+ tabIndex: editable ? void 0 : -1,
1243
+ onPointerDown: (event) => {
1244
+ if (editable) onSourceHandleDown(node.id, event);
1245
+ }
1246
+ })
1247
+ ]
1248
+ });
1249
+ }
1250
+ //#endregion
1251
+ //#region src/graph/components/GraphCanvas.tsx
1252
+ function GraphCanvas({ initialState, editable = false, theme, colorMode = "dark", allowCycles = false, onConnect, onEdgeUpdate, onEdgeDelete, onNodeMove, renderNodeDetails, renderNode }) {
1253
+ const [state, dispatch] = useReducer(graphReducer, initialState);
1254
+ const { savingEdgeIds, deleteSelection, reportNodeMove } = useGraphPersistence({
1255
+ state,
1256
+ dispatch,
1257
+ onConnect,
1258
+ onEdgeUpdate,
1259
+ onEdgeDelete,
1260
+ onNodeMove
1261
+ });
1262
+ const systemColorMode = useSystemColorMode();
1263
+ const resolvedColorMode = colorMode === "system" ? systemColorMode : colorMode;
1264
+ const resolvedTheme = useMemo(() => resolveGraphTheme(theme, resolvedColorMode), [theme, resolvedColorMode]);
1265
+ const themeStyle = useMemo(() => themeToCssVars(resolvedTheme), [resolvedTheme]);
1266
+ const nodes = useMemo(() => getNodes(state), [state]);
1267
+ const edges = useMemo(() => getEdges(state), [state]);
1268
+ const nodesRef = useRef(state.nodes);
1269
+ nodesRef.current = state.nodes;
1270
+ const onViewportChange = useCallback((viewport) => {
1271
+ dispatch({
1272
+ type: "setViewport",
1273
+ viewport
1274
+ });
1275
+ }, []);
1276
+ const { containerRef, isPanning, clientToWorld, startPan, movePan, endPan } = useViewport({
1277
+ viewport: state.viewport,
1278
+ onViewportChange
1279
+ });
1280
+ const clientToWorldRef = useRef(clientToWorld);
1281
+ clientToWorldRef.current = clientToWorld;
1282
+ const connection = useConnectionDrag({
1283
+ clientToWorld,
1284
+ onStart: (sourceId, pointer) => {
1285
+ dispatch({
1286
+ type: "startConnection",
1287
+ sourceId,
1288
+ pointer
1289
+ });
1290
+ },
1291
+ onMove: (pointer) => {
1292
+ dispatch({
1293
+ type: "updateConnection",
1294
+ pointer
1295
+ });
1296
+ },
1297
+ onComplete: (targetId) => {
1298
+ dispatch({
1299
+ type: "completeConnection",
1300
+ targetId,
1301
+ allowCycles
1302
+ });
1303
+ },
1304
+ onCancel: () => {
1305
+ dispatch({ type: "cancelConnection" });
1306
+ }
1307
+ });
1308
+ const nodeDrag = useRef(null);
1309
+ const [hoveredNodeId, setHoveredNodeId] = useState(null);
1310
+ const [expandedNodeIds, setExpandedNodeIds] = useState(/* @__PURE__ */ new Set());
1311
+ const onToggleNodeExpanded = useCallback((id) => {
1312
+ setExpandedNodeIds((current) => {
1313
+ const next = new Set(current);
1314
+ if (!next.delete(id)) next.add(id);
1315
+ return next;
1316
+ });
1317
+ }, []);
1318
+ const onNodeDragStart = useCallback((id, event) => {
1319
+ if (event.button !== 0) return;
1320
+ const node = nodesRef.current[id];
1321
+ if (!node) return;
1322
+ const startPosition = node.position;
1323
+ const world = clientToWorldRef.current(event.clientX, event.clientY);
1324
+ nodeDrag.current = {
1325
+ id,
1326
+ pointerId: event.pointerId,
1327
+ offset: {
1328
+ x: world.x - node.position.x,
1329
+ y: world.y - node.position.y
1330
+ }
1331
+ };
1332
+ const onWindowMove = (e) => {
1333
+ const drag = nodeDrag.current;
1334
+ if (!drag || drag.pointerId !== e.pointerId) return;
1335
+ const next = clientToWorldRef.current(e.clientX, e.clientY);
1336
+ dispatch({
1337
+ type: "moveNode",
1338
+ id: drag.id,
1339
+ position: {
1340
+ x: next.x - drag.offset.x,
1341
+ y: next.y - drag.offset.y
1342
+ }
1343
+ });
1344
+ };
1345
+ const onWindowUp = (e) => {
1346
+ const drag = nodeDrag.current;
1347
+ if (!drag || drag.pointerId !== e.pointerId) return;
1348
+ nodeDrag.current = null;
1349
+ reportNodeMove(drag.id, startPosition);
1350
+ const hoverEl = document.elementFromPoint(e.clientX, e.clientY)?.closest(".graph-node");
1351
+ setHoveredNodeId(hoverEl?.dataset.nodeId ?? null);
1352
+ window.removeEventListener("pointermove", onWindowMove);
1353
+ window.removeEventListener("pointerup", onWindowUp);
1354
+ window.removeEventListener("pointercancel", onWindowUp);
1355
+ };
1356
+ window.addEventListener("pointermove", onWindowMove);
1357
+ window.addEventListener("pointerup", onWindowUp);
1358
+ window.addEventListener("pointercancel", onWindowUp);
1359
+ }, [reportNodeMove]);
1360
+ const onNodeSizeChange = useCallback((id, size) => {
1361
+ dispatch({
1362
+ type: "resizeNode",
1363
+ id,
1364
+ width: size.width,
1365
+ height: size.height
1366
+ });
1367
+ }, []);
1368
+ useEffect(() => {
1369
+ const onKeyDown = (event) => {
1370
+ const target = event.target;
1371
+ if (target && (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable)) return;
1372
+ if (event.key === "Escape") {
1373
+ dispatch({ type: "cancelConnection" });
1374
+ dispatch({
1375
+ type: "setSelection",
1376
+ selection: null
1377
+ });
1378
+ return;
1379
+ }
1380
+ if (event.key === "Delete" || event.key === "Backspace") {
1381
+ if (editable && state.selection) {
1382
+ event.preventDefault();
1383
+ deleteSelection();
1384
+ }
1385
+ }
1386
+ };
1387
+ window.addEventListener("keydown", onKeyDown);
1388
+ return () => window.removeEventListener("keydown", onKeyDown);
1389
+ }, [
1390
+ editable,
1391
+ state.selection,
1392
+ deleteSelection
1393
+ ]);
1394
+ useEffect(() => {
1395
+ if (editable) {
1396
+ setExpandedNodeIds(/* @__PURE__ */ new Set());
1397
+ return;
1398
+ }
1399
+ dispatch({ type: "cancelConnection" });
1400
+ dispatch({
1401
+ type: "setSelection",
1402
+ selection: null
1403
+ });
1404
+ }, [editable]);
1405
+ const draftSourcePoint = useMemo(() => {
1406
+ if (!state.connectionDraft) return null;
1407
+ const node = state.nodes[state.connectionDraft.sourceId];
1408
+ if (!node) return null;
1409
+ return getHandlePosition(node, "right");
1410
+ }, [state.connectionDraft, state.nodes]);
1411
+ const onNodeHoverChange = useCallback((id, hovered) => {
1412
+ if (nodeDrag.current) return;
1413
+ setHoveredNodeId((current) => hovered ? id : current === id ? null : current);
1414
+ }, []);
1415
+ /** Dependency chain through the hovered node. Suppressed while panning or connecting. */
1416
+ const highlight = useMemo(() => {
1417
+ if (!hoveredNodeId || !state.nodes[hoveredNodeId]) return null;
1418
+ if (isPanning || state.connectionDraft) return null;
1419
+ return getDependencyHighlight(edges, hoveredNodeId);
1420
+ }, [
1421
+ edges,
1422
+ hoveredNodeId,
1423
+ state.nodes,
1424
+ isPanning,
1425
+ state.connectionDraft
1426
+ ]);
1427
+ /** While connecting: nodes the draft cannot end on (self, duplicate, or a cycle). */
1428
+ const invalidTargets = useMemo(() => {
1429
+ const draft = state.connectionDraft;
1430
+ if (!draft) return null;
1431
+ const invalid = /* @__PURE__ */ new Set([draft.sourceId]);
1432
+ for (const edge of edges) if (edge.source === draft.sourceId) invalid.add(edge.target);
1433
+ if (!allowCycles) for (const id of getUpstream(edges, draft.sourceId)) invalid.add(id);
1434
+ return invalid;
1435
+ }, [
1436
+ state.connectionDraft,
1437
+ edges,
1438
+ allowCycles
1439
+ ]);
1440
+ const { x, y, zoom } = state.viewport;
1441
+ const editingEdge = state.selection?.type === "edge" && !state.connectionDraft ? state.edges[state.selection.id] : void 0;
1442
+ /** Below the edge midpoint, in canvas-relative screen px. */
1443
+ const editorPosition = useMemo(() => {
1444
+ if (!editingEdge) return null;
1445
+ const sourceNode = state.nodes[editingEdge.source];
1446
+ const targetNode = state.nodes[editingEdge.target];
1447
+ if (!sourceNode || !targetNode) return null;
1448
+ const mid = getBezierMidpoint(getHandlePosition(sourceNode, "right"), getHandlePosition(targetNode, "left"));
1449
+ return {
1450
+ x: x + mid.x * zoom,
1451
+ y: y + mid.y * zoom + 14
1452
+ };
1453
+ }, [
1454
+ editingEdge,
1455
+ state.nodes,
1456
+ x,
1457
+ y,
1458
+ zoom
1459
+ ]);
1460
+ const transform = `translate(${x}px, ${y}px) scale(${zoom})`;
1461
+ const onCanvasPointerDown = (event) => {
1462
+ if (event.button !== 0) return;
1463
+ const t = event.target;
1464
+ if (t.closest(".graph-node") || t.closest(".graph-edge") || t.closest("[data-handle]")) return;
1465
+ dispatch({
1466
+ type: "setSelection",
1467
+ selection: null
1468
+ });
1469
+ startPan(event);
1470
+ };
1471
+ return /* @__PURE__ */ jsxs("div", {
1472
+ ref: containerRef,
1473
+ className: `graph-canvas${editable ? "" : " is-readonly"}${isPanning ? " is-panning" : ""}${state.connectionDraft ? " is-connecting" : ""}`,
1474
+ style: themeStyle,
1475
+ "data-color-mode": resolvedColorMode,
1476
+ onPointerDown: onCanvasPointerDown,
1477
+ onPointerMove: movePan,
1478
+ onPointerUp: endPan,
1479
+ onPointerCancel: endPan,
1480
+ children: [
1481
+ /* @__PURE__ */ jsxs("div", {
1482
+ className: "graph-world",
1483
+ style: { transform },
1484
+ children: [/* @__PURE__ */ jsxs("svg", {
1485
+ className: "graph-edges",
1486
+ "aria-hidden": true,
1487
+ children: [edges.map((edge) => {
1488
+ const sourceNode = state.nodes[edge.source];
1489
+ const targetNode = state.nodes[edge.target];
1490
+ if (!sourceNode || !targetNode) return null;
1491
+ const source = getHandlePosition(sourceNode, "right");
1492
+ const target = getHandlePosition(targetNode, "left");
1493
+ return /* @__PURE__ */ jsx(GraphEdge, {
1494
+ editable,
1495
+ id: edge.id,
1496
+ source,
1497
+ target,
1498
+ kind: edge.kind,
1499
+ label: edge.label,
1500
+ role: highlight?.edges.get(edge.id),
1501
+ dimmed: highlight ? !highlight.edges.has(edge.id) : false,
1502
+ pending: savingEdgeIds.has(edge.id),
1503
+ zoom,
1504
+ hitWidthPx: resolvedTheme.edge.hitWidth,
1505
+ arrowSizePx: resolvedTheme.edge.arrowSize,
1506
+ selected: state.selection?.type === "edge" && state.selection.id === edge.id,
1507
+ onSelect: (id) => dispatch({
1508
+ type: "setSelection",
1509
+ selection: {
1510
+ type: "edge",
1511
+ id
1512
+ }
1513
+ })
1514
+ }, edge.id);
1515
+ }), draftSourcePoint && state.connectionDraft ? /* @__PURE__ */ jsx(ConnectionLine, {
1516
+ source: draftSourcePoint,
1517
+ target: state.connectionDraft.pointer,
1518
+ arrowLength: resolvedTheme.edge.arrowSize / Math.max(zoom, .01)
1519
+ }) : null]
1520
+ }), /* @__PURE__ */ jsx("div", {
1521
+ className: "graph-nodes",
1522
+ children: nodes.map((node) => /* @__PURE__ */ jsx(GraphNode, {
1523
+ editable,
1524
+ node,
1525
+ selected: state.selection?.type === "node" && state.selection.id === node.id,
1526
+ role: highlight?.nodes.get(node.id),
1527
+ relation: formatRelations(highlight?.relations.get(node.id)),
1528
+ dimmed: invalidTargets ? invalidTargets.has(node.id) : highlight ? !highlight.nodes.has(node.id) : false,
1529
+ onSelect: (id) => dispatch({
1530
+ type: "setSelection",
1531
+ selection: {
1532
+ type: "node",
1533
+ id
1534
+ }
1535
+ }),
1536
+ onHoverChange: onNodeHoverChange,
1537
+ onDragStart: onNodeDragStart,
1538
+ onSourceHandleDown: connection.startFromHandle,
1539
+ onSizeChange: onNodeSizeChange,
1540
+ expanded: expandedNodeIds.has(node.id),
1541
+ onToggleExpanded: onToggleNodeExpanded,
1542
+ renderDetails: renderNodeDetails,
1543
+ renderNode
1544
+ }, node.id))
1545
+ })]
1546
+ }),
1547
+ editable && editingEdge && editorPosition ? /* @__PURE__ */ jsx(EdgeEditor, {
1548
+ edge: editingEdge,
1549
+ sourceName: nodeName(state.nodes[editingEdge.source]),
1550
+ targetName: nodeName(state.nodes[editingEdge.target]),
1551
+ position: editorPosition,
1552
+ autoFocus: state.selection?.type === "edge" && state.selection.created,
1553
+ onChange: (patch) => dispatch({
1554
+ type: "updateEdge",
1555
+ id: editingEdge.id,
1556
+ patch
1557
+ }),
1558
+ onDelete: deleteSelection,
1559
+ onClose: () => dispatch({
1560
+ type: "setSelection",
1561
+ selection: null
1562
+ })
1563
+ }, editingEdge.id) : null,
1564
+ /* @__PURE__ */ jsxs("div", {
1565
+ className: "graph-hud",
1566
+ "aria-hidden": true,
1567
+ children: [highlight ? /* @__PURE__ */ jsxs("span", {
1568
+ className: "graph-hud__legend",
1569
+ children: [
1570
+ /* @__PURE__ */ jsx("span", { className: "graph-hud__swatch graph-hud__swatch--upstream" }),
1571
+ "Depends on (",
1572
+ countRole(highlight.nodes, "upstream"),
1573
+ ")",
1574
+ /* @__PURE__ */ jsx("span", { className: "graph-hud__swatch graph-hud__swatch--downstream" }),
1575
+ "Depended on by (",
1576
+ countRole(highlight.nodes, "downstream"),
1577
+ ")"
1578
+ ]
1579
+ }) : null, /* @__PURE__ */ jsx("span", { children: editable ? "Scroll to zoom · Drag empty space to pan · Drag right handle to connect · Click a line to edit · Del to remove" : "Scroll to zoom · Drag empty space to pan · Click a node for details · Hover a node to see its dependencies" })]
1580
+ })
1581
+ ]
1582
+ });
1583
+ }
1584
+ /**
1585
+ * Chip text for a chain node: its relation to the previous node on the chain.
1586
+ * Several routes with different relations are comma-joined, each once.
1587
+ */
1588
+ function formatRelations(list) {
1589
+ if (!list?.length) return void 0;
1590
+ return [...new Set(list.map((entry) => entry.relation))].join(", ");
1591
+ }
1592
+ /** `data.title` when the node has one (built-in card data), else its id. */
1593
+ function nodeName(node) {
1594
+ if (!node) return "";
1595
+ const title = node.data?.title;
1596
+ return typeof title === "string" && title ? title : node.id;
1597
+ }
1598
+ function countRole(nodes, role) {
1599
+ let count = 0;
1600
+ for (const value of nodes.values()) if (value === role) count++;
1601
+ return count;
1602
+ }
1603
+ //#endregion
1604
+ //#region src/graph/layout.ts
1605
+ var COLUMN_GAP = 120;
1606
+ var ROW_GAP = 48;
1607
+ var ORIGIN_X = 0;
1608
+ var ORIGIN_Y = 0;
1609
+ function layoutColumns(inputs) {
1610
+ const widths = /* @__PURE__ */ new Map();
1611
+ for (const input of inputs) {
1612
+ const w = input.width ?? 300;
1613
+ widths.set(input.column, Math.max(widths.get(input.column) ?? 0, w));
1614
+ }
1615
+ const columnX = /* @__PURE__ */ new Map();
1616
+ let x = ORIGIN_X;
1617
+ const sortedColumns = [...new Set(inputs.map((i) => i.column))].sort((a, b) => a - b);
1618
+ for (const col of sortedColumns) {
1619
+ columnX.set(col, x);
1620
+ x += (widths.get(col) ?? 300) + COLUMN_GAP;
1621
+ }
1622
+ return inputs.map((input) => {
1623
+ const width = input.width ?? 300;
1624
+ const height = input.height ?? 150;
1625
+ const position = input.position ?? {
1626
+ x: columnX.get(input.column) ?? 0,
1627
+ y: ORIGIN_Y + input.row * (height + ROW_GAP)
1628
+ };
1629
+ return {
1630
+ id: input.id,
1631
+ position,
1632
+ width,
1633
+ height,
1634
+ data: input.data
1635
+ };
1636
+ });
1637
+ }
1638
+ //#endregion
1639
+ export { GraphCanvas, createInitialState, darkGraphTheme, defaultGraphTheme, getDependencyHighlight, getDownstream, getUpstream, graphThemes, layoutColumns, lightGraphTheme, resolveGraphTheme, useColorMode, useSystemColorMode, wouldCreateCycle };