@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/LICENSE +21 -0
- package/README.md +437 -2
- package/dist/components/ConnectionLine.d.ts +9 -0
- package/dist/components/EdgeEditor.d.ts +16 -0
- package/dist/components/GraphCanvas.d.ts +42 -0
- package/dist/components/GraphEdge.d.ts +22 -0
- package/dist/components/GraphNode.d.ts +59 -0
- package/dist/dependencies.d.ts +32 -0
- package/dist/geometry.d.ts +26 -0
- package/dist/hooks/useColorMode.d.ts +21 -0
- package/dist/hooks/useConnectionDrag.d.ts +12 -0
- package/dist/hooks/useGraphPersistence.d.ts +42 -0
- package/dist/hooks/useViewport.d.ts +15 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +1639 -0
- package/dist/layout.d.ts +20 -0
- package/dist/store.d.ts +46 -0
- package/dist/style.css +628 -0
- package/dist/theme.d.ts +108 -0
- package/dist/types.d.ts +86 -0
- package/package.json +68 -4
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 };
|