@ruckc/truss-graph 0.0.0-stage → 1.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/Graph.svelte +458 -0
- package/dist/Graph.svelte.d.ts +46 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +3 -0
- package/dist/layout.d.ts +10 -0
- package/dist/layout.js +59 -0
- package/dist/routing.d.ts +25 -0
- package/dist/routing.js +242 -0
- package/dist/types.d.ts +58 -0
- package/dist/types.js +2 -0
- package/package.json +45 -5
- package/README.md +0 -3
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { onMount, type Snippet } from 'svelte';
|
|
3
|
+
import { Button } from '@ruckc/truss-ui';
|
|
4
|
+
import { autoLayout } from './layout.js';
|
|
5
|
+
import { defaultPorts, opposite, portAnchor, routeEdge, type Anchor, type Rect } from './routing.js';
|
|
6
|
+
import { NODE_H, NODE_W, type EdgeType, type GraphEdge, type GraphNode, type GraphPort, type NodeCtx, type NodeRenderer } from './types.js';
|
|
7
|
+
|
|
8
|
+
let {
|
|
9
|
+
nodes = $bindable([]),
|
|
10
|
+
edges = $bindable([]),
|
|
11
|
+
selected = $bindable([]),
|
|
12
|
+
selectedEdge = $bindable(),
|
|
13
|
+
height = 480,
|
|
14
|
+
edgeType = 'bezier',
|
|
15
|
+
direction = 'LR',
|
|
16
|
+
connectable = true,
|
|
17
|
+
editable = true,
|
|
18
|
+
fitOnMount = true,
|
|
19
|
+
minimap = false,
|
|
20
|
+
minimapPosition = 'bottom-right',
|
|
21
|
+
nodeTypes,
|
|
22
|
+
node,
|
|
23
|
+
body,
|
|
24
|
+
edgeLabel,
|
|
25
|
+
isValidConnection,
|
|
26
|
+
onconnect,
|
|
27
|
+
onchange
|
|
28
|
+
}: {
|
|
29
|
+
nodes?: GraphNode[];
|
|
30
|
+
edges?: GraphEdge[];
|
|
31
|
+
/** Selected node ids. */
|
|
32
|
+
selected?: string[];
|
|
33
|
+
selectedEdge?: string;
|
|
34
|
+
height?: number | string;
|
|
35
|
+
/** Default routing for every edge (an edge's own `type` wins): bezier, straight, step, smoothstep, or avoid (steers around nodes). */
|
|
36
|
+
edgeType?: EdgeType;
|
|
37
|
+
/** Which sides the default ports use, and the direction of auto layout. */
|
|
38
|
+
direction?: 'LR' | 'TB';
|
|
39
|
+
/** Drag from an output port to an input port to create edges. */
|
|
40
|
+
connectable?: boolean;
|
|
41
|
+
/** Allow moving and deleting. */
|
|
42
|
+
editable?: boolean;
|
|
43
|
+
fitOnMount?: boolean;
|
|
44
|
+
/** Overview panel showing every node and the current viewport; click or drag it to pan. */
|
|
45
|
+
minimap?: boolean;
|
|
46
|
+
minimapPosition?: 'bottom-right' | 'bottom-left';
|
|
47
|
+
/** Custom node components by `node.type`. Each is a snippet receiving `{ node, selected, dragging, update }` and draws the whole node. */
|
|
48
|
+
nodeTypes?: Record<string, NodeRenderer>;
|
|
49
|
+
/** Custom component for every node that has no matching `nodeTypes` entry. */
|
|
50
|
+
node?: NodeRenderer;
|
|
51
|
+
/** Content inside the default card, under the title. */
|
|
52
|
+
body?: Snippet<[node: GraphNode]>;
|
|
53
|
+
/** Custom component rendered at the middle of each edge (replaces `edge.label`). */
|
|
54
|
+
edgeLabel?: Snippet<[edge: GraphEdge]>;
|
|
55
|
+
/** Return false to reject a connection. */
|
|
56
|
+
isValidConnection?: (from: { node: string; port: string }, to: { node: string; port: string }) => boolean;
|
|
57
|
+
onconnect?: (edge: GraphEdge) => void;
|
|
58
|
+
onchange?: () => void;
|
|
59
|
+
} = $props();
|
|
60
|
+
|
|
61
|
+
let root: HTMLDivElement;
|
|
62
|
+
let tx = $state(0), ty = $state(0), k = $state(1);
|
|
63
|
+
let drag = $state<{ ids: string[]; dx: number; dy: number } | null>(null);
|
|
64
|
+
let link = $state<{ from: string; port: string; x: number; y: number; over?: { node: string; port: string } } | null>(null);
|
|
65
|
+
let panning = $state(false);
|
|
66
|
+
let rw = $state(0), rh = $state(0);
|
|
67
|
+
|
|
68
|
+
// ---- minimap geometry: world bounds = nodes ∪ viewport (so the viewport box is always visible)
|
|
69
|
+
const MW = 184, MH = 124;
|
|
70
|
+
const mm = $derived.by(() => {
|
|
71
|
+
if (!minimap || !rw) return null;
|
|
72
|
+
const vx = -tx / k, vy = -ty / k, vw = rw / k, vh = rh / k;
|
|
73
|
+
let x0 = vx, y0 = vy, x1 = vx + vw, y1 = vy + vh;
|
|
74
|
+
for (const n of nodes) { x0 = Math.min(x0, n.x); y0 = Math.min(y0, n.y); x1 = Math.max(x1, n.x + W(n)); y1 = Math.max(y1, n.y + H(n)); }
|
|
75
|
+
const pad = 24; x0 -= pad; y0 -= pad; x1 += pad; y1 += pad;
|
|
76
|
+
const sc = Math.min(MW / (x1 - x0), MH / (y1 - y0));
|
|
77
|
+
return { x0, y0, sc, w: (x1 - x0) * sc, h: (y1 - y0) * sc, view: { x: (vx - x0) * sc, y: (vy - y0) * sc, w: vw * sc, h: vh * sc } };
|
|
78
|
+
});
|
|
79
|
+
function mmPan(e: PointerEvent) {
|
|
80
|
+
if (!mm) return;
|
|
81
|
+
const svg = e.currentTarget as SVGSVGElement;
|
|
82
|
+
svg.setPointerCapture(e.pointerId);
|
|
83
|
+
const to = (ev: PointerEvent) => {
|
|
84
|
+
const r = svg.getBoundingClientRect();
|
|
85
|
+
const gx = mm.x0 + (ev.clientX - r.left) / mm.sc, gy = mm.y0 + (ev.clientY - r.top) / mm.sc;
|
|
86
|
+
tx = rw / 2 - gx * k;
|
|
87
|
+
ty = rh / 2 - gy * k;
|
|
88
|
+
};
|
|
89
|
+
to(e);
|
|
90
|
+
const up = () => { svg.removeEventListener('pointermove', to); svg.removeEventListener('pointerup', up); };
|
|
91
|
+
svg.addEventListener('pointermove', to);
|
|
92
|
+
svg.addEventListener('pointerup', up);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const W = (n: GraphNode) => n.width ?? NODE_W;
|
|
96
|
+
const H = (n: GraphNode) => n.height ?? NODE_H;
|
|
97
|
+
const portsOf = (n: GraphNode): GraphPort[] => n.ports ?? defaultPorts(n, direction);
|
|
98
|
+
const shown = $derived(nodes.map((n) => (drag?.ids.includes(n.id) ? { ...n, x: n.x + drag.dx, y: n.y + drag.dy } : n)));
|
|
99
|
+
const byId = $derived(new Map(shown.map((n) => [n.id, n])));
|
|
100
|
+
const rects = $derived<Rect[]>(shown.map((n) => ({ id: n.id, x: n.x, y: n.y, w: W(n), h: H(n) })));
|
|
101
|
+
const needsObstacles = $derived(edgeType === 'avoid' || edges.some((e) => e.type === 'avoid'));
|
|
102
|
+
|
|
103
|
+
const findPort = (n: GraphNode, id: string | undefined, kind: 'in' | 'out') => {
|
|
104
|
+
const ps = portsOf(n);
|
|
105
|
+
return ps.find((p) => p.kind === kind && (id == null || p.id === id)) ?? ps.find((p) => p.kind === kind);
|
|
106
|
+
};
|
|
107
|
+
const geo = $derived(
|
|
108
|
+
edges.flatMap((e) => {
|
|
109
|
+
const a = byId.get(e.from), b = byId.get(e.to);
|
|
110
|
+
if (!a || !b) return [];
|
|
111
|
+
const pa = findPort(a, e.fromPort, 'out'), pb = findPort(b, e.toPort, 'in');
|
|
112
|
+
if (!pa || !pb) return [];
|
|
113
|
+
const r = routeEdge(e.type ?? edgeType, portAnchor(a, pa, portsOf(a)), portAnchor(b, pb, portsOf(b)), needsObstacles ? rects : []);
|
|
114
|
+
return [{ e, d: r.d, m: r.mid }];
|
|
115
|
+
})
|
|
116
|
+
);
|
|
117
|
+
const toGraph = (cx: number, cy: number) => {
|
|
118
|
+
const r = root.getBoundingClientRect();
|
|
119
|
+
return { x: (cx - r.left - tx) / k, y: (cy - r.top - ty) / k };
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
export function fit(padding = 48) {
|
|
123
|
+
if (!nodes.length || !root) return;
|
|
124
|
+
const x0 = Math.min(...nodes.map((n) => n.x)), y0 = Math.min(...nodes.map((n) => n.y));
|
|
125
|
+
const x1 = Math.max(...nodes.map((n) => n.x + W(n))), y1 = Math.max(...nodes.map((n) => n.y + H(n)));
|
|
126
|
+
const { width, height: hh } = root.getBoundingClientRect();
|
|
127
|
+
k = Math.min(1.5, Math.max(0.2, Math.min((width - padding * 2) / (x1 - x0), (hh - padding * 2) / (y1 - y0))));
|
|
128
|
+
tx = (width - (x1 - x0) * k) / 2 - x0 * k;
|
|
129
|
+
ty = (hh - (y1 - y0) * k) / 2 - y0 * k;
|
|
130
|
+
}
|
|
131
|
+
export function layout() {
|
|
132
|
+
nodes = autoLayout(nodes, edges, { direction });
|
|
133
|
+
onchange?.();
|
|
134
|
+
requestAnimationFrame(() => fit());
|
|
135
|
+
}
|
|
136
|
+
function zoomAt(factor: number, cx: number, cy: number) {
|
|
137
|
+
const nk = Math.min(2.5, Math.max(0.2, k * factor));
|
|
138
|
+
tx = cx - (cx - tx) * (nk / k);
|
|
139
|
+
ty = cy - (cy - ty) * (nk / k);
|
|
140
|
+
k = nk;
|
|
141
|
+
}
|
|
142
|
+
const zoomCenter = (f: number) => zoomAt(f, root.clientWidth / 2, root.clientHeight / 2);
|
|
143
|
+
|
|
144
|
+
onMount(() => {
|
|
145
|
+
const wheel = (e: WheelEvent) => {
|
|
146
|
+
// let scrollable content inside custom nodes scroll normally
|
|
147
|
+
if ((e.target as HTMLElement).closest('.nowheel')) return;
|
|
148
|
+
e.preventDefault();
|
|
149
|
+
const r = root.getBoundingClientRect();
|
|
150
|
+
zoomAt(Math.exp(-e.deltaY * (e.ctrlKey ? 0.01 : 0.0015)), e.clientX - r.left, e.clientY - r.top);
|
|
151
|
+
};
|
|
152
|
+
root.addEventListener('wheel', wheel, { passive: false });
|
|
153
|
+
if (fitOnMount) fit();
|
|
154
|
+
return () => root.removeEventListener('wheel', wheel);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
// ---- background: pan + clear selection
|
|
158
|
+
function bgDown(e: PointerEvent) {
|
|
159
|
+
if ((e.target as HTMLElement).closest('.node, .controls, .edge-hit, .elabel, .minimap')) return;
|
|
160
|
+
selected = []; selectedEdge = undefined;
|
|
161
|
+
root.focus();
|
|
162
|
+
const sx = e.clientX, sy = e.clientY, tx0 = tx, ty0 = ty;
|
|
163
|
+
panning = true;
|
|
164
|
+
const move = (ev: PointerEvent) => { tx = tx0 + ev.clientX - sx; ty = ty0 + ev.clientY - sy; };
|
|
165
|
+
const up = () => { panning = false; window.removeEventListener('pointermove', move); window.removeEventListener('pointerup', up); };
|
|
166
|
+
window.addEventListener('pointermove', move);
|
|
167
|
+
window.addEventListener('pointerup', up);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ---- nodes: select + drag. Controls inside custom nodes opt out of dragging.
|
|
171
|
+
const INTERACTIVE = 'input, textarea, select, button, a, label, [contenteditable="true"], [role="slider"], .nodrag';
|
|
172
|
+
function nodeDown(e: PointerEvent, n: GraphNode) {
|
|
173
|
+
const t = e.target as HTMLElement;
|
|
174
|
+
if (t.closest('.port')) return;
|
|
175
|
+
e.stopPropagation();
|
|
176
|
+
selectedEdge = undefined;
|
|
177
|
+
if (e.shiftKey || e.ctrlKey || e.metaKey) selected = selected.includes(n.id) ? selected.filter((i) => i !== n.id) : [...selected, n.id];
|
|
178
|
+
else if (!selected.includes(n.id)) selected = [n.id];
|
|
179
|
+
if (!editable || t.closest(INTERACTIVE)) return;
|
|
180
|
+
const ids = [...selected], sx = e.clientX, sy = e.clientY;
|
|
181
|
+
let moved = false;
|
|
182
|
+
const move = (ev: PointerEvent) => {
|
|
183
|
+
const dx = (ev.clientX - sx) / k, dy = (ev.clientY - sy) / k;
|
|
184
|
+
if (!moved && Math.hypot(dx, dy) * k < 3) return;
|
|
185
|
+
moved = true;
|
|
186
|
+
drag = { ids, dx, dy };
|
|
187
|
+
};
|
|
188
|
+
const up = () => {
|
|
189
|
+
window.removeEventListener('pointermove', move);
|
|
190
|
+
window.removeEventListener('pointerup', up);
|
|
191
|
+
if (drag) {
|
|
192
|
+
const d = drag;
|
|
193
|
+
nodes = nodes.map((m) => (d.ids.includes(m.id) ? { ...m, x: Math.round(m.x + d.dx), y: Math.round(m.y + d.dy) } : m));
|
|
194
|
+
drag = null;
|
|
195
|
+
onchange?.();
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
window.addEventListener('pointermove', move);
|
|
199
|
+
window.addEventListener('pointerup', up);
|
|
200
|
+
}
|
|
201
|
+
function updateNode(id: string, patch: Partial<GraphNode>) {
|
|
202
|
+
nodes = nodes.map((n) => (n.id === id ? { ...n, ...patch } : n));
|
|
203
|
+
onchange?.();
|
|
204
|
+
}
|
|
205
|
+
const ctxOf = (n: GraphNode): NodeCtx => ({
|
|
206
|
+
node: n,
|
|
207
|
+
selected: selected.includes(n.id),
|
|
208
|
+
dragging: !!drag?.ids.includes(n.id),
|
|
209
|
+
update: (patch) => updateNode(n.id, patch)
|
|
210
|
+
});
|
|
211
|
+
const rendererOf = (n: GraphNode) => (n.type ? nodeTypes?.[n.type] : undefined) ?? node;
|
|
212
|
+
|
|
213
|
+
// ---- connecting
|
|
214
|
+
const valid = (from: { node: string; port: string }, to: { node: string; port: string }) =>
|
|
215
|
+
from.node !== to.node && (isValidConnection?.(from, to) ?? true);
|
|
216
|
+
function portDown(e: PointerEvent, n: GraphNode, p: GraphPort) {
|
|
217
|
+
if (!connectable || !editable) return;
|
|
218
|
+
e.stopPropagation();
|
|
219
|
+
e.preventDefault();
|
|
220
|
+
const g0 = toGraph(e.clientX, e.clientY);
|
|
221
|
+
link = { from: n.id, port: p.id, x: g0.x, y: g0.y };
|
|
222
|
+
const move = (ev: PointerEvent) => {
|
|
223
|
+
const g = toGraph(ev.clientX, ev.clientY);
|
|
224
|
+
const el = document.elementFromPoint(ev.clientX, ev.clientY)?.closest<HTMLElement>('[data-port-in]');
|
|
225
|
+
const over = el ? { node: el.dataset.portIn!, port: el.dataset.portId! } : undefined;
|
|
226
|
+
link = { from: n.id, port: p.id, x: g.x, y: g.y, over: over && valid({ node: n.id, port: p.id }, over) ? over : undefined };
|
|
227
|
+
};
|
|
228
|
+
const up = () => {
|
|
229
|
+
window.removeEventListener('pointermove', move);
|
|
230
|
+
window.removeEventListener('pointerup', up);
|
|
231
|
+
const to = link?.over;
|
|
232
|
+
if (to && !edges.some((x) => x.from === n.id && x.to === to.node && (x.fromPort ?? p.id) === p.id && (x.toPort ?? to.port) === to.port)) {
|
|
233
|
+
const edge: GraphEdge = { id: `${n.id}.${p.id}->${to.node}.${to.port}`, from: n.id, to: to.node, fromPort: p.id, toPort: to.port };
|
|
234
|
+
edges = [...edges, edge];
|
|
235
|
+
onconnect?.(edge);
|
|
236
|
+
onchange?.();
|
|
237
|
+
}
|
|
238
|
+
link = null;
|
|
239
|
+
};
|
|
240
|
+
window.addEventListener('pointermove', move);
|
|
241
|
+
window.addEventListener('pointerup', up);
|
|
242
|
+
}
|
|
243
|
+
const linkRoute = $derived.by(() => {
|
|
244
|
+
if (!link) return null;
|
|
245
|
+
const a = byId.get(link.from);
|
|
246
|
+
const pa = a && portsOf(a).find((p) => p.id === link!.port);
|
|
247
|
+
if (!a || !pa) return null;
|
|
248
|
+
const from = portAnchor(a, pa, portsOf(a));
|
|
249
|
+
const to: Anchor = { x: link.x, y: link.y, side: opposite(from.side) };
|
|
250
|
+
const t = edgeType === 'avoid' ? 'smoothstep' : edgeType;
|
|
251
|
+
return routeEdge(t, from, to).d;
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
function remove() {
|
|
255
|
+
if (!editable) return;
|
|
256
|
+
if (selectedEdge) edges = edges.filter((e) => e.id !== selectedEdge);
|
|
257
|
+
if (selected.length) {
|
|
258
|
+
edges = edges.filter((e) => !selected.includes(e.from) && !selected.includes(e.to));
|
|
259
|
+
nodes = nodes.filter((n) => !selected.includes(n.id));
|
|
260
|
+
}
|
|
261
|
+
selected = []; selectedEdge = undefined;
|
|
262
|
+
onchange?.();
|
|
263
|
+
}
|
|
264
|
+
function onKey(e: KeyboardEvent) {
|
|
265
|
+
if ((e.target as HTMLElement).closest('input, textarea, select, [contenteditable="true"]')) return; // typing inside a node
|
|
266
|
+
const step = e.shiftKey ? 32 : 8;
|
|
267
|
+
const mv = { ArrowLeft: [-step, 0], ArrowRight: [step, 0], ArrowUp: [0, -step], ArrowDown: [0, step] }[e.key];
|
|
268
|
+
if (mv && selected.length && editable) {
|
|
269
|
+
nodes = nodes.map((n) => (selected.includes(n.id) ? { ...n, x: n.x + mv[0], y: n.y + mv[1] } : n));
|
|
270
|
+
} else if (e.key === 'Delete' || e.key === 'Backspace') remove();
|
|
271
|
+
else if (e.key === '+' || e.key === '=') zoomCenter(1.2);
|
|
272
|
+
else if (e.key === '-') zoomCenter(1 / 1.2);
|
|
273
|
+
else if (e.key === '0') fit();
|
|
274
|
+
else if (e.key === 'Escape') { selected = []; selectedEdge = undefined; }
|
|
275
|
+
else return;
|
|
276
|
+
e.preventDefault();
|
|
277
|
+
}
|
|
278
|
+
</script>
|
|
279
|
+
|
|
280
|
+
<!-- svelte-ignore a11y_no_noninteractive_tabindex, a11y_no_noninteractive_element_interactions -->
|
|
281
|
+
<div
|
|
282
|
+
class="trg" bind:this={root} bind:clientWidth={rw} bind:clientHeight={rh} role="application" tabindex="0" data-panning={panning}
|
|
283
|
+
aria-label="Graph editor. Arrow keys move selected nodes, Delete removes, plus and minus zoom, 0 fits to view."
|
|
284
|
+
style:height={typeof height === 'number' ? `${height}px` : height}
|
|
285
|
+
style:--tx="{tx}px" style:--ty="{ty}px" style:--k={k}
|
|
286
|
+
onpointerdown={bgDown} onkeydown={onKey}
|
|
287
|
+
>
|
|
288
|
+
<div class="viewport" style:transform="translate({tx}px, {ty}px) scale({k})">
|
|
289
|
+
<svg class="edges" width="1" height="1">
|
|
290
|
+
<defs>
|
|
291
|
+
<marker id="trg-arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" orient="auto-start-reverse">
|
|
292
|
+
<path d="M1,1 L9,5 L1,9 z" fill="currentColor" />
|
|
293
|
+
</marker>
|
|
294
|
+
</defs>
|
|
295
|
+
{#each geo as g (g.e.id)}
|
|
296
|
+
<g class="edge" data-kind={g.e.kind ?? 'neutral'} data-selected={selectedEdge === g.e.id} data-animated={g.e.animated}>
|
|
297
|
+
<path class="line" d={g.d} marker-end="url(#trg-arrow)" />
|
|
298
|
+
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_noninteractive_element_interactions -->
|
|
299
|
+
<path class="edge-hit" d={g.d} role="img" aria-label="Edge {g.e.from} to {g.e.to}" onclick={(ev) => { ev.stopPropagation(); selected = []; selectedEdge = g.e.id; root.focus(); }} />
|
|
300
|
+
{#if edgeLabel}
|
|
301
|
+
<foreignObject x={g.m.x - 90} y={g.m.y - 16} width="180" height="32" class="elabel">
|
|
302
|
+
<div class="elabel-in">{@render edgeLabel(g.e)}</div>
|
|
303
|
+
</foreignObject>
|
|
304
|
+
{:else if g.e.label}
|
|
305
|
+
<g transform="translate({g.m.x},{g.m.y})">
|
|
306
|
+
<rect class="lbl-bg" x={-(g.e.label.length * 3.4 + 8)} y="-10" width={g.e.label.length * 6.8 + 16} height="20" rx="10" />
|
|
307
|
+
<text class="lbl" text-anchor="middle" dominant-baseline="central">{g.e.label}</text>
|
|
308
|
+
</g>
|
|
309
|
+
{/if}
|
|
310
|
+
</g>
|
|
311
|
+
{/each}
|
|
312
|
+
{#if linkRoute}<path class="line temp" d={linkRoute} />{/if}
|
|
313
|
+
</svg>
|
|
314
|
+
|
|
315
|
+
{#each shown as n (n.id)}
|
|
316
|
+
{@const render = rendererOf(n)}
|
|
317
|
+
{@const bare = !!render && n.chrome !== true}
|
|
318
|
+
{@const ports = portsOf(n)}
|
|
319
|
+
<div
|
|
320
|
+
class="node" role="group" tabindex="0" aria-label="{n.label}{n.subtitle ? `, ${n.subtitle}` : ''}"
|
|
321
|
+
data-kind={n.kind ?? 'neutral'} data-bare={bare} data-selected={selected.includes(n.id)} data-dragging={!!drag?.ids.includes(n.id)}
|
|
322
|
+
style:left="{n.x}px" style:top="{n.y}px" style:width="{W(n)}px" style:height="{H(n)}px"
|
|
323
|
+
onpointerdown={(e) => nodeDown(e, n)}
|
|
324
|
+
>
|
|
325
|
+
{#if render}
|
|
326
|
+
<div class="custom" class:fill={bare}>{@render render(ctxOf(n))}</div>
|
|
327
|
+
{:else}
|
|
328
|
+
<div class="content">
|
|
329
|
+
<div class="label">{n.label}</div>
|
|
330
|
+
{#if n.subtitle}<div class="subtitle">{n.subtitle}</div>{/if}
|
|
331
|
+
{#if body}{@render body(n)}{/if}
|
|
332
|
+
</div>
|
|
333
|
+
{/if}
|
|
334
|
+
|
|
335
|
+
{#each ports as p (p.id)}
|
|
336
|
+
{@const a = portAnchor(n, p, ports)}
|
|
337
|
+
{#if p.kind === 'in'}
|
|
338
|
+
<span class="port" data-side={p.side} data-port-in={n.id} data-port-id={p.id} data-hot={link?.over?.node === n.id && link.over.port === p.id}
|
|
339
|
+
style:left="{a.x - n.x}px" style:top="{a.y - n.y}px"></span>
|
|
340
|
+
{:else}
|
|
341
|
+
<span class="port" role="presentation" data-side={p.side} data-connectable={connectable && editable} onpointerdown={(e) => portDown(e, n, p)}
|
|
342
|
+
style:left="{a.x - n.x}px" style:top="{a.y - n.y}px"></span>
|
|
343
|
+
{/if}
|
|
344
|
+
{#if p.label}<span class="plabel" data-side={p.side} style:left="{a.x - n.x}px" style:top="{a.y - n.y}px">{p.label}</span>{/if}
|
|
345
|
+
{/each}
|
|
346
|
+
</div>
|
|
347
|
+
{/each}
|
|
348
|
+
</div>
|
|
349
|
+
|
|
350
|
+
{#if mm}
|
|
351
|
+
<div class="minimap" data-pos={minimapPosition} style:width="{MW}px" style:height="{MH}px">
|
|
352
|
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
353
|
+
<svg width={mm.w} height={mm.h} role="img" aria-label="Minimap: drag to pan the view" onpointerdown={mmPan}>
|
|
354
|
+
{#each geo as g (g.e.id)}
|
|
355
|
+
{@const a = byId.get(g.e.from)}{@const bn = byId.get(g.e.to)}
|
|
356
|
+
{#if a && bn}<line class="mme" x1={(a.x + W(a) / 2 - mm.x0) * mm.sc} y1={(a.y + H(a) / 2 - mm.y0) * mm.sc} x2={(bn.x + W(bn) / 2 - mm.x0) * mm.sc} y2={(bn.y + H(bn) / 2 - mm.y0) * mm.sc} />{/if}
|
|
357
|
+
{/each}
|
|
358
|
+
{#each shown as n (n.id)}
|
|
359
|
+
<rect class="mmn" data-kind={n.kind ?? 'neutral'} data-selected={selected.includes(n.id)} x={(n.x - mm.x0) * mm.sc} y={(n.y - mm.y0) * mm.sc} width={Math.max(2, W(n) * mm.sc)} height={Math.max(2, H(n) * mm.sc)} rx="1.5" />
|
|
360
|
+
{/each}
|
|
361
|
+
<rect class="mmv" x={mm.view.x} y={mm.view.y} width={mm.view.w} height={mm.view.h} />
|
|
362
|
+
</svg>
|
|
363
|
+
</div>
|
|
364
|
+
{/if}
|
|
365
|
+
|
|
366
|
+
<div class="controls" role="toolbar" aria-label="Graph controls">
|
|
367
|
+
<Button variant="minimal" intent="neutral" size="sm" icon="plus" aria-label="Zoom in" onclick={() => zoomCenter(1.2)} />
|
|
368
|
+
<Button variant="minimal" intent="neutral" size="sm" icon="minus" aria-label="Zoom out" onclick={() => zoomCenter(1 / 1.2)} />
|
|
369
|
+
<Button variant="minimal" intent="neutral" size="sm" icon="expand" aria-label="Fit to view" onclick={() => fit()} />
|
|
370
|
+
{#if editable}<Button variant="minimal" intent="neutral" size="sm" icon="refresh" aria-label="Auto layout" onclick={layout} />{/if}
|
|
371
|
+
</div>
|
|
372
|
+
</div>
|
|
373
|
+
|
|
374
|
+
<style>
|
|
375
|
+
.trg {
|
|
376
|
+
--grid: 24px;
|
|
377
|
+
position: relative; overflow: hidden; outline: none; cursor: grab; user-select: none; touch-action: none;
|
|
378
|
+
background-color: var(--tr-surface);
|
|
379
|
+
background-image: radial-gradient(circle, var(--tr-outline-variant) 1px, transparent 1.4px);
|
|
380
|
+
background-size: calc(var(--grid) * var(--k)) calc(var(--grid) * var(--k));
|
|
381
|
+
background-position: var(--tx) var(--ty);
|
|
382
|
+
border: 1px solid var(--tr-outline-variant); border-radius: var(--tr-radius-md);
|
|
383
|
+
}
|
|
384
|
+
.trg[data-panning='true'] { cursor: grabbing; }
|
|
385
|
+
.trg:focus-visible { box-shadow: var(--tr-focus-ring); border-color: var(--tr-primary); }
|
|
386
|
+
.viewport { position: absolute; left: 0; top: 0; transform-origin: 0 0; }
|
|
387
|
+
.edges { position: absolute; left: 0; top: 0; overflow: visible; pointer-events: none; }
|
|
388
|
+
|
|
389
|
+
.edge { --c: var(--tr-outline); color: var(--c); }
|
|
390
|
+
.edge[data-kind='primary'] { --c: var(--tr-primary); }
|
|
391
|
+
.edge[data-kind='success'] { --c: var(--tr-success); }
|
|
392
|
+
.edge[data-kind='warning'] { --c: var(--tr-warning); }
|
|
393
|
+
.edge[data-kind='danger'] { --c: var(--tr-danger); }
|
|
394
|
+
.line { fill: none; stroke: var(--c); stroke-width: 1.75; stroke-linejoin: round; }
|
|
395
|
+
.line.temp { stroke: var(--tr-primary); stroke-dasharray: 5 4; color: var(--tr-primary); }
|
|
396
|
+
.edge[data-animated='true'] .line { stroke-dasharray: 6 5; animation: flow 0.9s linear infinite; }
|
|
397
|
+
@keyframes flow { to { stroke-dashoffset: -11; } }
|
|
398
|
+
@media (prefers-reduced-motion: reduce) { .edge[data-animated='true'] .line { animation: none; } }
|
|
399
|
+
.edge[data-selected='true'] .line { stroke-width: 3; }
|
|
400
|
+
.edge-hit { fill: none; stroke: transparent; stroke-width: 14; pointer-events: stroke; cursor: pointer; }
|
|
401
|
+
.edge:hover .line { stroke-width: 2.75; }
|
|
402
|
+
.lbl-bg { fill: var(--tr-surface-container-highest); stroke: var(--tr-outline-variant); }
|
|
403
|
+
.lbl { fill: var(--tr-on-surface); font-size: 11px; font-weight: 600; pointer-events: none; }
|
|
404
|
+
.elabel { overflow: visible; pointer-events: none; }
|
|
405
|
+
.elabel-in { display: flex; justify-content: center; align-items: center; width: 100%; height: 100%; pointer-events: auto; color: var(--tr-on-surface); font: 600 11px var(--tr-font-sans); }
|
|
406
|
+
|
|
407
|
+
.node {
|
|
408
|
+
--c: var(--tr-outline);
|
|
409
|
+
position: absolute; display: flex; align-items: center; box-sizing: border-box; cursor: default;
|
|
410
|
+
background: var(--tr-surface-container-low); border: 1px solid var(--tr-outline-variant); border-radius: var(--tr-radius-md);
|
|
411
|
+
box-shadow: var(--tr-elevation-1); outline: none;
|
|
412
|
+
transition: box-shadow var(--tr-dur) var(--tr-ease), border-color var(--tr-dur) var(--tr-ease);
|
|
413
|
+
}
|
|
414
|
+
.node::before { content: ''; position: absolute; left: 0; top: 0; bottom: 0; width: 4px; border-radius: var(--tr-radius-md) 0 0 var(--tr-radius-md); background: var(--c); }
|
|
415
|
+
.node[data-kind='primary'] { --c: var(--tr-primary); }
|
|
416
|
+
.node[data-kind='success'] { --c: var(--tr-success); }
|
|
417
|
+
.node[data-kind='warning'] { --c: var(--tr-warning); }
|
|
418
|
+
.node[data-kind='danger'] { --c: var(--tr-danger); }
|
|
419
|
+
.node:hover { border-color: var(--tr-outline); }
|
|
420
|
+
.node[data-selected='true'] { border-color: var(--tr-primary); box-shadow: var(--tr-focus-ring), var(--tr-elevation-2); background: color-mix(in srgb, var(--tr-primary-container) 40%, var(--tr-surface-container-low)); }
|
|
421
|
+
.node:focus-visible { box-shadow: var(--tr-focus-ring); }
|
|
422
|
+
.node[data-dragging='true'] { box-shadow: var(--tr-elevation-3); z-index: 2; cursor: grabbing; }
|
|
423
|
+
/* Bare nodes: a custom component draws everything; we only add a selection outline */
|
|
424
|
+
.node[data-bare='true'] { background: none; border: 0; box-shadow: none; border-radius: 0; }
|
|
425
|
+
.node[data-bare='true']::before { display: none; }
|
|
426
|
+
.node[data-bare='true'][data-selected='true'] { background: none; outline: 2px solid var(--tr-primary); outline-offset: 3px; border-radius: var(--tr-radius-sm); box-shadow: none; }
|
|
427
|
+
.node[data-bare='true']:focus-visible { outline: 2px solid var(--tr-primary); outline-offset: 3px; box-shadow: none; }
|
|
428
|
+
.custom { width: 100%; min-width: 0; }
|
|
429
|
+
.custom.fill { height: 100%; }
|
|
430
|
+
.content { flex: 1; min-width: 0; padding: 0 12px 0 16px; line-height: 1.25; }
|
|
431
|
+
.label { font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: var(--tr-on-surface); }
|
|
432
|
+
.subtitle { font-size: var(--tr-text-sm); color: var(--tr-on-surface-variant); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
433
|
+
|
|
434
|
+
.port { position: absolute; z-index: 3; width: 12px; height: 12px; margin: -6px 0 0 -6px; border-radius: 50%; background: var(--tr-surface-container-highest); border: 2px solid var(--tr-outline); box-sizing: border-box; transition: transform var(--tr-dur-fast) var(--tr-ease); }
|
|
435
|
+
.port[data-connectable='true'] { cursor: crosshair; }
|
|
436
|
+
.port[data-connectable='true']:hover, .port[data-hot='true'] { background: var(--tr-primary); border-color: var(--tr-primary); transform: scale(1.35); }
|
|
437
|
+
/* larger invisible hit area for ports */
|
|
438
|
+
.port::after { content: ''; position: absolute; inset: -6px; }
|
|
439
|
+
.plabel { position: absolute; z-index: 3; pointer-events: none; font: 600 10px var(--tr-font-sans); color: var(--tr-on-surface-variant); white-space: nowrap; }
|
|
440
|
+
.plabel[data-side='right'] { transform: translate(-100%, -50%); margin-left: -12px; }
|
|
441
|
+
.plabel[data-side='left'] { transform: translate(0, -50%); margin-left: 12px; }
|
|
442
|
+
.plabel[data-side='top'] { transform: translate(-50%, 0); margin-top: 8px; }
|
|
443
|
+
.plabel[data-side='bottom'] { transform: translate(-50%, -100%); margin-top: -8px; }
|
|
444
|
+
|
|
445
|
+
.minimap { position: absolute; bottom: 8px; z-index: 4; display: grid; place-items: center; background: var(--tr-surface-container-highest); border: 1px solid var(--tr-outline-variant); border-radius: var(--tr-radius-md); box-shadow: var(--tr-elevation-2); overflow: hidden; cursor: default; }
|
|
446
|
+
.minimap[data-pos='bottom-right'] { right: 8px; }
|
|
447
|
+
.minimap[data-pos='bottom-left'] { left: 8px; }
|
|
448
|
+
.minimap svg { display: block; cursor: grab; touch-action: none; }
|
|
449
|
+
.mme { stroke: var(--tr-outline); stroke-width: 1; stroke-opacity: 0.6; }
|
|
450
|
+
.mmn { fill: var(--tr-outline); }
|
|
451
|
+
.mmn[data-kind='primary'] { fill: var(--tr-primary); }
|
|
452
|
+
.mmn[data-kind='success'] { fill: var(--tr-success); }
|
|
453
|
+
.mmn[data-kind='warning'] { fill: var(--tr-warning); }
|
|
454
|
+
.mmn[data-kind='danger'] { fill: var(--tr-danger); }
|
|
455
|
+
.mmn[data-selected='true'] { stroke: var(--tr-on-surface); stroke-width: 1.5; }
|
|
456
|
+
.mmv { fill: var(--tr-primary); fill-opacity: 0.12; stroke: var(--tr-primary); stroke-width: 1.5; pointer-events: none; }
|
|
457
|
+
.controls { position: absolute; right: 8px; top: 8px; display: flex; gap: 2px; padding: 2px; background: var(--tr-surface-container-highest); border: 1px solid var(--tr-outline-variant); border-radius: var(--tr-radius-pill); box-shadow: var(--tr-elevation-2); cursor: default; }
|
|
458
|
+
</style>
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { type Snippet } from 'svelte';
|
|
2
|
+
import { type EdgeType, type GraphEdge, type GraphNode, type NodeRenderer } from './types.js';
|
|
3
|
+
type $$ComponentProps = {
|
|
4
|
+
nodes?: GraphNode[];
|
|
5
|
+
edges?: GraphEdge[];
|
|
6
|
+
/** Selected node ids. */
|
|
7
|
+
selected?: string[];
|
|
8
|
+
selectedEdge?: string;
|
|
9
|
+
height?: number | string;
|
|
10
|
+
/** Default routing for every edge (an edge's own `type` wins): bezier, straight, step, smoothstep, or avoid (steers around nodes). */
|
|
11
|
+
edgeType?: EdgeType;
|
|
12
|
+
/** Which sides the default ports use, and the direction of auto layout. */
|
|
13
|
+
direction?: 'LR' | 'TB';
|
|
14
|
+
/** Drag from an output port to an input port to create edges. */
|
|
15
|
+
connectable?: boolean;
|
|
16
|
+
/** Allow moving and deleting. */
|
|
17
|
+
editable?: boolean;
|
|
18
|
+
fitOnMount?: boolean;
|
|
19
|
+
/** Overview panel showing every node and the current viewport; click or drag it to pan. */
|
|
20
|
+
minimap?: boolean;
|
|
21
|
+
minimapPosition?: 'bottom-right' | 'bottom-left';
|
|
22
|
+
/** Custom node components by `node.type`. Each is a snippet receiving `{ node, selected, dragging, update }` and draws the whole node. */
|
|
23
|
+
nodeTypes?: Record<string, NodeRenderer>;
|
|
24
|
+
/** Custom component for every node that has no matching `nodeTypes` entry. */
|
|
25
|
+
node?: NodeRenderer;
|
|
26
|
+
/** Content inside the default card, under the title. */
|
|
27
|
+
body?: Snippet<[node: GraphNode]>;
|
|
28
|
+
/** Custom component rendered at the middle of each edge (replaces `edge.label`). */
|
|
29
|
+
edgeLabel?: Snippet<[edge: GraphEdge]>;
|
|
30
|
+
/** Return false to reject a connection. */
|
|
31
|
+
isValidConnection?: (from: {
|
|
32
|
+
node: string;
|
|
33
|
+
port: string;
|
|
34
|
+
}, to: {
|
|
35
|
+
node: string;
|
|
36
|
+
port: string;
|
|
37
|
+
}) => boolean;
|
|
38
|
+
onconnect?: (edge: GraphEdge) => void;
|
|
39
|
+
onchange?: () => void;
|
|
40
|
+
};
|
|
41
|
+
declare const Graph: import("svelte").Component<$$ComponentProps, {
|
|
42
|
+
fit: (padding?: number) => void;
|
|
43
|
+
layout: () => void;
|
|
44
|
+
}, "selected" | "nodes" | "edges" | "selectedEdge">;
|
|
45
|
+
type Graph = ReturnType<typeof Graph>;
|
|
46
|
+
export default Graph;
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { default as Graph } from './Graph.svelte';
|
|
2
|
+
export { autoLayout } from './layout.js';
|
|
3
|
+
export { routeEdge, portAnchor, defaultPorts } from './routing.js';
|
|
4
|
+
export type { GraphNode, GraphEdge, GraphPort, NodeKind, NodeCtx, NodeRenderer, EdgeType, Side } from './types.js';
|
package/dist/index.js
ADDED
package/dist/layout.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { type GraphEdge, type GraphNode } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Layered (Sugiyama-lite) auto layout: longest-path layering, one barycenter sweep to
|
|
4
|
+
* reduce crossings, columns centred vertically. Cycle-safe. Returns new node objects.
|
|
5
|
+
*/
|
|
6
|
+
export declare function autoLayout(nodes: GraphNode[], edges: GraphEdge[], opts?: {
|
|
7
|
+
gapX?: number;
|
|
8
|
+
gapY?: number;
|
|
9
|
+
direction?: 'LR' | 'TB';
|
|
10
|
+
}): GraphNode[];
|
package/dist/layout.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { NODE_H, NODE_W } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Layered (Sugiyama-lite) auto layout: longest-path layering, one barycenter sweep to
|
|
4
|
+
* reduce crossings, columns centred vertically. Cycle-safe. Returns new node objects.
|
|
5
|
+
*/
|
|
6
|
+
export function autoLayout(nodes, edges, opts = {}) {
|
|
7
|
+
const { gapX = 90, gapY = 28, direction = 'LR' } = opts;
|
|
8
|
+
const ids = new Set(nodes.map((n) => n.id));
|
|
9
|
+
const preds = new Map(nodes.map((n) => [n.id, []]));
|
|
10
|
+
for (const e of edges)
|
|
11
|
+
if (ids.has(e.from) && ids.has(e.to) && e.from !== e.to)
|
|
12
|
+
preds.get(e.to).push(e.from);
|
|
13
|
+
const layer = new Map();
|
|
14
|
+
const visiting = new Set();
|
|
15
|
+
const depth = (id) => {
|
|
16
|
+
if (layer.has(id))
|
|
17
|
+
return layer.get(id);
|
|
18
|
+
if (visiting.has(id))
|
|
19
|
+
return 0; // break cycles
|
|
20
|
+
visiting.add(id);
|
|
21
|
+
const d = preds.get(id).reduce((m, p) => Math.max(m, depth(p) + 1), 0);
|
|
22
|
+
visiting.delete(id);
|
|
23
|
+
layer.set(id, d);
|
|
24
|
+
return d;
|
|
25
|
+
};
|
|
26
|
+
nodes.forEach((n) => depth(n.id));
|
|
27
|
+
const cols = [];
|
|
28
|
+
for (const n of nodes)
|
|
29
|
+
(cols[layer.get(n.id)] ??= []).push(n);
|
|
30
|
+
const order = new Map();
|
|
31
|
+
cols.forEach((col, ci) => {
|
|
32
|
+
if (ci > 0) {
|
|
33
|
+
const bary = (n) => {
|
|
34
|
+
const ps = preds.get(n.id).map((p) => order.get(p)).filter((v) => v != null);
|
|
35
|
+
return ps.length ? ps.reduce((a, b) => a + b, 0) / ps.length : Infinity;
|
|
36
|
+
};
|
|
37
|
+
col.sort((a, b) => bary(a) - bary(b));
|
|
38
|
+
}
|
|
39
|
+
col.forEach((n, i) => order.set(n.id, i));
|
|
40
|
+
});
|
|
41
|
+
const w = (n) => n.width ?? NODE_W;
|
|
42
|
+
const h = (n) => n.height ?? NODE_H;
|
|
43
|
+
const pos = new Map();
|
|
44
|
+
const span = (col) => col.reduce((a, n) => a + (direction === 'LR' ? h(n) : w(n)) + gapY, -gapY);
|
|
45
|
+
const tallest = Math.max(0, ...cols.filter(Boolean).map(span));
|
|
46
|
+
let offset = 0;
|
|
47
|
+
cols.forEach((col) => {
|
|
48
|
+
if (!col)
|
|
49
|
+
return;
|
|
50
|
+
const colSize = col.reduce((m, n) => Math.max(m, direction === 'LR' ? w(n) : h(n)), 0);
|
|
51
|
+
let cursor = (tallest - span(col)) / 2;
|
|
52
|
+
for (const n of col) {
|
|
53
|
+
pos.set(n.id, direction === 'LR' ? { x: offset, y: cursor } : { x: cursor, y: offset });
|
|
54
|
+
cursor += (direction === 'LR' ? h(n) : w(n)) + gapY;
|
|
55
|
+
}
|
|
56
|
+
offset += colSize + gapX;
|
|
57
|
+
});
|
|
58
|
+
return nodes.map((n) => ({ ...n, ...pos.get(n.id) }));
|
|
59
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { EdgeType, GraphNode, GraphPort, Side } from './types.js';
|
|
2
|
+
export type Pt = {
|
|
3
|
+
x: number;
|
|
4
|
+
y: number;
|
|
5
|
+
};
|
|
6
|
+
export type Anchor = Pt & {
|
|
7
|
+
side: Side;
|
|
8
|
+
};
|
|
9
|
+
export type Rect = {
|
|
10
|
+
x: number;
|
|
11
|
+
y: number;
|
|
12
|
+
w: number;
|
|
13
|
+
h: number;
|
|
14
|
+
id?: string;
|
|
15
|
+
};
|
|
16
|
+
export type Routed = {
|
|
17
|
+
d: string;
|
|
18
|
+
mid: Pt;
|
|
19
|
+
};
|
|
20
|
+
export declare const opposite: (s: Side) => Side;
|
|
21
|
+
export declare function defaultPorts(n: GraphNode, direction?: 'LR' | 'TB'): GraphPort[];
|
|
22
|
+
/** Where a port sits on its node; several ports on one side are spread evenly. */
|
|
23
|
+
export declare function portAnchor(n: GraphNode, port: GraphPort, ports: GraphPort[]): Anchor;
|
|
24
|
+
/** Route an edge between two anchors with the chosen method. `obstacles` is only used by `avoid`. */
|
|
25
|
+
export declare function routeEdge(type: EdgeType, a: Anchor, b: Anchor, obstacles?: Rect[]): Routed;
|
package/dist/routing.js
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import { NODE_H, NODE_W } from './types.js';
|
|
2
|
+
const DIR = { left: { x: -1, y: 0 }, right: { x: 1, y: 0 }, top: { x: 0, y: -1 }, bottom: { x: 0, y: 1 } };
|
|
3
|
+
export const opposite = (s) => ({ left: 'right', right: 'left', top: 'bottom', bottom: 'top' }[s]);
|
|
4
|
+
const horiz = (s) => s === 'left' || s === 'right';
|
|
5
|
+
const dist = (a, b) => Math.hypot(a.x - b.x, a.y - b.y);
|
|
6
|
+
// ---------- ports
|
|
7
|
+
export function defaultPorts(n, direction = 'LR') {
|
|
8
|
+
const ps = [];
|
|
9
|
+
if (n.input !== false)
|
|
10
|
+
ps.push({ id: 'in', side: direction === 'TB' ? 'top' : 'left', kind: 'in' });
|
|
11
|
+
if (n.output !== false)
|
|
12
|
+
ps.push({ id: 'out', side: direction === 'TB' ? 'bottom' : 'right', kind: 'out' });
|
|
13
|
+
return ps;
|
|
14
|
+
}
|
|
15
|
+
/** Where a port sits on its node; several ports on one side are spread evenly. */
|
|
16
|
+
export function portAnchor(n, port, ports) {
|
|
17
|
+
const w = n.width ?? NODE_W, h = n.height ?? NODE_H;
|
|
18
|
+
const peers = ports.filter((p) => p.side === port.side);
|
|
19
|
+
const t = (peers.indexOf(port) + 1) / (peers.length + 1);
|
|
20
|
+
switch (port.side) {
|
|
21
|
+
case 'left': return { x: n.x, y: n.y + h * t, side: 'left' };
|
|
22
|
+
case 'right': return { x: n.x + w, y: n.y + h * t, side: 'right' };
|
|
23
|
+
case 'top': return { x: n.x + w * t, y: n.y, side: 'top' };
|
|
24
|
+
default: return { x: n.x + w * t, y: n.y + h, side: 'bottom' };
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
// ---------- geometry helpers
|
|
28
|
+
function simplify(pts) {
|
|
29
|
+
const out = [];
|
|
30
|
+
for (const p of pts) {
|
|
31
|
+
const last = out.at(-1);
|
|
32
|
+
if (last && Math.abs(last.x - p.x) < 0.01 && Math.abs(last.y - p.y) < 0.01)
|
|
33
|
+
continue;
|
|
34
|
+
out.push(p);
|
|
35
|
+
}
|
|
36
|
+
// drop collinear middle points
|
|
37
|
+
return out.filter((p, i) => {
|
|
38
|
+
if (i === 0 || i === out.length - 1)
|
|
39
|
+
return true;
|
|
40
|
+
const a = out[i - 1], b = out[i + 1];
|
|
41
|
+
return !((a.x === p.x && p.x === b.x) || (a.y === p.y && p.y === b.y));
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
function pathOf(pts, r) {
|
|
45
|
+
if (pts.length < 3 || r <= 0)
|
|
46
|
+
return 'M' + pts.map((p) => `${p.x},${p.y}`).join(' L');
|
|
47
|
+
let d = `M${pts[0].x},${pts[0].y}`;
|
|
48
|
+
for (let i = 1; i < pts.length - 1; i++) {
|
|
49
|
+
const prev = pts[i - 1], cur = pts[i], next = pts[i + 1];
|
|
50
|
+
const l1 = dist(prev, cur), l2 = dist(cur, next);
|
|
51
|
+
const rr = Math.min(r, l1 / 2, l2 / 2);
|
|
52
|
+
const a = { x: cur.x + ((prev.x - cur.x) / l1) * rr, y: cur.y + ((prev.y - cur.y) / l1) * rr };
|
|
53
|
+
const b = { x: cur.x + ((next.x - cur.x) / l2) * rr, y: cur.y + ((next.y - cur.y) / l2) * rr };
|
|
54
|
+
d += ` L${a.x},${a.y} Q${cur.x},${cur.y} ${b.x},${b.y}`;
|
|
55
|
+
}
|
|
56
|
+
const last = pts.at(-1);
|
|
57
|
+
return d + ` L${last.x},${last.y}`;
|
|
58
|
+
}
|
|
59
|
+
function midOf(pts) {
|
|
60
|
+
const total = pts.slice(1).reduce((s, p, i) => s + dist(pts[i], p), 0);
|
|
61
|
+
let walk = total / 2;
|
|
62
|
+
for (let i = 1; i < pts.length; i++) {
|
|
63
|
+
const l = dist(pts[i - 1], pts[i]);
|
|
64
|
+
if (walk <= l) {
|
|
65
|
+
const t = l ? walk / l : 0;
|
|
66
|
+
return { x: pts[i - 1].x + (pts[i].x - pts[i - 1].x) * t, y: pts[i - 1].y + (pts[i].y - pts[i - 1].y) * t };
|
|
67
|
+
}
|
|
68
|
+
walk -= l;
|
|
69
|
+
}
|
|
70
|
+
return pts.at(-1);
|
|
71
|
+
}
|
|
72
|
+
const stubOf = (a, s) => ({ x: a.x + DIR[a.side].x * s, y: a.y + DIR[a.side].y * s });
|
|
73
|
+
// ---------- routers
|
|
74
|
+
function bezier(a, b) {
|
|
75
|
+
const o = Math.max(40, dist(a, b) / 2.2);
|
|
76
|
+
const c1 = { x: a.x + DIR[a.side].x * o, y: a.y + DIR[a.side].y * o };
|
|
77
|
+
const c2 = { x: b.x + DIR[b.side].x * o, y: b.y + DIR[b.side].y * o };
|
|
78
|
+
return {
|
|
79
|
+
d: `M${a.x},${a.y} C${c1.x},${c1.y} ${c2.x},${c2.y} ${b.x},${b.y}`,
|
|
80
|
+
mid: { x: (a.x + 3 * c1.x + 3 * c2.x + b.x) / 8, y: (a.y + 3 * c1.y + 3 * c2.y + b.y) / 8 }
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
/** Orthogonal route with short stubs leaving/entering perpendicular to each node side. */
|
|
84
|
+
function orthogonalPoints(a, b, stub = 24) {
|
|
85
|
+
const p1 = stubOf(a, stub), q1 = stubOf(b, stub);
|
|
86
|
+
const mid = [];
|
|
87
|
+
if (horiz(a.side) && horiz(b.side)) {
|
|
88
|
+
if (a.side === b.side) {
|
|
89
|
+
const x = a.side === 'right' ? Math.max(p1.x, q1.x) : Math.min(p1.x, q1.x);
|
|
90
|
+
mid.push({ x, y: p1.y }, { x, y: q1.y });
|
|
91
|
+
}
|
|
92
|
+
else if ((a.side === 'right' && q1.x >= p1.x) || (a.side === 'left' && q1.x <= p1.x)) {
|
|
93
|
+
const x = (p1.x + q1.x) / 2;
|
|
94
|
+
mid.push({ x, y: p1.y }, { x, y: q1.y });
|
|
95
|
+
}
|
|
96
|
+
else {
|
|
97
|
+
const y = Math.abs(a.y - b.y) < 20 ? a.y - 70 : (a.y + b.y) / 2;
|
|
98
|
+
mid.push({ x: p1.x, y }, { x: q1.x, y });
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
else if (!horiz(a.side) && !horiz(b.side)) {
|
|
102
|
+
if (a.side === b.side) {
|
|
103
|
+
const y = a.side === 'bottom' ? Math.max(p1.y, q1.y) : Math.min(p1.y, q1.y);
|
|
104
|
+
mid.push({ x: p1.x, y }, { x: q1.x, y });
|
|
105
|
+
}
|
|
106
|
+
else if ((a.side === 'bottom' && q1.y >= p1.y) || (a.side === 'top' && q1.y <= p1.y)) {
|
|
107
|
+
const y = (p1.y + q1.y) / 2;
|
|
108
|
+
mid.push({ x: p1.x, y }, { x: q1.x, y });
|
|
109
|
+
}
|
|
110
|
+
else {
|
|
111
|
+
const x = Math.abs(a.x - b.x) < 20 ? a.x + 90 : (a.x + b.x) / 2;
|
|
112
|
+
mid.push({ x, y: p1.y }, { x, y: q1.y });
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
else if (horiz(a.side))
|
|
116
|
+
mid.push({ x: q1.x, y: p1.y });
|
|
117
|
+
else
|
|
118
|
+
mid.push({ x: p1.x, y: q1.y });
|
|
119
|
+
return simplify([a, p1, ...mid, q1, b]);
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Obstacle-avoiding orthogonal routing: A* over the "Hanan" grid formed by the obstacle edges (inflated by `margin`)
|
|
123
|
+
* and the two stub points, with a penalty per bend so routes stay tidy. Returns null when no clear route exists.
|
|
124
|
+
*/
|
|
125
|
+
function avoidPoints(a, b, obstacles, margin = 14, stub = 26) {
|
|
126
|
+
const p1 = stubOf(a, stub), q1 = stubOf(b, stub);
|
|
127
|
+
const rs = obstacles.map((o) => ({ l: o.x - margin, t: o.y - margin, r: o.x + o.w + margin, b: o.y + o.h + margin }));
|
|
128
|
+
const inside = (x, y) => rs.some((r) => x > r.l && x < r.r && y > r.t && y < r.b);
|
|
129
|
+
if (inside(p1.x, p1.y) || inside(q1.x, q1.y))
|
|
130
|
+
return null;
|
|
131
|
+
const uniq = (v) => [...new Set(v.map((n) => Math.round(n * 100) / 100))].sort((m, n) => m - n);
|
|
132
|
+
const withChannels = (v) => {
|
|
133
|
+
const out = [...v];
|
|
134
|
+
for (let i = 1; i < v.length; i++)
|
|
135
|
+
if (v[i] - v[i - 1] > 3 * margin)
|
|
136
|
+
out.push((v[i] + v[i - 1]) / 2);
|
|
137
|
+
return uniq(out);
|
|
138
|
+
};
|
|
139
|
+
const xs = withChannels(uniq([p1.x, q1.x, ...rs.flatMap((r) => [r.l, r.r])]));
|
|
140
|
+
const ys = withChannels(uniq([p1.y, q1.y, ...rs.flatMap((r) => [r.t, r.b])]));
|
|
141
|
+
const nx = xs.length, ny = ys.length;
|
|
142
|
+
const xi = (x) => xs.indexOf(Math.round(x * 100) / 100);
|
|
143
|
+
const yi = (y) => ys.indexOf(Math.round(y * 100) / 100);
|
|
144
|
+
const s = { x: xi(p1.x), y: yi(p1.y) }, g = { x: xi(q1.x), y: yi(q1.y) };
|
|
145
|
+
if (s.x < 0 || s.y < 0 || g.x < 0 || g.y < 0)
|
|
146
|
+
return null;
|
|
147
|
+
const DX = [1, -1, 0, 0], DY = [0, 0, 1, -1];
|
|
148
|
+
const key = (x, y, d) => (x * ny + y) * 5 + d;
|
|
149
|
+
const best = new Map();
|
|
150
|
+
const prev = new Map();
|
|
151
|
+
const heap = []; // f, g, x, y, dir
|
|
152
|
+
const push = (e) => {
|
|
153
|
+
heap.push(e);
|
|
154
|
+
let i = heap.length - 1;
|
|
155
|
+
while (i > 0) {
|
|
156
|
+
const p = (i - 1) >> 1;
|
|
157
|
+
if (heap[p][0] <= heap[i][0])
|
|
158
|
+
break;
|
|
159
|
+
[heap[p], heap[i]] = [heap[i], heap[p]];
|
|
160
|
+
i = p;
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
const pop = () => {
|
|
164
|
+
const top = heap[0], last = heap.pop();
|
|
165
|
+
if (heap.length) {
|
|
166
|
+
heap[0] = last;
|
|
167
|
+
let i = 0;
|
|
168
|
+
for (;;) {
|
|
169
|
+
const l = 2 * i + 1, r = l + 1;
|
|
170
|
+
let m = i;
|
|
171
|
+
if (l < heap.length && heap[l][0] < heap[m][0])
|
|
172
|
+
m = l;
|
|
173
|
+
if (r < heap.length && heap[r][0] < heap[m][0])
|
|
174
|
+
m = r;
|
|
175
|
+
if (m === i)
|
|
176
|
+
break;
|
|
177
|
+
[heap[m], heap[i]] = [heap[i], heap[m]];
|
|
178
|
+
i = m;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return top;
|
|
182
|
+
};
|
|
183
|
+
const h = (x, y) => Math.abs(xs[x] - xs[g.x]) + Math.abs(ys[y] - ys[g.y]);
|
|
184
|
+
const BEND = 40;
|
|
185
|
+
best.set(key(s.x, s.y, 4), 0);
|
|
186
|
+
push([h(s.x, s.y), 0, s.x, s.y, 4]);
|
|
187
|
+
let goalKey = -1;
|
|
188
|
+
while (heap.length) {
|
|
189
|
+
const [, gc, x, y, d] = pop();
|
|
190
|
+
const k = key(x, y, d);
|
|
191
|
+
if (gc > (best.get(k) ?? Infinity))
|
|
192
|
+
continue;
|
|
193
|
+
if (x === g.x && y === g.y) {
|
|
194
|
+
goalKey = k;
|
|
195
|
+
break;
|
|
196
|
+
}
|
|
197
|
+
for (let nd = 0; nd < 4; nd++) {
|
|
198
|
+
const px = x + DX[nd], py = y + DY[nd];
|
|
199
|
+
if (px < 0 || py < 0 || px >= nx || py >= ny)
|
|
200
|
+
continue;
|
|
201
|
+
// segment between adjacent grid lines is wholly inside or outside an obstacle: test its midpoint
|
|
202
|
+
if (inside((xs[x] + xs[px]) / 2, (ys[y] + ys[py]) / 2))
|
|
203
|
+
continue;
|
|
204
|
+
const cost = gc + Math.abs(xs[px] - xs[x]) + Math.abs(ys[py] - ys[y]) + (d !== 4 && d !== nd ? BEND : 0);
|
|
205
|
+
const nk = key(px, py, nd);
|
|
206
|
+
if (cost < (best.get(nk) ?? Infinity)) {
|
|
207
|
+
best.set(nk, cost);
|
|
208
|
+
prev.set(nk, k);
|
|
209
|
+
push([cost + h(px, py), cost, px, py, nd]);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
if (goalKey < 0)
|
|
214
|
+
return null;
|
|
215
|
+
const path = [];
|
|
216
|
+
for (let k = goalKey; k !== undefined; k = prev.get(k)) {
|
|
217
|
+
const cell = Math.floor(k / 5);
|
|
218
|
+
path.push({ x: xs[Math.floor(cell / ny)], y: ys[cell % ny] });
|
|
219
|
+
}
|
|
220
|
+
return simplify([a, ...path.reverse(), b]);
|
|
221
|
+
}
|
|
222
|
+
/** Route an edge between two anchors with the chosen method. `obstacles` is only used by `avoid`. */
|
|
223
|
+
export function routeEdge(type, a, b, obstacles = []) {
|
|
224
|
+
switch (type) {
|
|
225
|
+
case 'straight':
|
|
226
|
+
return { d: `M${a.x},${a.y} L${b.x},${b.y}`, mid: { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 } };
|
|
227
|
+
case 'step': {
|
|
228
|
+
const p = orthogonalPoints(a, b);
|
|
229
|
+
return { d: pathOf(p, 0), mid: midOf(p) };
|
|
230
|
+
}
|
|
231
|
+
case 'smoothstep': {
|
|
232
|
+
const p = orthogonalPoints(a, b);
|
|
233
|
+
return { d: pathOf(p, 12), mid: midOf(p) };
|
|
234
|
+
}
|
|
235
|
+
case 'avoid': {
|
|
236
|
+
const p = avoidPoints(a, b, obstacles) ?? orthogonalPoints(a, b);
|
|
237
|
+
return { d: pathOf(p, 10), mid: midOf(p) };
|
|
238
|
+
}
|
|
239
|
+
default:
|
|
240
|
+
return bezier(a, b);
|
|
241
|
+
}
|
|
242
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
2
|
+
export type NodeKind = 'neutral' | 'primary' | 'success' | 'warning' | 'danger';
|
|
3
|
+
export type Side = 'left' | 'right' | 'top' | 'bottom';
|
|
4
|
+
export type EdgeType = 'bezier' | 'straight' | 'step' | 'smoothstep' | 'avoid';
|
|
5
|
+
export type GraphPort = {
|
|
6
|
+
id: string;
|
|
7
|
+
side: Side;
|
|
8
|
+
/** `out` ports start connections, `in` ports receive them. */
|
|
9
|
+
kind: 'in' | 'out';
|
|
10
|
+
label?: string;
|
|
11
|
+
};
|
|
12
|
+
export type GraphNode = {
|
|
13
|
+
id: string;
|
|
14
|
+
x: number;
|
|
15
|
+
y: number;
|
|
16
|
+
label: string;
|
|
17
|
+
subtitle?: string;
|
|
18
|
+
kind?: NodeKind;
|
|
19
|
+
/** Picks a renderer from the `nodeTypes` registry on <Graph>. */
|
|
20
|
+
type?: string;
|
|
21
|
+
/** Fixed size in graph units (edge math relies on it). Defaults 180×56. */
|
|
22
|
+
width?: number;
|
|
23
|
+
height?: number;
|
|
24
|
+
/** Shorthand: set false to hide the default input / output port. Ignored when `ports` is set. */
|
|
25
|
+
input?: boolean;
|
|
26
|
+
output?: boolean;
|
|
27
|
+
/** Explicit ports on any side; several per side are spread evenly. */
|
|
28
|
+
ports?: GraphPort[];
|
|
29
|
+
/** Draw the default card around a custom renderer too. By default custom renderers are bare (they draw everything). */
|
|
30
|
+
chrome?: boolean;
|
|
31
|
+
/** Free-form payload for custom node components. */
|
|
32
|
+
data?: Record<string, any>;
|
|
33
|
+
};
|
|
34
|
+
export type GraphEdge = {
|
|
35
|
+
id: string;
|
|
36
|
+
from: string;
|
|
37
|
+
to: string;
|
|
38
|
+
/** Port ids; default to the first `out` port of `from` and first `in` port of `to`. */
|
|
39
|
+
fromPort?: string;
|
|
40
|
+
toPort?: string;
|
|
41
|
+
label?: string;
|
|
42
|
+
kind?: NodeKind;
|
|
43
|
+
/** Overrides the graph-wide `edgeType` for this edge. */
|
|
44
|
+
type?: EdgeType;
|
|
45
|
+
/** Flowing dashes, e.g. for live data. */
|
|
46
|
+
animated?: boolean;
|
|
47
|
+
};
|
|
48
|
+
/** What a custom node component receives. */
|
|
49
|
+
export type NodeCtx = {
|
|
50
|
+
node: GraphNode;
|
|
51
|
+
selected: boolean;
|
|
52
|
+
dragging: boolean;
|
|
53
|
+
/** Patch this node (e.g. `update({ data: { ...node.data, value: 3 } })`). */
|
|
54
|
+
update: (patch: Partial<GraphNode>) => void;
|
|
55
|
+
};
|
|
56
|
+
export type NodeRenderer = Snippet<[ctx: NodeCtx]>;
|
|
57
|
+
export declare const NODE_W = 180;
|
|
58
|
+
export declare const NODE_H = 56;
|
package/dist/types.js
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,46 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
2
|
+
"name": "@ruckc/truss-graph",
|
|
3
|
+
"version": "1.1.1",
|
|
4
|
+
"description": "Zero-dependency node/edge graph editor for Truss UI",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"package": "svelte-package -i src/lib -o dist"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"dist"
|
|
12
|
+
],
|
|
13
|
+
"svelte": "./dist/index.js",
|
|
14
|
+
"types": "./dist/index.d.ts",
|
|
15
|
+
"exports": {
|
|
16
|
+
".": {
|
|
17
|
+
"types": "./dist/index.d.ts",
|
|
18
|
+
"svelte": "./dist/index.js"
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
"peerDependencies": {
|
|
22
|
+
"svelte": "^5.20.0",
|
|
23
|
+
"@ruckc/truss-ui": "^1.0.0"
|
|
24
|
+
},
|
|
25
|
+
"author": "Curtis Ruck",
|
|
26
|
+
"homepage": "https://truss.ruck.io",
|
|
27
|
+
"bugs": {
|
|
28
|
+
"url": "https://github.com/ruckc/truss-ui/issues"
|
|
29
|
+
},
|
|
30
|
+
"publishConfig": {
|
|
31
|
+
"access": "public"
|
|
32
|
+
},
|
|
33
|
+
"repository": {
|
|
34
|
+
"type": "git",
|
|
35
|
+
"url": "git+https://github.com/ruckc/truss-ui.git",
|
|
36
|
+
"directory": "packages/graph"
|
|
37
|
+
},
|
|
38
|
+
"keywords": [
|
|
39
|
+
"svelte",
|
|
40
|
+
"sveltekit",
|
|
41
|
+
"truss",
|
|
42
|
+
"graph",
|
|
43
|
+
"node-editor",
|
|
44
|
+
"diagram"
|
|
45
|
+
]
|
|
46
|
+
}
|
package/README.md
DELETED