@ruckc/truss-graph 1.2.0 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/Graph.svelte +231 -27
- package/dist/Graph.svelte.d.ts +20 -4
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/layout.d.ts +21 -0
- package/dist/layout.js +253 -0
- package/dist/types.d.ts +10 -0
- package/package.json +1 -1
package/dist/Graph.svelte
CHANGED
|
@@ -1,18 +1,22 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
import { onMount, type Snippet } from 'svelte';
|
|
2
|
+
import { onMount, tick, type Snippet } from 'svelte';
|
|
3
3
|
import { Button } from '@ruckc/truss-ui';
|
|
4
|
-
import { autoLayout } from './layout.js';
|
|
4
|
+
import { autoLayout, forceLayout, radialLayout } from './layout.js';
|
|
5
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';
|
|
6
|
+
import { NODE_H, NODE_W, type EdgeType, type GraphEdge, type GraphLayout, type GraphLegend, type GraphNode, type GraphPort, type HighlightDirection, type NodeCtx, type NodeKind, type NodeRenderer } from './types.js';
|
|
7
7
|
|
|
8
8
|
let {
|
|
9
9
|
nodes = $bindable([]),
|
|
10
10
|
edges = $bindable([]),
|
|
11
11
|
selected = $bindable([]),
|
|
12
12
|
selectedEdge = $bindable(),
|
|
13
|
+
collapsed = $bindable([]),
|
|
14
|
+
hiddenEdgeKinds = $bindable([]),
|
|
13
15
|
height = 480,
|
|
14
16
|
edgeType = 'bezier',
|
|
15
17
|
direction = 'LR',
|
|
18
|
+
layout: layoutMode = 'layered',
|
|
19
|
+
legend,
|
|
16
20
|
connectable = true,
|
|
17
21
|
editable = true,
|
|
18
22
|
fitOnMount = true,
|
|
@@ -31,11 +35,19 @@
|
|
|
31
35
|
/** Selected node ids. */
|
|
32
36
|
selected?: string[];
|
|
33
37
|
selectedEdge?: string;
|
|
38
|
+
/** Ids of collapsed nodes: everything downstream of them is hidden. Use `collapse(id)` / `expand(id)` or bind this. */
|
|
39
|
+
collapsed?: string[];
|
|
40
|
+
/** Edge kinds currently filtered out (the legend toggles these). Edges without a kind count as `neutral`. */
|
|
41
|
+
hiddenEdgeKinds?: string[];
|
|
34
42
|
height?: number | string;
|
|
35
|
-
/** Default routing for every edge (an edge's own `type` wins): bezier, straight, step, smoothstep, or avoid (steers around nodes). */
|
|
43
|
+
/** Default routing for every edge (an edge's own `type` wins): bezier, straight, step, smoothstep, or avoid (steers around nodes; falls back to smoothstep on large graphs). */
|
|
36
44
|
edgeType?: EdgeType;
|
|
37
|
-
/** Which sides the default ports use, and the direction of
|
|
45
|
+
/** Which sides the default ports use, and the direction of the layered layout. */
|
|
38
46
|
direction?: 'LR' | 'TB';
|
|
47
|
+
/** Algorithm used by `layout()` and the auto-layout button. `force` and `radial` also run once on mount. Default `layered` keeps your positions on mount. */
|
|
48
|
+
layout?: GraphLayout;
|
|
49
|
+
/** Key for node and edge kinds. `true` derives it from the kinds in use; or pass labels per kind. Edge entries toggle `hiddenEdgeKinds`. */
|
|
50
|
+
legend?: boolean | GraphLegend;
|
|
39
51
|
/** Drag from an output port to an input port to create edges. */
|
|
40
52
|
connectable?: boolean;
|
|
41
53
|
/** Allow moving and deleting. */
|
|
@@ -63,7 +75,8 @@
|
|
|
63
75
|
let drag = $state<{ ids: string[]; dx: number; dy: number } | null>(null);
|
|
64
76
|
let link = $state<{ from: string; port: string; x: number; y: number; over?: { node: string; port: string } } | null>(null);
|
|
65
77
|
let panning = $state(false);
|
|
66
|
-
|
|
78
|
+
// sensible guesses until the first measurement, so a big graph does not render everything once
|
|
79
|
+
let rw = $state(1000), rh = $state(480);
|
|
67
80
|
|
|
68
81
|
// ---- minimap geometry: world bounds = nodes ∪ viewport (so the viewport box is always visible)
|
|
69
82
|
const MW = 184, MH = 124;
|
|
@@ -71,7 +84,7 @@
|
|
|
71
84
|
if (!minimap || !rw) return null;
|
|
72
85
|
const vx = -tx / k, vy = -ty / k, vw = rw / k, vh = rh / k;
|
|
73
86
|
let x0 = vx, y0 = vy, x1 = vx + vw, y1 = vy + vh;
|
|
74
|
-
for (const n of
|
|
87
|
+
for (const n of live) { 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
88
|
const pad = 24; x0 -= pad; y0 -= pad; x1 += pad; y1 += pad;
|
|
76
89
|
const sc = Math.min(MW / (x1 - x0), MH / (y1 - y0));
|
|
77
90
|
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 } };
|
|
@@ -95,22 +108,70 @@
|
|
|
95
108
|
const W = (n: GraphNode) => n.width ?? NODE_W;
|
|
96
109
|
const H = (n: GraphNode) => n.height ?? NODE_H;
|
|
97
110
|
const portsOf = (n: GraphNode): GraphPort[] => n.ports ?? defaultPorts(n, direction);
|
|
98
|
-
|
|
111
|
+
// ---- reachability, collapsing, edge-kind filter
|
|
112
|
+
const adjacency = (from: 'from' | 'to') => {
|
|
113
|
+
const m = new Map<string, string[]>();
|
|
114
|
+
for (const e of edges) {
|
|
115
|
+
const a = from === 'from' ? e.from : e.to, b = from === 'from' ? e.to : e.from;
|
|
116
|
+
const l = m.get(a);
|
|
117
|
+
if (l) l.push(b); else m.set(a, [b]);
|
|
118
|
+
}
|
|
119
|
+
return m;
|
|
120
|
+
};
|
|
121
|
+
const childrenOf = $derived(adjacency('from'));
|
|
122
|
+
const parentsOf = $derived(adjacency('to'));
|
|
123
|
+
/** Every node reachable from `id` along `map` (excluding `id` itself). */
|
|
124
|
+
function reach(id: string, map: Map<string, string[]>): Set<string> {
|
|
125
|
+
const seen = new Set<string>([id]), stack = [id];
|
|
126
|
+
while (stack.length) for (const v of map.get(stack.pop()!) ?? []) if (!seen.has(v)) { seen.add(v); stack.push(v); }
|
|
127
|
+
seen.delete(id);
|
|
128
|
+
return seen;
|
|
129
|
+
}
|
|
130
|
+
const hiding = $derived.by(() => {
|
|
131
|
+
const hidden = new Set<string>(), counts = new Map<string, number>();
|
|
132
|
+
for (const c of collapsed) {
|
|
133
|
+
const r = reach(c, childrenOf);
|
|
134
|
+
counts.set(c, r.size);
|
|
135
|
+
for (const d of r) hidden.add(d);
|
|
136
|
+
}
|
|
137
|
+
return { hidden, counts };
|
|
138
|
+
});
|
|
139
|
+
const live = $derived(hiding.hidden.size ? nodes.filter((n) => !hiding.hidden.has(n.id)) : nodes);
|
|
140
|
+
const liveEdges = $derived(hiddenEdgeKinds.length ? edges.filter((e) => !hiddenEdgeKinds.includes(e.kind ?? 'neutral')) : edges);
|
|
141
|
+
let hl = $state<Set<string> | null>(null);
|
|
142
|
+
|
|
143
|
+
// ---- large graphs: only nodes and edges near the viewport are in the DOM. The window is snapped to a
|
|
144
|
+
// coarse grid so panning does not rebuild anything until it crosses a cell.
|
|
145
|
+
const BIG = 150, CELL = 320;
|
|
146
|
+
const big = $derived(live.length > BIG);
|
|
147
|
+
const minK = $derived(big ? 0.04 : 0.2);
|
|
148
|
+
const vx0 = $derived(big ? Math.floor(-tx / k / CELL - 1) * CELL : -Infinity);
|
|
149
|
+
const vy0 = $derived(big ? Math.floor(-ty / k / CELL - 1) * CELL : -Infinity);
|
|
150
|
+
const vx1 = $derived(big ? Math.ceil((rw - tx) / k / CELL + 1) * CELL : Infinity);
|
|
151
|
+
const vy1 = $derived(big ? Math.ceil((rh - ty) / k / CELL + 1) * CELL : Infinity);
|
|
152
|
+
/** Zoomed far out on a big graph: nodes are drawn as plain tiles without content or ports. */
|
|
153
|
+
const lod = $derived(big && k < 0.3);
|
|
154
|
+
|
|
155
|
+
const shown = $derived(live.map((n) => (drag?.ids.includes(n.id) ? { ...n, x: n.x + drag.dx, y: n.y + drag.dy } : n)));
|
|
99
156
|
const byId = $derived(new Map(shown.map((n) => [n.id, n])));
|
|
157
|
+
const visible = $derived(big ? shown.filter((n) => n.x + W(n) >= vx0 && n.x <= vx1 && n.y + H(n) >= vy0 && n.y <= vy1) : shown);
|
|
100
158
|
const rects = $derived<Rect[]>(shown.map((n) => ({ id: n.id, x: n.x, y: n.y, w: W(n), h: H(n) })));
|
|
101
|
-
|
|
159
|
+
// obstacle-avoiding routing is O(nodes) per edge: large graphs fall back to the cheap smoothstep router
|
|
160
|
+
const needsObstacles = $derived(!big && (edgeType === 'avoid' || edges.some((e) => e.type === 'avoid')));
|
|
161
|
+
const routeType = (t: EdgeType): EdgeType => (big && t === 'avoid' ? 'smoothstep' : t);
|
|
102
162
|
|
|
103
163
|
const findPort = (n: GraphNode, id: string | undefined, kind: 'in' | 'out') => {
|
|
104
164
|
const ps = portsOf(n);
|
|
105
165
|
return ps.find((p) => p.kind === kind && (id == null || p.id === id)) ?? ps.find((p) => p.kind === kind);
|
|
106
166
|
};
|
|
107
167
|
const geo = $derived(
|
|
108
|
-
|
|
168
|
+
liveEdges.flatMap((e) => {
|
|
109
169
|
const a = byId.get(e.from), b = byId.get(e.to);
|
|
110
170
|
if (!a || !b) return [];
|
|
171
|
+
if (big && (Math.max(a.x + W(a), b.x + W(b)) < vx0 || Math.min(a.x, b.x) > vx1 || Math.max(a.y + H(a), b.y + H(b)) < vy0 || Math.min(a.y, b.y) > vy1)) return [];
|
|
111
172
|
const pa = findPort(a, e.fromPort, 'out'), pb = findPort(b, e.toPort, 'in');
|
|
112
173
|
if (!pa || !pb) return [];
|
|
113
|
-
const r = routeEdge(e.type ?? edgeType, portAnchor(a, pa, portsOf(a)), portAnchor(b, pb, portsOf(b)), needsObstacles ? rects : []);
|
|
174
|
+
const r = routeEdge(routeType(e.type ?? edgeType), portAnchor(a, pa, portsOf(a)), portAnchor(b, pb, portsOf(b)), needsObstacles ? rects : []);
|
|
114
175
|
return [{ e, d: r.d, m: r.mid }];
|
|
115
176
|
})
|
|
116
177
|
);
|
|
@@ -119,22 +180,66 @@
|
|
|
119
180
|
return { x: (cx - r.left - tx) / k, y: (cy - r.top - ty) / k };
|
|
120
181
|
};
|
|
121
182
|
|
|
183
|
+
const bounds = () => {
|
|
184
|
+
let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
|
|
185
|
+
for (const n of live) { 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)); }
|
|
186
|
+
return { x0, y0, x1, y1 };
|
|
187
|
+
};
|
|
188
|
+
/** Zoom and pan so every visible node is in view. */
|
|
122
189
|
export function fit(padding = 48) {
|
|
123
|
-
if (!
|
|
124
|
-
const x0
|
|
125
|
-
const x1 = Math.max(...nodes.map((n) => n.x + W(n))), y1 = Math.max(...nodes.map((n) => n.y + H(n)));
|
|
190
|
+
if (!live.length || !root) return;
|
|
191
|
+
const { x0, y0, x1, y1 } = bounds();
|
|
126
192
|
const { width, height: hh } = root.getBoundingClientRect();
|
|
127
|
-
k = Math.min(1.5, Math.max(
|
|
193
|
+
k = Math.min(1.5, Math.max(minK, Math.min((width - padding * 2) / (x1 - x0), (hh - padding * 2) / (y1 - y0))));
|
|
128
194
|
tx = (width - (x1 - x0) * k) / 2 - x0 * k;
|
|
129
195
|
ty = (hh - (y1 - y0) * k) / 2 - y0 * k;
|
|
130
196
|
}
|
|
197
|
+
/** Pan (keeping the zoom) so the middle of the graph is in the middle of the view. */
|
|
198
|
+
export function center() {
|
|
199
|
+
if (!live.length || !root) return;
|
|
200
|
+
const { x0, y0, x1, y1 } = bounds();
|
|
201
|
+
tx = root.clientWidth / 2 - ((x0 + x1) / 2) * k;
|
|
202
|
+
ty = root.clientHeight / 2 - ((y0 + y1) / 2) * k;
|
|
203
|
+
}
|
|
204
|
+
/** Centre a node in the view and zoom to `scale` (default: at least 1). Returns false if the node is missing or hidden by a collapse. */
|
|
205
|
+
export function zoomTo(id: string, opts: { scale?: number } = {}): boolean {
|
|
206
|
+
const n = byId.get(id);
|
|
207
|
+
if (!n || !root) return false;
|
|
208
|
+
k = Math.min(2.5, Math.max(minK, opts.scale ?? Math.max(k, 1)));
|
|
209
|
+
tx = root.clientWidth / 2 - (n.x + W(n) / 2) * k;
|
|
210
|
+
ty = root.clientHeight / 2 - (n.y + H(n) / 2) * k;
|
|
211
|
+
return true;
|
|
212
|
+
}
|
|
213
|
+
/** Dim everything except the node and its upstream (`up`), downstream (`down`) or both neighbourhoods. */
|
|
214
|
+
export function highlight(id: string, direction: HighlightDirection = 'both') {
|
|
215
|
+
if (!nodes.some((n) => n.id === id)) return;
|
|
216
|
+
const s = new Set<string>([id]);
|
|
217
|
+
if (direction !== 'down') for (const v of reach(id, parentsOf)) s.add(v);
|
|
218
|
+
if (direction !== 'up') for (const v of reach(id, childrenOf)) s.add(v);
|
|
219
|
+
hl = s;
|
|
220
|
+
}
|
|
221
|
+
export function clearHighlight() { hl = null; }
|
|
222
|
+
/** Hide everything downstream of a node. */
|
|
223
|
+
export function collapse(id: string) {
|
|
224
|
+
if (collapsed.includes(id) || !nodes.some((n) => n.id === id)) return;
|
|
225
|
+
collapsed = [...collapsed, id];
|
|
226
|
+
const gone = reach(id, childrenOf);
|
|
227
|
+
if (selected.some((s) => gone.has(s))) selected = selected.filter((s) => !gone.has(s));
|
|
228
|
+
}
|
|
229
|
+
export function expand(id: string) { if (collapsed.includes(id)) collapsed = collapsed.filter((c) => c !== id); }
|
|
230
|
+
/** Re-run the `layout` algorithm over all nodes, then fit. */
|
|
131
231
|
export function layout() {
|
|
132
|
-
nodes =
|
|
232
|
+
nodes = computeLayout();
|
|
133
233
|
onchange?.();
|
|
134
234
|
requestAnimationFrame(() => fit());
|
|
135
235
|
}
|
|
236
|
+
function computeLayout(): GraphNode[] {
|
|
237
|
+
if (layoutMode === 'force') return forceLayout(nodes, edges);
|
|
238
|
+
if (layoutMode === 'radial') return radialLayout(nodes, edges, { root: selected[0] });
|
|
239
|
+
return autoLayout(nodes, edges, { direction });
|
|
240
|
+
}
|
|
136
241
|
function zoomAt(factor: number, cx: number, cy: number) {
|
|
137
|
-
const nk = Math.min(2.5, Math.max(
|
|
242
|
+
const nk = Math.min(2.5, Math.max(minK, k * factor));
|
|
138
243
|
tx = cx - (cx - tx) * (nk / k);
|
|
139
244
|
ty = cy - (cy - ty) * (nk / k);
|
|
140
245
|
k = nk;
|
|
@@ -150,13 +255,14 @@
|
|
|
150
255
|
zoomAt(Math.exp(-e.deltaY * (e.ctrlKey ? 0.01 : 0.0015)), e.clientX - r.left, e.clientY - r.top);
|
|
151
256
|
};
|
|
152
257
|
root.addEventListener('wheel', wheel, { passive: false });
|
|
258
|
+
if (layoutMode !== 'layered' && nodes.length) nodes = computeLayout();
|
|
153
259
|
if (fitOnMount) fit();
|
|
154
260
|
return () => root.removeEventListener('wheel', wheel);
|
|
155
261
|
});
|
|
156
262
|
|
|
157
263
|
// ---- background: pan + clear selection
|
|
158
264
|
function bgDown(e: PointerEvent) {
|
|
159
|
-
if ((e.target as HTMLElement).closest('.node, .controls, .edge-hit, .elabel, .minimap')) return;
|
|
265
|
+
if ((e.target as HTMLElement).closest('.node, .controls, .edge-hit, .elabel, .minimap, .legend')) return;
|
|
160
266
|
selected = []; selectedEdge = undefined;
|
|
161
267
|
root.focus();
|
|
162
268
|
const sx = e.clientX, sy = e.clientY, tx0 = tx, ty0 = ty;
|
|
@@ -261,11 +367,57 @@
|
|
|
261
367
|
selected = []; selectedEdge = undefined;
|
|
262
368
|
onchange?.();
|
|
263
369
|
}
|
|
370
|
+
|
|
371
|
+
// ---- legend
|
|
372
|
+
const cap = (s: string) => s.charAt(0).toUpperCase() + s.slice(1);
|
|
373
|
+
const legendData = $derived.by(() => {
|
|
374
|
+
if (!legend) return null;
|
|
375
|
+
const kinds = (xs: { kind?: NodeKind }[]) => [...new Set(xs.map((x) => x.kind ?? 'neutral'))];
|
|
376
|
+
const nodeRows = legend === true ? kinds(live).map((x) => [x, cap(x)] as [string, string]) : Object.entries(legend.nodes ?? {}) as [string, string][];
|
|
377
|
+
const edgeRows = legend === true ? kinds(edges).map((x) => [x, cap(x)] as [string, string]) : Object.entries(legend.edges ?? {}) as [string, string][];
|
|
378
|
+
return nodeRows.length || edgeRows.length ? { nodeRows, edgeRows } : null;
|
|
379
|
+
});
|
|
380
|
+
const toggleEdgeKind = (kind: string) => (hiddenEdgeKinds = hiddenEdgeKinds.includes(kind) ? hiddenEdgeKinds.filter((x) => x !== kind) : [...hiddenEdgeKinds, kind]);
|
|
381
|
+
|
|
382
|
+
// ---- read-only keyboard traversal: arrows hop to the nearest node in that direction
|
|
383
|
+
const DIRS: Record<string, [number, number]> = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] };
|
|
384
|
+
function neighbour(from: GraphNode | undefined, key: string): GraphNode | undefined {
|
|
385
|
+
if (!from) return shown.reduce<GraphNode | undefined>((m, n) => (!m || n.y + n.x * 0.01 < m.y + m.x * 0.01 ? n : m), undefined);
|
|
386
|
+
const [ux, uy] = DIRS[key];
|
|
387
|
+
const fx = from.x + W(from) / 2, fy = from.y + H(from) / 2;
|
|
388
|
+
let best: GraphNode | undefined, bestScore = Infinity;
|
|
389
|
+
for (const n of shown) {
|
|
390
|
+
if (n.id === from.id) continue;
|
|
391
|
+
const vx = n.x + W(n) / 2 - fx, vy = n.y + H(n) / 2 - fy, d = Math.hypot(vx, vy) || 1;
|
|
392
|
+
const cos = (vx * ux + vy * uy) / d;
|
|
393
|
+
if (cos < 0.5) continue; // within 60 degrees of the arrow
|
|
394
|
+
const score = d / cos;
|
|
395
|
+
if (score < bestScore) { best = n; bestScore = score; }
|
|
396
|
+
}
|
|
397
|
+
return best;
|
|
398
|
+
}
|
|
399
|
+
function goTo(n: GraphNode) {
|
|
400
|
+
selected = [n.id]; selectedEdge = undefined;
|
|
401
|
+
const pad = 32, sx = n.x * k + tx, sy = n.y * k + ty, sw = W(n) * k, sh = H(n) * k;
|
|
402
|
+
if (sx < pad) tx += pad - sx; else if (sx + sw > rw - pad) tx -= sx + sw - (rw - pad);
|
|
403
|
+
if (sy < pad) ty += pad - sy; else if (sy + sh > rh - pad) ty -= sy + sh - (rh - pad);
|
|
404
|
+
tick().then(() => root?.querySelector<HTMLElement>(`.node[data-id="${CSS.escape(n.id)}"]`)?.focus());
|
|
405
|
+
}
|
|
264
406
|
function onKey(e: KeyboardEvent) {
|
|
265
407
|
if ((e.target as HTMLElement).closest('input, textarea, select, [contenteditable="true"]')) return; // typing inside a node
|
|
266
408
|
const step = e.shiftKey ? 32 : 8;
|
|
267
409
|
const mv = { ArrowLeft: [-step, 0], ArrowRight: [step, 0], ArrowUp: [0, -step], ArrowDown: [0, step] }[e.key];
|
|
268
|
-
if (mv &&
|
|
410
|
+
if (mv && !editable) {
|
|
411
|
+
const cur = (e.target as HTMLElement).closest<HTMLElement>('.node')?.dataset.id ?? selected[0];
|
|
412
|
+
const next = neighbour(cur ? byId.get(cur) : undefined, e.key);
|
|
413
|
+
if (!next) return;
|
|
414
|
+
goTo(next);
|
|
415
|
+
} else if (e.key === 'Enter' && (e.target as HTMLElement).classList.contains('node')) {
|
|
416
|
+
const id = (e.target as HTMLElement).dataset.id!;
|
|
417
|
+
if (collapsed.includes(id)) expand(id);
|
|
418
|
+
else if (childrenOf.get(id)?.length) collapse(id);
|
|
419
|
+
else return;
|
|
420
|
+
} else if (mv && selected.length && editable) {
|
|
269
421
|
nodes = nodes.map((n) => (selected.includes(n.id) ? { ...n, x: n.x + mv[0], y: n.y + mv[1] } : n));
|
|
270
422
|
} else if (e.key === 'Delete' || e.key === 'Backspace') remove();
|
|
271
423
|
else if (e.key === '+' || e.key === '=') zoomCenter(1.2);
|
|
@@ -280,7 +432,7 @@
|
|
|
280
432
|
<!-- svelte-ignore a11y_no_noninteractive_tabindex, a11y_no_noninteractive_element_interactions -->
|
|
281
433
|
<div
|
|
282
434
|
class="trg" bind:this={root} bind:clientWidth={rw} bind:clientHeight={rh} role="application" tabindex="0" data-panning={panning}
|
|
283
|
-
aria-label=
|
|
435
|
+
aria-label={editable ? 'Graph editor. Arrow keys move selected nodes, Delete removes, plus and minus zoom, 0 fits to view.' : 'Graph. Arrow keys move between nodes, Enter collapses or expands, plus and minus zoom, 0 fits to view.'}
|
|
284
436
|
style:height={typeof height === 'number' ? `${height}px` : height}
|
|
285
437
|
style:--tx="{tx}px" style:--ty="{ty}px" style:--k={k}
|
|
286
438
|
onpointerdown={bgDown} onkeydown={onKey}
|
|
@@ -293,11 +445,15 @@
|
|
|
293
445
|
</marker>
|
|
294
446
|
</defs>
|
|
295
447
|
{#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}>
|
|
448
|
+
<g class="edge" data-kind={g.e.kind ?? 'neutral'} data-selected={selectedEdge === g.e.id} data-animated={g.e.animated} data-dim={hl ? !(hl.has(g.e.from) && hl.has(g.e.to)) : undefined}>
|
|
297
449
|
<path class="line" d={g.d} marker-end="url(#trg-arrow)" />
|
|
450
|
+
{#if !lod}
|
|
298
451
|
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_noninteractive_element_interactions -->
|
|
299
452
|
<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
|
-
{
|
|
453
|
+
{/if}
|
|
454
|
+
{#if lod}
|
|
455
|
+
<!-- labels are unreadable this far out -->
|
|
456
|
+
{:else if edgeLabel}
|
|
301
457
|
<foreignObject x={g.m.x - 90} y={g.m.y - 16} width="180" height="32" class="elabel">
|
|
302
458
|
<div class="elabel-in">{@render edgeLabel(g.e)}</div>
|
|
303
459
|
</foreignObject>
|
|
@@ -312,17 +468,20 @@
|
|
|
312
468
|
{#if linkRoute}<path class="line temp" d={linkRoute} />{/if}
|
|
313
469
|
</svg>
|
|
314
470
|
|
|
315
|
-
{#each
|
|
471
|
+
{#each visible as n (n.id)}
|
|
316
472
|
{@const render = rendererOf(n)}
|
|
317
473
|
{@const bare = !!render && n.chrome !== true}
|
|
318
|
-
{@const ports = portsOf(n)}
|
|
474
|
+
{@const ports = lod ? [] : portsOf(n)}
|
|
319
475
|
<div
|
|
320
476
|
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)}
|
|
477
|
+
data-id={n.id} data-kind={n.kind ?? 'neutral'} data-shape={n.shape && n.shape !== 'rect' ? n.shape : undefined} data-bare={bare} data-selected={selected.includes(n.id)} data-dragging={!!drag?.ids.includes(n.id)}
|
|
478
|
+
data-dim={hl ? !hl.has(n.id) : undefined} data-collapsed={hiding.counts.has(n.id) ? true : undefined}
|
|
322
479
|
style:left="{n.x}px" style:top="{n.y}px" style:width="{W(n)}px" style:height="{H(n)}px"
|
|
323
480
|
onpointerdown={(e) => nodeDown(e, n)}
|
|
324
481
|
>
|
|
325
|
-
{#if
|
|
482
|
+
{#if lod}
|
|
483
|
+
<!-- tile only -->
|
|
484
|
+
{:else if render}
|
|
326
485
|
<div class="custom" class:fill={bare}>{@render render(ctxOf(n))}</div>
|
|
327
486
|
{:else}
|
|
328
487
|
<div class="content">
|
|
@@ -343,6 +502,9 @@
|
|
|
343
502
|
{/if}
|
|
344
503
|
{#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
504
|
{/each}
|
|
505
|
+
{#if !lod && hiding.counts.has(n.id)}
|
|
506
|
+
<button type="button" class="expander nodrag" aria-label="Expand {n.label}, {hiding.counts.get(n.id)} hidden" onclick={(e) => { e.stopPropagation(); expand(n.id); }}>+{hiding.counts.get(n.id)}</button>
|
|
507
|
+
{/if}
|
|
346
508
|
</div>
|
|
347
509
|
{/each}
|
|
348
510
|
</div>
|
|
@@ -351,7 +513,7 @@
|
|
|
351
513
|
<div class="minimap" data-pos={minimapPosition} style:width="{MW}px" style:height="{MH}px">
|
|
352
514
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
353
515
|
<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)}
|
|
516
|
+
{#each big ? [] : geo as g (g.e.id)}
|
|
355
517
|
{@const a = byId.get(g.e.from)}{@const bn = byId.get(g.e.to)}
|
|
356
518
|
{#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
519
|
{/each}
|
|
@@ -363,6 +525,19 @@
|
|
|
363
525
|
</div>
|
|
364
526
|
{/if}
|
|
365
527
|
|
|
528
|
+
{#if legendData}
|
|
529
|
+
<div class="legend" role="group" aria-label="Legend">
|
|
530
|
+
{#each legendData.nodeRows as [kind, label] (kind)}
|
|
531
|
+
<span class="lg-item"><span class="lg-node" data-kind={kind}></span>{label}</span>
|
|
532
|
+
{/each}
|
|
533
|
+
{#each legendData.edgeRows as [kind, label] (kind)}
|
|
534
|
+
<button type="button" class="lg-item lg-edge" data-kind={kind} aria-pressed={!hiddenEdgeKinds.includes(kind)} title="Show or hide {label} edges" onclick={() => toggleEdgeKind(kind)}>
|
|
535
|
+
<span class="lg-line"></span>{label}
|
|
536
|
+
</button>
|
|
537
|
+
{/each}
|
|
538
|
+
</div>
|
|
539
|
+
{/if}
|
|
540
|
+
|
|
366
541
|
<div class="controls" role="toolbar" aria-label="Graph controls">
|
|
367
542
|
<Button variant="minimal" intent="neutral" size="sm" icon="plus" aria-label="Zoom in" onclick={() => zoomCenter(1.2)} />
|
|
368
543
|
<Button variant="minimal" intent="neutral" size="sm" icon="minus" aria-label="Zoom out" onclick={() => zoomCenter(1 / 1.2)} />
|
|
@@ -425,6 +600,35 @@
|
|
|
425
600
|
.node[data-bare='true']::before { display: none; }
|
|
426
601
|
.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
602
|
.node[data-bare='true']:focus-visible { outline: 2px solid var(--tr-primary); outline-offset: 3px; box-shadow: none; }
|
|
603
|
+
.node { transition: box-shadow var(--tr-dur) var(--tr-ease), border-color var(--tr-dur) var(--tr-ease), opacity var(--tr-dur) var(--tr-ease); }
|
|
604
|
+
.node[data-dim='true'] { opacity: 0.22; }
|
|
605
|
+
.edge[data-dim='true'] { opacity: 0.12; }
|
|
606
|
+
/* shapes: circle and diamond centre their text; ports sit on the bounding box, which touches both outlines */
|
|
607
|
+
.node[data-shape] .content { padding: 0 14%; text-align: center; position: relative; z-index: 1; }
|
|
608
|
+
.node[data-shape]::before { display: none; }
|
|
609
|
+
.node[data-shape='circle'] { border-radius: 50%; border: 2px solid var(--c); }
|
|
610
|
+
.node[data-shape='circle'][data-selected='true'] { border-color: var(--tr-primary); }
|
|
611
|
+
.node[data-shape='diamond'] { background: none; border: 0; box-shadow: none; }
|
|
612
|
+
.node[data-shape='diamond']::before, .node[data-shape='diamond']::after { display: block; content: ''; position: absolute; inset: 0; width: auto; border-radius: 0; background: var(--c); clip-path: polygon(50% 0, 100% 50%, 50% 100%, 0 50%); }
|
|
613
|
+
.node[data-shape='diamond']::after { inset: 2px; background: var(--tr-surface-container-low); }
|
|
614
|
+
.node[data-shape='diamond'][data-selected='true']::before, .node[data-shape='diamond']:focus-visible::before { background: var(--tr-primary); }
|
|
615
|
+
.node[data-shape='diamond'][data-selected='true']::after { background: color-mix(in srgb, var(--tr-primary-container) 40%, var(--tr-surface-container-low)); }
|
|
616
|
+
.node[data-shape='diamond'][data-selected='true'], .node[data-shape='diamond']:focus-visible { box-shadow: none; background: none; }
|
|
617
|
+
.expander { position: absolute; right: -10px; top: -10px; z-index: 4; min-width: 22px; height: 22px; padding: 0 6px; box-sizing: border-box; border-radius: var(--tr-radius-pill); border: 1px solid var(--tr-outline); background: var(--tr-surface-container-highest); color: var(--tr-on-surface); font: 600 11px var(--tr-font-sans); cursor: pointer; }
|
|
618
|
+
.expander:hover { border-color: var(--tr-primary); }
|
|
619
|
+
.expander:focus-visible { outline: none; box-shadow: var(--tr-focus-ring); }
|
|
620
|
+
|
|
621
|
+
.legend { position: absolute; left: 8px; top: 8px; z-index: 4; display: flex; flex-wrap: wrap; gap: 4px 12px; max-width: calc(100% - 200px); padding: 6px 10px; 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); cursor: default; font: 500 var(--tr-text-sm) var(--tr-font-sans); color: var(--tr-on-surface); }
|
|
622
|
+
.lg-item { --c: var(--tr-outline); display: inline-flex; align-items: center; gap: 6px; }
|
|
623
|
+
[data-kind='primary'] { --c: var(--tr-primary); }
|
|
624
|
+
[data-kind='success'] { --c: var(--tr-success); }
|
|
625
|
+
[data-kind='warning'] { --c: var(--tr-warning); }
|
|
626
|
+
[data-kind='danger'] { --c: var(--tr-danger); }
|
|
627
|
+
.lg-node { width: 12px; height: 12px; border-radius: 3px; background: var(--c); }
|
|
628
|
+
.lg-line { width: 18px; height: 0; border-top: 3px solid var(--c); border-radius: 2px; }
|
|
629
|
+
.lg-edge { background: none; border: 0; padding: 0; margin: 0; font: inherit; color: inherit; cursor: pointer; border-radius: var(--tr-radius-sm); }
|
|
630
|
+
.lg-edge[aria-pressed='false'] { opacity: 0.45; text-decoration: line-through; }
|
|
631
|
+
.lg-edge:focus-visible { outline: none; box-shadow: var(--tr-focus-ring); }
|
|
428
632
|
.custom { width: 100%; min-width: 0; }
|
|
429
633
|
.custom.fill { height: 100%; }
|
|
430
634
|
.content { flex: 1; min-width: 0; padding: 0 12px 0 16px; line-height: 1.25; }
|
package/dist/Graph.svelte.d.ts
CHANGED
|
@@ -1,16 +1,24 @@
|
|
|
1
1
|
import { type Snippet } from 'svelte';
|
|
2
|
-
import { type EdgeType, type GraphEdge, type GraphNode, type NodeRenderer } from './types.js';
|
|
2
|
+
import { type EdgeType, type GraphEdge, type GraphLayout, type GraphLegend, type GraphNode, type HighlightDirection, type NodeRenderer } from './types.js';
|
|
3
3
|
type $$ComponentProps = {
|
|
4
4
|
nodes?: GraphNode[];
|
|
5
5
|
edges?: GraphEdge[];
|
|
6
6
|
/** Selected node ids. */
|
|
7
7
|
selected?: string[];
|
|
8
8
|
selectedEdge?: string;
|
|
9
|
+
/** Ids of collapsed nodes: everything downstream of them is hidden. Use `collapse(id)` / `expand(id)` or bind this. */
|
|
10
|
+
collapsed?: string[];
|
|
11
|
+
/** Edge kinds currently filtered out (the legend toggles these). Edges without a kind count as `neutral`. */
|
|
12
|
+
hiddenEdgeKinds?: string[];
|
|
9
13
|
height?: number | string;
|
|
10
|
-
/** Default routing for every edge (an edge's own `type` wins): bezier, straight, step, smoothstep, or avoid (steers around nodes). */
|
|
14
|
+
/** Default routing for every edge (an edge's own `type` wins): bezier, straight, step, smoothstep, or avoid (steers around nodes; falls back to smoothstep on large graphs). */
|
|
11
15
|
edgeType?: EdgeType;
|
|
12
|
-
/** Which sides the default ports use, and the direction of
|
|
16
|
+
/** Which sides the default ports use, and the direction of the layered layout. */
|
|
13
17
|
direction?: 'LR' | 'TB';
|
|
18
|
+
/** Algorithm used by `layout()` and the auto-layout button. `force` and `radial` also run once on mount. Default `layered` keeps your positions on mount. */
|
|
19
|
+
layout?: GraphLayout;
|
|
20
|
+
/** Key for node and edge kinds. `true` derives it from the kinds in use; or pass labels per kind. Edge entries toggle `hiddenEdgeKinds`. */
|
|
21
|
+
legend?: boolean | GraphLegend;
|
|
14
22
|
/** Drag from an output port to an input port to create edges. */
|
|
15
23
|
connectable?: boolean;
|
|
16
24
|
/** Allow moving and deleting. */
|
|
@@ -40,7 +48,15 @@ type $$ComponentProps = {
|
|
|
40
48
|
};
|
|
41
49
|
declare const Graph: import("svelte").Component<$$ComponentProps, {
|
|
42
50
|
fit: (padding?: number) => void;
|
|
51
|
+
center: () => void;
|
|
52
|
+
zoomTo: (id: string, opts?: {
|
|
53
|
+
scale?: number;
|
|
54
|
+
}) => boolean;
|
|
55
|
+
highlight: (id: string, direction?: HighlightDirection) => void;
|
|
56
|
+
clearHighlight: () => void;
|
|
57
|
+
collapse: (id: string) => void;
|
|
58
|
+
expand: (id: string) => void;
|
|
43
59
|
layout: () => void;
|
|
44
|
-
}, "selected" | "nodes" | "edges" | "selectedEdge">;
|
|
60
|
+
}, "collapsed" | "selected" | "nodes" | "edges" | "selectedEdge" | "hiddenEdgeKinds">;
|
|
45
61
|
type Graph = ReturnType<typeof Graph>;
|
|
46
62
|
export default Graph;
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
export { default as Graph } from './Graph.svelte';
|
|
2
|
-
export { autoLayout } from './layout.js';
|
|
2
|
+
export { autoLayout, forceLayout, radialLayout } from './layout.js';
|
|
3
3
|
export { routeEdge, portAnchor, defaultPorts } from './routing.js';
|
|
4
|
-
export type { GraphNode, GraphEdge, GraphPort, NodeKind, NodeCtx, NodeRenderer, EdgeType, Side } from './types.js';
|
|
4
|
+
export type { GraphNode, GraphEdge, GraphPort, NodeKind, NodeCtx, NodeRenderer, EdgeType, Side, NodeShape, GraphLayout, GraphLegend, HighlightDirection } from './types.js';
|
package/dist/index.js
CHANGED
package/dist/layout.d.ts
CHANGED
|
@@ -8,3 +8,24 @@ export declare function autoLayout(nodes: GraphNode[], edges: GraphEdge[], opts?
|
|
|
8
8
|
gapY?: number;
|
|
9
9
|
direction?: 'LR' | 'TB';
|
|
10
10
|
}): GraphNode[];
|
|
11
|
+
/**
|
|
12
|
+
* Force-directed layout (Fruchterman-Reingold). Deterministic for a given `seed`; repulsion is
|
|
13
|
+
* computed through a uniform grid so large graphs (1000+ nodes) stay fast. Ends with a rectangle
|
|
14
|
+
* overlap-removal pass. Returns new node objects.
|
|
15
|
+
*/
|
|
16
|
+
export declare function forceLayout(nodes: GraphNode[], edges: GraphEdge[], opts?: {
|
|
17
|
+
iterations?: number;
|
|
18
|
+
seed?: number;
|
|
19
|
+
spacing?: number;
|
|
20
|
+
gravity?: number;
|
|
21
|
+
}): GraphNode[];
|
|
22
|
+
/**
|
|
23
|
+
* Radial layout: the `root` (default: the first node with no incoming edge) in the centre, every
|
|
24
|
+
* other node on a ring by its distance from the root (edges treated as undirected). Subtrees get
|
|
25
|
+
* angular wedges proportional to their leaf count; unreachable components hang off the root.
|
|
26
|
+
*/
|
|
27
|
+
export declare function radialLayout(nodes: GraphNode[], edges: GraphEdge[], opts?: {
|
|
28
|
+
root?: string;
|
|
29
|
+
ringGap?: number;
|
|
30
|
+
spacing?: number;
|
|
31
|
+
}): GraphNode[];
|
package/dist/layout.js
CHANGED
|
@@ -57,3 +57,256 @@ export function autoLayout(nodes, edges, opts = {}) {
|
|
|
57
57
|
});
|
|
58
58
|
return nodes.map((n) => ({ ...n, ...pos.get(n.id) }));
|
|
59
59
|
}
|
|
60
|
+
/** Small deterministic PRNG (mulberry32) so layouts are reproducible for a given seed. */
|
|
61
|
+
function rng(seed) {
|
|
62
|
+
let a = seed >>> 0;
|
|
63
|
+
return () => {
|
|
64
|
+
a = (a + 0x6d2b79f5) >>> 0;
|
|
65
|
+
let t = a;
|
|
66
|
+
t = Math.imul(t ^ (t >>> 15), t | 1);
|
|
67
|
+
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
|
68
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
const sizeOf = (n) => ({ w: n.width ?? NODE_W, h: n.height ?? NODE_H });
|
|
72
|
+
/** Valid, de-duplicated, non-loop edges as index pairs. */
|
|
73
|
+
function pairsOf(nodes, edges) {
|
|
74
|
+
const idx = new Map(nodes.map((n, i) => [n.id, i]));
|
|
75
|
+
const seen = new Set();
|
|
76
|
+
const out = [];
|
|
77
|
+
for (const e of edges) {
|
|
78
|
+
const a = idx.get(e.from), b = idx.get(e.to);
|
|
79
|
+
if (a == null || b == null || a === b)
|
|
80
|
+
continue;
|
|
81
|
+
const key = a < b ? `${a}:${b}` : `${b}:${a}`;
|
|
82
|
+
if (seen.has(key))
|
|
83
|
+
continue;
|
|
84
|
+
seen.add(key);
|
|
85
|
+
out.push([a, b]);
|
|
86
|
+
}
|
|
87
|
+
return out;
|
|
88
|
+
}
|
|
89
|
+
/** Shift so the top-left of the bounding box is the origin and round to whole units. */
|
|
90
|
+
function normalise(nodes, cx, cy) {
|
|
91
|
+
let x0 = Infinity, y0 = Infinity;
|
|
92
|
+
nodes.forEach((n, i) => {
|
|
93
|
+
const { w, h } = sizeOf(n);
|
|
94
|
+
x0 = Math.min(x0, cx[i] - w / 2);
|
|
95
|
+
y0 = Math.min(y0, cy[i] - h / 2);
|
|
96
|
+
});
|
|
97
|
+
return nodes.map((n, i) => {
|
|
98
|
+
const { w, h } = sizeOf(n);
|
|
99
|
+
return { ...n, x: Math.round(cx[i] - w / 2 - x0), y: Math.round(cy[i] - h / 2 - y0) };
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Force-directed layout (Fruchterman-Reingold). Deterministic for a given `seed`; repulsion is
|
|
104
|
+
* computed through a uniform grid so large graphs (1000+ nodes) stay fast. Ends with a rectangle
|
|
105
|
+
* overlap-removal pass. Returns new node objects.
|
|
106
|
+
*/
|
|
107
|
+
export function forceLayout(nodes, edges, opts = {}) {
|
|
108
|
+
const n = nodes.length;
|
|
109
|
+
if (!n)
|
|
110
|
+
return [];
|
|
111
|
+
const { seed = 1, spacing = 260, gravity = 0.02 } = opts;
|
|
112
|
+
const iterations = opts.iterations ?? (n > 400 ? 120 : n > 150 ? 220 : 300);
|
|
113
|
+
const rand = rng(seed);
|
|
114
|
+
const k = spacing;
|
|
115
|
+
const pairs = pairsOf(nodes, edges);
|
|
116
|
+
const px = new Float64Array(n), py = new Float64Array(n);
|
|
117
|
+
for (let i = 0; i < n; i++) {
|
|
118
|
+
// phyllotaxis start: even coverage, seeded jitter breaks symmetry
|
|
119
|
+
const r = k * 0.55 * Math.sqrt(i + 1), a = i * 2.399963 + (rand() - 0.5) * 0.4;
|
|
120
|
+
px[i] = r * Math.cos(a);
|
|
121
|
+
py[i] = r * Math.sin(a);
|
|
122
|
+
}
|
|
123
|
+
const dx = new Float64Array(n), dy = new Float64Array(n);
|
|
124
|
+
const cutoff = k * 2.5;
|
|
125
|
+
const t0 = k * 1.5;
|
|
126
|
+
for (let it = 0; it < iterations; it++) {
|
|
127
|
+
dx.fill(0);
|
|
128
|
+
dy.fill(0);
|
|
129
|
+
const grid = new Map();
|
|
130
|
+
for (let i = 0; i < n; i++) {
|
|
131
|
+
const key = Math.floor(px[i] / cutoff) * 100003 + Math.floor(py[i] / cutoff);
|
|
132
|
+
const cell = grid.get(key);
|
|
133
|
+
if (cell)
|
|
134
|
+
cell.push(i);
|
|
135
|
+
else
|
|
136
|
+
grid.set(key, [i]);
|
|
137
|
+
}
|
|
138
|
+
for (let i = 0; i < n; i++) {
|
|
139
|
+
const gx = Math.floor(px[i] / cutoff), gy = Math.floor(py[i] / cutoff);
|
|
140
|
+
for (let ox = -1; ox <= 1; ox++)
|
|
141
|
+
for (let oy = -1; oy <= 1; oy++) {
|
|
142
|
+
const cell = grid.get((gx + ox) * 100003 + gy + oy);
|
|
143
|
+
if (!cell)
|
|
144
|
+
continue;
|
|
145
|
+
for (const j of cell) {
|
|
146
|
+
if (j <= i)
|
|
147
|
+
continue;
|
|
148
|
+
let ex = px[i] - px[j], ey = py[i] - py[j];
|
|
149
|
+
let d = Math.hypot(ex, ey);
|
|
150
|
+
if (d < 0.01) {
|
|
151
|
+
ex = rand() - 0.5;
|
|
152
|
+
ey = rand() - 0.5;
|
|
153
|
+
d = Math.hypot(ex, ey) || 1;
|
|
154
|
+
}
|
|
155
|
+
if (d > cutoff)
|
|
156
|
+
continue;
|
|
157
|
+
const f = (k * k) / d / d; // k² / d, divided by d for the unit vector
|
|
158
|
+
dx[i] += ex * f;
|
|
159
|
+
dy[i] += ey * f;
|
|
160
|
+
dx[j] -= ex * f;
|
|
161
|
+
dy[j] -= ey * f;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
for (const [a, b] of pairs) {
|
|
166
|
+
const ex = px[a] - px[b], ey = py[a] - py[b];
|
|
167
|
+
const f = (Math.hypot(ex, ey) || 0.01) / k; // d² / k, divided by d
|
|
168
|
+
dx[a] -= ex * f;
|
|
169
|
+
dy[a] -= ey * f;
|
|
170
|
+
dx[b] += ex * f;
|
|
171
|
+
dy[b] += ey * f;
|
|
172
|
+
}
|
|
173
|
+
const t = t0 * (1 - it / iterations) + 1;
|
|
174
|
+
for (let i = 0; i < n; i++) {
|
|
175
|
+
dx[i] -= px[i] * gravity;
|
|
176
|
+
dy[i] -= py[i] * gravity;
|
|
177
|
+
const len = Math.hypot(dx[i], dy[i]) || 1;
|
|
178
|
+
const step = Math.min(len, t);
|
|
179
|
+
px[i] += (dx[i] / len) * step;
|
|
180
|
+
py[i] += (dy[i] / len) * step;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
removeOverlaps(nodes, px, py);
|
|
184
|
+
return normalise(nodes, px, py);
|
|
185
|
+
}
|
|
186
|
+
/** Push apart any rectangles that still overlap (grid-accelerated, bounded passes). */
|
|
187
|
+
function removeOverlaps(nodes, px, py, pad = 16) {
|
|
188
|
+
const n = nodes.length;
|
|
189
|
+
const sz = nodes.map(sizeOf);
|
|
190
|
+
const cell = Math.max(...sz.map((s) => Math.max(s.w, s.h))) + pad;
|
|
191
|
+
for (let pass = 0; pass < 40; pass++) {
|
|
192
|
+
const grid = new Map();
|
|
193
|
+
for (let i = 0; i < n; i++) {
|
|
194
|
+
const key = Math.floor(px[i] / cell) * 100003 + Math.floor(py[i] / cell);
|
|
195
|
+
const c = grid.get(key);
|
|
196
|
+
if (c)
|
|
197
|
+
c.push(i);
|
|
198
|
+
else
|
|
199
|
+
grid.set(key, [i]);
|
|
200
|
+
}
|
|
201
|
+
let moved = false;
|
|
202
|
+
for (let i = 0; i < n; i++) {
|
|
203
|
+
const gx = Math.floor(px[i] / cell), gy = Math.floor(py[i] / cell);
|
|
204
|
+
for (let ox = -1; ox <= 1; ox++)
|
|
205
|
+
for (let oy = -1; oy <= 1; oy++) {
|
|
206
|
+
for (const j of grid.get((gx + ox) * 100003 + gy + oy) ?? []) {
|
|
207
|
+
if (j <= i)
|
|
208
|
+
continue;
|
|
209
|
+
const ex = px[j] - px[i], ey = py[j] - py[i];
|
|
210
|
+
const px2 = (sz[i].w + sz[j].w) / 2 + pad - Math.abs(ex), py2 = (sz[i].h + sz[j].h) / 2 + pad - Math.abs(ey);
|
|
211
|
+
if (px2 <= 0 || py2 <= 0)
|
|
212
|
+
continue;
|
|
213
|
+
moved = true;
|
|
214
|
+
if (px2 < py2) {
|
|
215
|
+
const s = (ex >= 0 ? 1 : -1) * (px2 / 2 + 0.5);
|
|
216
|
+
px[i] -= s;
|
|
217
|
+
px[j] += s;
|
|
218
|
+
}
|
|
219
|
+
else {
|
|
220
|
+
const s = (ey >= 0 ? 1 : -1) * (py2 / 2 + 0.5);
|
|
221
|
+
py[i] -= s;
|
|
222
|
+
py[j] += s;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
if (!moved)
|
|
228
|
+
break;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* Radial layout: the `root` (default: the first node with no incoming edge) in the centre, every
|
|
233
|
+
* other node on a ring by its distance from the root (edges treated as undirected). Subtrees get
|
|
234
|
+
* angular wedges proportional to their leaf count; unreachable components hang off the root.
|
|
235
|
+
*/
|
|
236
|
+
export function radialLayout(nodes, edges, opts = {}) {
|
|
237
|
+
const n = nodes.length;
|
|
238
|
+
if (!n)
|
|
239
|
+
return [];
|
|
240
|
+
const { ringGap = 240, spacing = 200 } = opts;
|
|
241
|
+
const adj = nodes.map(() => []);
|
|
242
|
+
const hasIn = new Array(n).fill(false);
|
|
243
|
+
for (const [a, b] of pairsOf(nodes, edges)) {
|
|
244
|
+
adj[a].push(b);
|
|
245
|
+
adj[b].push(a);
|
|
246
|
+
}
|
|
247
|
+
const idx = new Map(nodes.map((nd, i) => [nd.id, i]));
|
|
248
|
+
for (const e of edges) {
|
|
249
|
+
const t = idx.get(e.to);
|
|
250
|
+
if (t != null && e.from !== e.to)
|
|
251
|
+
hasIn[t] = true;
|
|
252
|
+
}
|
|
253
|
+
let root = opts.root != null ? idx.get(opts.root) : undefined;
|
|
254
|
+
if (root == null)
|
|
255
|
+
root = hasIn.findIndex((v) => !v);
|
|
256
|
+
if (root < 0)
|
|
257
|
+
root = 0;
|
|
258
|
+
const parent = new Array(n).fill(-1), depth = new Array(n).fill(-1), order = [];
|
|
259
|
+
const bfs = (start, d0, p) => {
|
|
260
|
+
parent[start] = p;
|
|
261
|
+
depth[start] = d0;
|
|
262
|
+
order.push(start);
|
|
263
|
+
for (let qi = order.length - 1; qi < order.length; qi++) {
|
|
264
|
+
const u = order[qi];
|
|
265
|
+
for (const v of adj[u])
|
|
266
|
+
if (depth[v] < 0) {
|
|
267
|
+
depth[v] = depth[u] + 1;
|
|
268
|
+
parent[v] = u;
|
|
269
|
+
order.push(v);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
};
|
|
273
|
+
bfs(root, 0, -1);
|
|
274
|
+
for (let i = 0; i < n; i++)
|
|
275
|
+
if (depth[i] < 0)
|
|
276
|
+
bfs(i, 1, root);
|
|
277
|
+
const kids = nodes.map(() => []);
|
|
278
|
+
for (const i of order)
|
|
279
|
+
if (parent[i] >= 0)
|
|
280
|
+
kids[parent[i]].push(i);
|
|
281
|
+
const leaves = new Array(n).fill(0);
|
|
282
|
+
for (let qi = order.length - 1; qi >= 0; qi--) {
|
|
283
|
+
const u = order[qi];
|
|
284
|
+
if (!kids[u].length)
|
|
285
|
+
leaves[u] = 1;
|
|
286
|
+
if (parent[u] >= 0)
|
|
287
|
+
leaves[parent[u]] += leaves[u];
|
|
288
|
+
}
|
|
289
|
+
const lo = new Array(n).fill(0), hi = new Array(n).fill(0);
|
|
290
|
+
hi[root] = Math.PI * 2;
|
|
291
|
+
for (const u of order) {
|
|
292
|
+
let a = lo[u];
|
|
293
|
+
for (const c of kids[u]) {
|
|
294
|
+
lo[c] = a;
|
|
295
|
+
a += ((hi[u] - lo[u]) * leaves[c]) / leaves[u];
|
|
296
|
+
hi[c] = a;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
const maxD = depth.reduce((m, d) => Math.max(m, d), 0), count = new Array(maxD + 1).fill(0);
|
|
300
|
+
for (const d of depth)
|
|
301
|
+
count[d]++;
|
|
302
|
+
const radius = new Array(maxD + 1).fill(0);
|
|
303
|
+
for (let d = 1; d <= maxD; d++)
|
|
304
|
+
radius[d] = Math.max(radius[d - 1] + ringGap, (count[d] * spacing) / (Math.PI * 2));
|
|
305
|
+
const cx = new Float64Array(n), cy = new Float64Array(n);
|
|
306
|
+
for (let i = 0; i < n; i++) {
|
|
307
|
+
const a = (lo[i] + hi[i]) / 2, r = radius[depth[i]];
|
|
308
|
+
cx[i] = r * Math.cos(a);
|
|
309
|
+
cy[i] = r * Math.sin(a);
|
|
310
|
+
}
|
|
311
|
+
return normalise(nodes, cx, cy);
|
|
312
|
+
}
|
package/dist/types.d.ts
CHANGED
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
import type { Snippet } from 'svelte';
|
|
2
2
|
export type NodeKind = 'neutral' | 'primary' | 'success' | 'warning' | 'danger';
|
|
3
3
|
export type Side = 'left' | 'right' | 'top' | 'bottom';
|
|
4
|
+
export type NodeShape = 'rect' | 'circle' | 'diamond';
|
|
5
|
+
export type GraphLayout = 'layered' | 'force' | 'radial';
|
|
6
|
+
export type HighlightDirection = 'up' | 'down' | 'both';
|
|
7
|
+
/** Legend labels per node kind and edge kind. `legend` on <Graph> may also be `true` to derive them from the kinds in use. */
|
|
8
|
+
export type GraphLegend = {
|
|
9
|
+
nodes?: Partial<Record<NodeKind, string>>;
|
|
10
|
+
edges?: Partial<Record<NodeKind, string>>;
|
|
11
|
+
};
|
|
4
12
|
export type EdgeType = 'bezier' | 'straight' | 'step' | 'smoothstep' | 'avoid';
|
|
5
13
|
export type GraphPort = {
|
|
6
14
|
id: string;
|
|
@@ -16,6 +24,8 @@ export type GraphNode = {
|
|
|
16
24
|
label: string;
|
|
17
25
|
subtitle?: string;
|
|
18
26
|
kind?: NodeKind;
|
|
27
|
+
/** Outline of the default card (default `rect`). Circle and diamond centre their text, so give them a fitting width and height. */
|
|
28
|
+
shape?: NodeShape;
|
|
19
29
|
/** Picks a renderer from the `nodeTypes` registry on <Graph>. */
|
|
20
30
|
type?: string;
|
|
21
31
|
/** Fixed size in graph units (edge math relies on it). Defaults 180×56. */
|