@poodle64/ui 2026.10.15 → 2026.10.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,922 @@
1
+ <script lang="ts" generics="N extends GraphNode">
2
+ /**
3
+ * A live force-directed graph of entities and relations, drawn on a canvas
4
+ * and themed from the --ds-* tokens of wherever it is placed, a page or an
5
+ * element's shadow root alike. Every colour is resolved against this
6
+ * component's own element, never the document, so a shadow root takes its
7
+ * host page's palette; nothing portals out.
8
+ */
9
+ import { onMount, tick, untrack, type Snippet } from 'svelte';
10
+ import type { HTMLAttributes } from 'svelte/elements';
11
+ import Search from '@lucide/svelte/icons/search';
12
+ import Plus from '@lucide/svelte/icons/plus';
13
+ import Minus from '@lucide/svelte/icons/minus';
14
+ import Maximize from '@lucide/svelte/icons/maximize';
15
+ import Shuffle from '@lucide/svelte/icons/shuffle';
16
+ import Focus from '@lucide/svelte/icons/focus';
17
+ import ArrowUpRight from '@lucide/svelte/icons/arrow-up-right';
18
+ import X from '@lucide/svelte/icons/x';
19
+ import Button from '../button/button.svelte';
20
+ import { Input } from '../input/index.js';
21
+ import { cn } from '../../../utils.js';
22
+ import { formatNumber } from '../../../format.js';
23
+ import { GraphScene, type Camera, type Inset } from './scene.js';
24
+ import type { GraphEdge, GraphKind, GraphNeighbour, GraphNode, GraphShape } from './types.js';
25
+
26
+ type Props = Omit<HTMLAttributes<HTMLDivElement>, 'class' | 'onselect'> & {
27
+ nodes: N[];
28
+ edges: GraphEdge[];
29
+ /** Legend order, labels, colours and shapes. Kinds in the data but not here still draw. */
30
+ kinds?: GraphKind[];
31
+ /** The selected node's id. */
32
+ selected?: string | null;
33
+ /** Kind ids the legend has switched off. */
34
+ hiddenKinds?: string[];
35
+ /** Arrowheads at each edge's target. */
36
+ directed?: boolean;
37
+ /** The accessible name. */
38
+ label?: string;
39
+ /** The find field. */
40
+ search?: boolean;
41
+ activateLabel?: string;
42
+ /** A click on a node, a match or a neighbour; `null` when the selection clears. */
43
+ onselect?: (node: N | null) => void;
44
+ /** Double-click, Enter, or the card's action: open the thing the node stands for. */
45
+ onactivate?: (node: N) => void;
46
+ /** Extra content for the selected node's card, under its title. */
47
+ detail?: Snippet<[N, GraphNeighbour<N>[]]>;
48
+ class?: string;
49
+ };
50
+
51
+ let {
52
+ nodes,
53
+ edges,
54
+ kinds = [],
55
+ selected = $bindable(null),
56
+ hiddenKinds = $bindable([]),
57
+ directed = false,
58
+ label = 'Graph',
59
+ search = true,
60
+ activateLabel = 'Open',
61
+ onselect,
62
+ onactivate,
63
+ onkeydown,
64
+ detail,
65
+ class: className,
66
+ ...restProps
67
+ }: Props = $props();
68
+
69
+ const PRIMARY = 'var(--ds-color-primary)';
70
+ const NEIGHBOURS = 12;
71
+
72
+ const scene = new GraphScene<N>();
73
+ let root: HTMLDivElement;
74
+ let canvas: HTMLCanvasElement;
75
+ let probe: HTMLSpanElement;
76
+ let panel = $state<HTMLDivElement | null>(null);
77
+ let card = $state<HTMLElement | null>(null);
78
+ let findField = $state<HTMLInputElement | null>(null);
79
+ let ctx: CanvasRenderingContext2D | null = null;
80
+
81
+ let query = $state('');
82
+ let showAll = $state(false);
83
+ let settled = $state(false);
84
+ let cursor = $state('grab');
85
+
86
+ // The loop. Plain variables: the frame callback runs outside any effect, and
87
+ // a $state read here would make an effect that calls requestDraw depend on it.
88
+ let raf = 0;
89
+ let last = 0;
90
+ let dirty = false;
91
+ let follow = true;
92
+ let firstSettle = true;
93
+ let reduceMotion = false;
94
+ let tween: { from: Camera; to: Camera; t0: number; dur: number } | null = null;
95
+
96
+ const kindOf = $derived(new Map(kinds.map((k) => [k.id, k])));
97
+ // The first node with an id wins, as it does in the scene.
98
+ const nodeById = $derived.by(() => {
99
+ const m = new Map<string, N>();
100
+ for (const n of nodes) if (!m.has(n.id)) m.set(n.id, n);
101
+ return m;
102
+ });
103
+ const current = $derived.by(() => {
104
+ const n = selected ? nodeById.get(selected) : undefined;
105
+ return n && !hiddenKinds.includes(n.kind) ? n : null;
106
+ });
107
+
108
+ const legendRows = $derived.by(() => {
109
+ const counts = new Map<string, number>();
110
+ for (const n of nodes) counts.set(n.kind, (counts.get(n.kind) ?? 0) + 1);
111
+ const rows = kinds.map((k) => ({ kind: k, count: counts.get(k.id) ?? 0 }));
112
+ for (const [id, count] of counts) {
113
+ if (!kindOf.has(id)) rows.push({ kind: { id, label: id }, count });
114
+ }
115
+ return rows;
116
+ });
117
+
118
+ const neighbours = $derived.by(() => {
119
+ if (!current) return [];
120
+ const hidden = new Set(hiddenKinds);
121
+ const out: GraphNeighbour<N>[] = [];
122
+ for (const e of edges) {
123
+ const outward = e.source === current.id;
124
+ if (!outward && e.target !== current.id) continue;
125
+ const node = nodeById.get(outward ? e.target : e.source);
126
+ if (!node || node.id === current.id || hidden.has(node.kind)) continue;
127
+ out.push(e.label ? { node, label: e.label, direction: outward ? 'out' : 'in' } : { node, direction: outward ? 'out' : 'in' });
128
+ }
129
+ return out.sort(
130
+ (a, b) =>
131
+ (directed ? b.direction.localeCompare(a.direction) : 0) ||
132
+ (a.label ?? '').localeCompare(b.label ?? '') ||
133
+ a.node.label.localeCompare(b.node.label)
134
+ );
135
+ });
136
+
137
+ // The card's list, one heading per relation, phrased so the direction reads:
138
+ // "Director of" what this points at, "Director of this" what points here.
139
+ const relations = $derived.by(() => {
140
+ const shown = showAll ? neighbours : neighbours.slice(0, NEIGHBOURS);
141
+ const groups: { heading: string; rows: GraphNeighbour<N>[] }[] = [];
142
+ for (const nb of shown) {
143
+ const name = nb.label ? nb.label[0]!.toLocaleUpperCase() + nb.label.slice(1) : 'Connected';
144
+ const heading = directed && nb.label && nb.direction === 'in' ? `${name} this` : name;
145
+ const last = groups.at(-1);
146
+ if (last?.heading === heading) last.rows.push(nb);
147
+ else groups.push({ heading, rows: [nb] });
148
+ }
149
+ return groups;
150
+ });
151
+
152
+ const matches = $derived.by(() => {
153
+ const q = query.trim().toLocaleLowerCase();
154
+ if (!q) return [];
155
+ const hidden = new Set(hiddenKinds);
156
+ const hits: { node: N; at: number }[] = [];
157
+ for (const n of nodeById.values()) {
158
+ if (hidden.has(n.kind)) continue;
159
+ const at = n.label.toLocaleLowerCase().indexOf(q);
160
+ if (at >= 0) hits.push({ node: n, at });
161
+ }
162
+ hits.sort((a, b) => a.at - b.at || a.node.label.length - b.node.label.length);
163
+ return hits.slice(0, 8).map((h) => h.node);
164
+ });
165
+
166
+ const summary = $derived(
167
+ `${formatNumber(nodes.length)} ${nodes.length === 1 ? 'node' : 'nodes'} · ${formatNumber(edges.length)} ${edges.length === 1 ? 'link' : 'links'}`
168
+ );
169
+ const announcement = $derived(
170
+ current
171
+ ? `${current.label}, ${kindLabel(current.kind)}, ${neighbours.length} ${neighbours.length === 1 ? 'connection' : 'connections'}`
172
+ : ''
173
+ );
174
+
175
+ function kindLabel(id: string): string {
176
+ return kindOf.get(id)?.label ?? id;
177
+ }
178
+ function kindColour(id: string): string {
179
+ const k = kindOf.get(id);
180
+ return k ? (k.color ?? PRIMARY) : 'var(--ds-color-muted-foreground)';
181
+ }
182
+ function kindShape(id: string): GraphShape {
183
+ return kindOf.get(id)?.shape ?? 'circle';
184
+ }
185
+
186
+ // ---- the loop ----
187
+
188
+ function schedule() {
189
+ if (!raf && typeof requestAnimationFrame === 'function') raf = requestAnimationFrame(frame);
190
+ }
191
+ function requestDraw() {
192
+ dirty = true;
193
+ schedule();
194
+ }
195
+
196
+ function frame(now: number) {
197
+ raf = 0;
198
+ // Layout time is bounded by ticks, not by paint: where a frame is slow to
199
+ // paint (a 4K canvas, software raster), each frame advances further.
200
+ const spent = last ? now - last : 16;
201
+ last = now;
202
+ let again = false;
203
+ if (scene.hot()) {
204
+ scene.advance(reduceMotion && firstSettle ? 24 : Math.min(Math.max(spent * 0.6, 7), 60));
205
+ dirty = true;
206
+ again = true;
207
+ if (follow) {
208
+ const to = scene.fit(undefined, chromeInset());
209
+ if (to) scene.cam = reduceMotion ? to : ease(scene.cam, to, 0.12);
210
+ }
211
+ if (!scene.hot()) {
212
+ if (follow) goTo(scene.fit(undefined, chromeInset()), firstSettle ? 500 : 300);
213
+ firstSettle = false;
214
+ settled = true;
215
+ }
216
+ }
217
+ if (tween) {
218
+ const t = Math.min((now - tween.t0) / tween.dur, 1);
219
+ scene.cam = ease(tween.from, tween.to, 1 - Math.pow(1 - t, 3));
220
+ if (t >= 1) tween = null;
221
+ dirty = true;
222
+ again = true;
223
+ }
224
+ // Under reduced motion the first layout settles off-screen and appears once.
225
+ if (dirty && ctx && !(reduceMotion && firstSettle)) {
226
+ scene.draw(ctx);
227
+ dirty = false;
228
+ }
229
+ if (again || dirty) schedule();
230
+ else last = 0;
231
+ }
232
+
233
+ /** Interpolate cameras about the view's centre, zoom in log space. */
234
+ function ease(a: Camera, b: Camera, t: number): Camera {
235
+ const cx = scene.width / 2;
236
+ const cy = scene.height / 2;
237
+ const k = Math.exp(Math.log(a.k) + (Math.log(b.k) - Math.log(a.k)) * t);
238
+ const ax = (cx - a.x) / a.k;
239
+ const ay = (cy - a.y) / a.k;
240
+ const bx = (cx - b.x) / b.k;
241
+ const by = (cy - b.y) / b.k;
242
+ return { k, x: cx - (ax + (bx - ax) * t) * k, y: cy - (ay + (by - ay) * t) * k };
243
+ }
244
+
245
+ function goTo(to: Camera | null, dur = 450) {
246
+ if (!to) return;
247
+ if (reduceMotion || !dur) {
248
+ tween = null;
249
+ scene.cam = to;
250
+ requestDraw();
251
+ return;
252
+ }
253
+ tween = { from: { ...scene.cam }, to, t0: performance.now(), dur };
254
+ schedule();
255
+ }
256
+
257
+ /** The part of the view the floating chrome leaves clear: a wide panel or card is a column, a narrow one a band. */
258
+ function chromeInset(): Inset {
259
+ const out = { top: 0, right: 0, bottom: 0, left: 0 };
260
+ if (!root) return out;
261
+ const r = root.getBoundingClientRect();
262
+ if (panel) {
263
+ const p = panel.getBoundingClientRect();
264
+ if (p.width > r.width * 0.6) out.top = p.bottom - r.top;
265
+ else out.left = p.right - r.left;
266
+ }
267
+ if (card) {
268
+ const c = card.getBoundingClientRect();
269
+ if (c.width > r.width * 0.6) out.bottom = r.bottom - c.top;
270
+ else out.right = r.right - c.left;
271
+ }
272
+ return out;
273
+ }
274
+
275
+ function zoomLimits(): [number, number] {
276
+ const all = scene.fit(undefined, chromeInset());
277
+ return [Math.min(0.5 * (all?.k ?? 1), 0.2), 6];
278
+ }
279
+
280
+ // ---- what the chrome and the caller drive ----
281
+
282
+ /** Frame every visible node. */
283
+ export function fit() {
284
+ follow = false;
285
+ goTo(scene.fit(undefined, chromeInset()));
286
+ }
287
+
288
+ /** Select a node and frame it with its neighbours. */
289
+ export async function focus(id: string) {
290
+ const s = scene.byId.get(id);
291
+ if (!s) return;
292
+ choose(id);
293
+ follow = false;
294
+ await tick();
295
+ const cam = scene.fit(scene.neighbourhood(s), chromeInset(), 2.2);
296
+ goTo(cam && scene.neighbourhood(s).length === 1 ? { ...cam, k: Math.max(scene.cam.k, 1.4) } : cam);
297
+ }
298
+
299
+ /** Where a node is drawn, in px from the graph's top-left; null when it is not in the graph. */
300
+ export function locate(id: string): { x: number; y: number } | null {
301
+ const s = scene.byId.get(id);
302
+ if (!s || Number.isNaN(s.x)) return null;
303
+ return { x: scene.cam.x + s.x * scene.cam.k, y: scene.cam.y + s.y * scene.cam.k };
304
+ }
305
+
306
+ /** Shuffle and lay the graph out again. */
307
+ export function relayout() {
308
+ for (const s of scene.nodes) {
309
+ s.x = s.y = s.vx = s.vy = NaN;
310
+ }
311
+ scene.sim.nodes(scene.nodes);
312
+ scene.sim.alpha(1);
313
+ follow = true;
314
+ settled = false;
315
+ requestDraw();
316
+ }
317
+
318
+ function zoomBy(factor: number) {
319
+ follow = false;
320
+ const [min, max] = zoomLimits();
321
+ const from = { ...scene.cam };
322
+ scene.zoomAt(scene.width / 2, scene.height / 2, factor, min, max);
323
+ const to = scene.cam;
324
+ scene.cam = from;
325
+ goTo(to, 200);
326
+ }
327
+
328
+ function choose(id: string | null) {
329
+ showAll = false;
330
+ selected = id;
331
+ const node = id ? (nodeById.get(id) ?? null) : null;
332
+ onselect?.(node);
333
+ }
334
+
335
+ function toggleKind(id: string) {
336
+ hiddenKinds = hiddenKinds.includes(id)
337
+ ? hiddenKinds.filter((k) => k !== id)
338
+ : [...hiddenKinds, id];
339
+ const chosen = selected ? nodeById.get(selected) : undefined;
340
+ if (chosen && hiddenKinds.includes(chosen.kind)) choose(null);
341
+ }
342
+
343
+ function pickMatch(n: N) {
344
+ query = '';
345
+ focus(n.id);
346
+ }
347
+
348
+ // ---- theme ----
349
+
350
+ function resolvePalette() {
351
+ if (!root || !probe) return;
352
+ const cs = getComputedStyle(root);
353
+ const read = (colour: string) => {
354
+ probe.style.color = colour;
355
+ return getComputedStyle(probe).color;
356
+ };
357
+ const kindsMap = new Map<string, string>();
358
+ for (const n of untrack(() => legendRows)) kindsMap.set(n.kind.id, read(kindColour(n.kind.id)));
359
+ scene.palette = {
360
+ kinds: kindsMap,
361
+ fallback: read('var(--ds-color-muted-foreground)'),
362
+ ground: read('var(--ds-color-background)'),
363
+ ink: read('var(--ds-color-foreground)'),
364
+ inkSoft: read('var(--ds-color-muted-foreground)'),
365
+ edge: read('var(--ds-color-border-strong)'),
366
+ font: cs.getPropertyValue('--ds-font-body').trim() || cs.fontFamily || 'sans-serif'
367
+ };
368
+ scene.clearTextCache();
369
+ requestDraw();
370
+ }
371
+
372
+ // ---- props into the scene ----
373
+
374
+ $effect(() => {
375
+ const fresh = scene.setData(nodes, edges);
376
+ untrack(() => {
377
+ scene.selected = selected ? (scene.byId.get(selected) ?? null) : null;
378
+ if (!firstSettle) scene.heat(fresh ? 0.45 : 0.15);
379
+ settled = !scene.hot();
380
+ resolvePalette();
381
+ requestDraw();
382
+ });
383
+ });
384
+
385
+ $effect(() => {
386
+ scene.shapes = new Map(kinds.map((k) => [k.id, k.shape ?? 'circle']));
387
+ void kinds.map((k) => k.color);
388
+ untrack(resolvePalette);
389
+ });
390
+
391
+ $effect(() => {
392
+ scene.hidden = new Set(hiddenKinds);
393
+ requestDraw();
394
+ });
395
+
396
+ $effect(() => {
397
+ scene.selected = selected ? (scene.byId.get(selected) ?? null) : null;
398
+ requestDraw();
399
+ });
400
+
401
+ $effect(() => {
402
+ scene.matches = new Set(matches.flatMap((n) => scene.byId.get(n.id) ?? []));
403
+ requestDraw();
404
+ });
405
+
406
+ $effect(() => {
407
+ scene.directed = directed;
408
+ requestDraw();
409
+ });
410
+
411
+ // ---- pointer, wheel, keys ----
412
+
413
+ onMount(() => {
414
+ ctx = canvas.getContext('2d');
415
+ reduceMotion = matchMedia('(prefers-reduced-motion: reduce)').matches;
416
+
417
+ function measure() {
418
+ const r = root.getBoundingClientRect();
419
+ const w = Math.max(r.width, 1);
420
+ const h = Math.max(r.height, 1);
421
+ // Two device pixels per CSS pixel at most, and no more than ~16 MP of
422
+ // backing store: a 4K-wide graph stays a canvas the GPU fills cheaply.
423
+ let dpr = Math.min(window.devicePixelRatio || 1, 2);
424
+ dpr = Math.min(dpr, Math.sqrt(16_000_000 / (w * h)));
425
+ const dw = w - scene.width;
426
+ const dh = h - scene.height;
427
+ if (scene.width && !follow) scene.cam = { ...scene.cam, x: scene.cam.x + dw / 2, y: scene.cam.y + dh / 2 };
428
+ scene.width = w;
429
+ scene.height = h;
430
+ scene.dpr = dpr;
431
+ canvas.width = Math.round(w * dpr);
432
+ canvas.height = Math.round(h * dpr);
433
+ const inset = chromeInset();
434
+ scene.setAspect(w - inset.left - inset.right, h - inset.top - inset.bottom);
435
+ if (follow) {
436
+ const to = scene.fit(undefined, chromeInset());
437
+ if (to) scene.cam = to;
438
+ }
439
+ requestDraw();
440
+ }
441
+ const ro = new ResizeObserver(measure);
442
+ ro.observe(root);
443
+ if (panel) ro.observe(panel);
444
+ measure();
445
+
446
+ // Before the first paint, a few ticks so it does not open on d3's seed spiral.
447
+ scene.advance(reduceMotion ? 0 : 40);
448
+ const to = scene.fit(undefined, chromeInset());
449
+ if (to) scene.cam = to;
450
+ requestDraw();
451
+
452
+ const pointers = new Map<number, { x: number; y: number }>();
453
+ type Gesture =
454
+ | { type: 'pan'; x: number; y: number; moved: boolean }
455
+ | { type: 'node'; id: string; x: number; y: number; moved: boolean }
456
+ | { type: 'pinch'; d: number; mx: number; my: number; cam: Camera };
457
+ let gesture: Gesture | null = null;
458
+
459
+ const at = (ev: PointerEvent | MouseEvent | WheelEvent) => {
460
+ const r = canvas.getBoundingClientRect();
461
+ return { x: ev.clientX - r.left, y: ev.clientY - r.top };
462
+ };
463
+ const pinchOf = () => {
464
+ const [a, b] = [...pointers.values()];
465
+ if (!a || !b) return null;
466
+ return { d: Math.hypot(a.x - b.x, a.y - b.y) || 1, mx: (a.x + b.x) / 2, my: (a.y + b.y) / 2 };
467
+ };
468
+ const release = () => {
469
+ if (gesture?.type === 'node') {
470
+ const s = scene.byId.get(gesture.id);
471
+ if (s) {
472
+ s.fx = null;
473
+ s.fy = null;
474
+ }
475
+ scene.sim.alphaTarget(0);
476
+ }
477
+ };
478
+
479
+ function onDown(ev: PointerEvent) {
480
+ if (ev.button !== 0 && ev.pointerType === 'mouse') return;
481
+ try {
482
+ canvas.setPointerCapture(ev.pointerId);
483
+ } catch {
484
+ // A pointer the browser no longer tracks: the gesture still works uncaptured.
485
+ }
486
+ const p = at(ev);
487
+ pointers.set(ev.pointerId, p);
488
+ if (pointers.size === 2) {
489
+ release();
490
+ const pinch = pinchOf();
491
+ if (pinch) gesture = { type: 'pinch', ...pinch, cam: { ...scene.cam } };
492
+ return;
493
+ }
494
+ if (pointers.size > 2) return;
495
+ const hit = scene.pick(p.x, p.y, ev.pointerType === 'touch' ? 14 : 4);
496
+ gesture = hit
497
+ ? { type: 'node', id: hit.id, x: p.x, y: p.y, moved: false }
498
+ : { type: 'pan', x: p.x, y: p.y, moved: false };
499
+ if (!hit) cursor = 'grabbing';
500
+ }
501
+
502
+ function onMove(ev: PointerEvent) {
503
+ const p = at(ev);
504
+ if (pointers.has(ev.pointerId)) pointers.set(ev.pointerId, p);
505
+ if (gesture?.type === 'pinch') {
506
+ const pinch = pinchOf();
507
+ if (!pinch) return;
508
+ const [min, max] = zoomLimits();
509
+ const k = Math.min(Math.max((gesture.cam.k * pinch.d) / gesture.d, min), max);
510
+ const wx = (gesture.mx - gesture.cam.x) / gesture.cam.k;
511
+ const wy = (gesture.my - gesture.cam.y) / gesture.cam.k;
512
+ scene.cam = { k, x: pinch.mx - wx * k, y: pinch.my - wy * k };
513
+ follow = false;
514
+ tween = null;
515
+ requestDraw();
516
+ return;
517
+ }
518
+ if (gesture) {
519
+ const far = Math.hypot(p.x - gesture.x, p.y - gesture.y) > 4;
520
+ if (!gesture.moved && !far) return;
521
+ gesture.moved = true;
522
+ if (gesture.type === 'pan') {
523
+ scene.cam = { ...scene.cam, x: scene.cam.x + p.x - gesture.x, y: scene.cam.y + p.y - gesture.y };
524
+ gesture.x = p.x;
525
+ gesture.y = p.y;
526
+ follow = false;
527
+ tween = null;
528
+ requestDraw();
529
+ } else {
530
+ const s = scene.byId.get(gesture.id);
531
+ if (!s) return;
532
+ const [wx, wy] = scene.toWorld(p.x, p.y);
533
+ s.fx = wx;
534
+ s.fy = wy;
535
+ follow = false;
536
+ scene.sim.alphaTarget(0.25);
537
+ scene.heat(0.25);
538
+ requestDraw();
539
+ }
540
+ return;
541
+ }
542
+ if (ev.pointerType !== 'mouse') return;
543
+ const hit = scene.pick(p.x, p.y, 4);
544
+ if (hit !== scene.hovered) {
545
+ scene.hovered = hit;
546
+ cursor = hit ? 'pointer' : 'grab';
547
+ requestDraw();
548
+ }
549
+ }
550
+
551
+ function onUp(ev: PointerEvent) {
552
+ pointers.delete(ev.pointerId);
553
+ const g = gesture;
554
+ if (g?.type === 'pinch') {
555
+ if (pointers.size === 0) {
556
+ gesture = null;
557
+ cursor = 'grab';
558
+ }
559
+ return;
560
+ }
561
+ release();
562
+ gesture = null;
563
+ cursor = scene.hovered ? 'pointer' : 'grab';
564
+ if (!g || g.moved || ev.type !== 'pointerup') return;
565
+ if (g.type === 'node') choose(g.id);
566
+ else if (selected) choose(null);
567
+ }
568
+
569
+ function onLeave() {
570
+ if (scene.hovered) {
571
+ scene.hovered = null;
572
+ requestDraw();
573
+ }
574
+ }
575
+
576
+ function onDouble(ev: MouseEvent) {
577
+ const p = at(ev);
578
+ const hit = scene.pick(p.x, p.y, 4);
579
+ if (hit && onactivate) onactivate(hit.node);
580
+ else if (hit) focus(hit.id);
581
+ else {
582
+ const [min, max] = zoomLimits();
583
+ follow = false;
584
+ const from = { ...scene.cam };
585
+ scene.zoomAt(p.x, p.y, 2, min, max);
586
+ const to = scene.cam;
587
+ scene.cam = from;
588
+ goTo(to, 250);
589
+ }
590
+ }
591
+
592
+ function onWheel(ev: WheelEvent) {
593
+ ev.preventDefault();
594
+ const p = at(ev);
595
+ const unit = ev.deltaMode === 1 ? 16 : ev.deltaMode === 2 ? 400 : 1;
596
+ // A trackpad pinch arrives as a wheel with ctrlKey, in much smaller steps.
597
+ const factor = Math.exp(-ev.deltaY * unit * (ev.ctrlKey ? 0.01 : 0.0018));
598
+ const [min, max] = zoomLimits();
599
+ follow = false;
600
+ tween = null;
601
+ scene.zoomAt(p.x, p.y, factor, min, max);
602
+ requestDraw();
603
+ }
604
+
605
+ canvas.addEventListener('pointerdown', onDown);
606
+ canvas.addEventListener('pointermove', onMove);
607
+ canvas.addEventListener('pointerup', onUp);
608
+ canvas.addEventListener('pointercancel', onUp);
609
+ // A capture the browser drops without a pointerup still ends the gesture.
610
+ canvas.addEventListener('lostpointercapture', onUp);
611
+ canvas.addEventListener('pointerleave', onLeave);
612
+ canvas.addEventListener('dblclick', onDouble);
613
+ canvas.addEventListener('wheel', onWheel, { passive: false });
614
+
615
+ // The palette follows the page: its mode class, an element host's
616
+ // data-dark, a palette swap, the OS scheme, a web font arriving late.
617
+ const theme = () => resolvePalette();
618
+ const watch = ['class', 'style', 'data-theme', 'data-dark', 'data-ds-palette'];
619
+ const mo = new MutationObserver(theme);
620
+ mo.observe(document.documentElement, { attributes: true, attributeFilter: watch });
621
+ const host = root.getRootNode() instanceof ShadowRoot ? (root.getRootNode() as ShadowRoot).host : null;
622
+ if (host) mo.observe(host, { attributes: true, attributeFilter: watch });
623
+ const scheme = matchMedia('(prefers-color-scheme: dark)');
624
+ scheme.addEventListener?.('change', theme);
625
+ document.fonts?.addEventListener?.('loadingdone', theme);
626
+ resolvePalette();
627
+
628
+ return () => {
629
+ cancelAnimationFrame(raf);
630
+ raf = 0;
631
+ ro.disconnect();
632
+ mo.disconnect();
633
+ scheme.removeEventListener?.('change', theme);
634
+ document.fonts?.removeEventListener?.('loadingdone', theme);
635
+ canvas.removeEventListener('pointerdown', onDown);
636
+ canvas.removeEventListener('pointermove', onMove);
637
+ canvas.removeEventListener('pointerup', onUp);
638
+ canvas.removeEventListener('pointercancel', onUp);
639
+ canvas.removeEventListener('lostpointercapture', onUp);
640
+ canvas.removeEventListener('pointerleave', onLeave);
641
+ canvas.removeEventListener('dblclick', onDouble);
642
+ canvas.removeEventListener('wheel', onWheel);
643
+ scene.sim.stop();
644
+ };
645
+ });
646
+
647
+ function onCanvasKey(ev: KeyboardEvent) {
648
+ // Ctrl or Cmd with + - 0 is the browser's own zoom, never the graph's.
649
+ if (ev.ctrlKey || ev.metaKey || ev.altKey) return;
650
+ const step = 64;
651
+ const pan = (dx: number, dy: number) => {
652
+ follow = false;
653
+ goTo({ ...scene.cam, x: scene.cam.x + dx, y: scene.cam.y + dy }, 150);
654
+ };
655
+ if (ev.key === 'ArrowLeft') pan(step, 0);
656
+ else if (ev.key === 'ArrowRight') pan(-step, 0);
657
+ else if (ev.key === 'ArrowUp') pan(0, step);
658
+ else if (ev.key === 'ArrowDown') pan(0, -step);
659
+ else if (ev.key === '+' || ev.key === '=') zoomBy(1.3);
660
+ else if (ev.key === '-' || ev.key === '_') zoomBy(1 / 1.3);
661
+ else if (ev.key === '0') fit();
662
+ else if (ev.key === 'Enter' && current && onactivate) onactivate(current);
663
+ else return;
664
+ ev.preventDefault();
665
+ }
666
+
667
+ function onRootKey(ev: KeyboardEvent) {
668
+ if (ev.key === '/' && search && ev.target === canvas) {
669
+ ev.preventDefault();
670
+ findField?.focus();
671
+ return;
672
+ }
673
+ if (ev.key !== 'Escape') return;
674
+ if (query) query = '';
675
+ else if (selected) choose(null);
676
+ else return;
677
+ ev.preventDefault();
678
+ }
679
+
680
+ function onSearchKey(ev: KeyboardEvent) {
681
+ if (ev.key === 'Enter' && matches[0]) {
682
+ ev.preventDefault();
683
+ pickMatch(matches[0]);
684
+ }
685
+ }
686
+ </script>
687
+
688
+ {#snippet swatch(kind: string, off = false)}
689
+ {@const shape = kindShape(kind)}
690
+ <svg
691
+ viewBox="-6 -6 12 12"
692
+ class="size-3 shrink-0"
693
+ style:color={kindColour(kind)}
694
+ fill={off ? 'none' : 'currentColor'}
695
+ stroke="currentColor"
696
+ stroke-width="1.5"
697
+ aria-hidden="true"
698
+ >
699
+ {#if shape === 'square'}
700
+ <rect x="-4" y="-4" width="8" height="8" />
701
+ {:else if shape === 'diamond'}
702
+ <path d="M0 -5.2 L5.2 0 L0 5.2 L-5.2 0 Z" />
703
+ {:else if shape === 'triangle'}
704
+ <path d="M0 -5.2 L4.8 3.6 L-4.8 3.6 Z" />
705
+ {:else}
706
+ <circle r="4.5" />
707
+ {/if}
708
+ </svg>
709
+ {/snippet}
710
+
711
+ <!-- svelte-ignore a11y_no_noninteractive_element_interactions: Escape closes the find or the card from anywhere inside -->
712
+ <div
713
+ bind:this={root}
714
+ class={cn('bg-background @container relative isolate h-full min-h-80 w-full overflow-hidden', className)}
715
+ role="group"
716
+ aria-roledescription="graph"
717
+ aria-label={label}
718
+ data-slot="graph-canvas"
719
+ data-layout={settled ? 'settled' : 'running'}
720
+ data-selected={selected ?? undefined}
721
+ {...restProps}
722
+ onkeydown={(ev) => {
723
+ onkeydown?.(ev);
724
+ onRootKey(ev);
725
+ }}
726
+ >
727
+ <span bind:this={probe} class="hidden" aria-hidden="true"></span>
728
+ <canvas
729
+ bind:this={canvas}
730
+ class="absolute inset-0 h-full w-full touch-none outline-none focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ring"
731
+ style:cursor
732
+ tabindex="0"
733
+ aria-roledescription="graph canvas"
734
+ aria-label={`${label}: ${summary}. Arrow keys pan, plus and minus zoom, 0 fits${search ? ', slash finds' : ''}.`}
735
+ onkeydown={onCanvasKey}
736
+ ></canvas>
737
+
738
+ <!-- Find and legend: a column on the left of a wide graph, a bar across the top of a narrow one. -->
739
+ <div
740
+ bind:this={panel}
741
+ class="border-border bg-surface-2/90 absolute inset-x-3 top-3 z-10 flex flex-col gap-2 ds-edge rounded-xl border p-2 backdrop-blur-md @2xl:inset-x-auto @2xl:left-4 @2xl:top-4 @2xl:w-64 @2xl:gap-3 @2xl:p-3"
742
+ >
743
+ {#if search}
744
+ <div class="relative">
745
+ <Search
746
+ class="text-muted-foreground pointer-events-none absolute top-1/2 left-2.5 size-4 -translate-y-1/2"
747
+ aria-hidden="true"
748
+ />
749
+ <Input
750
+ type="search"
751
+ bind:ref={findField}
752
+ bind:value={query}
753
+ placeholder="Find…"
754
+ aria-label={`Find in ${label}`}
755
+ autocomplete="off"
756
+ class="bg-background pr-8 pl-8"
757
+ onkeydown={onSearchKey}
758
+ />
759
+ {#if !query}
760
+ <kbd
761
+ class="text-muted-foreground border-border pointer-events-none absolute top-1/2 right-2 hidden -translate-y-1/2 rounded border px-1.5 font-mono text-[0.7rem] leading-4 @2xl:block"
762
+ aria-hidden="true">/</kbd
763
+ >
764
+ {/if}
765
+ </div>
766
+ {#if matches.length}
767
+ <ul class="-mt-1 flex flex-col" aria-label="Matches">
768
+ {#each matches as m (m.id)}
769
+ <li>
770
+ <button
771
+ type="button"
772
+ class="hover:bg-accent focus-visible:bg-accent flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm outline-none"
773
+ onclick={() => pickMatch(m)}
774
+ >
775
+ {@render swatch(m.kind)}
776
+ <span class="text-foreground min-w-0 flex-1 truncate">{m.label}</span>
777
+ <span class="text-muted-foreground shrink-0 text-xs">{kindLabel(m.kind)}</span>
778
+ </button>
779
+ </li>
780
+ {/each}
781
+ </ul>
782
+ {:else if query.trim()}
783
+ <p class="text-muted-foreground -mt-1 px-2 py-1 text-sm">No match</p>
784
+ {/if}
785
+ {/if}
786
+
787
+ {#if legendRows.length}
788
+ <ul
789
+ class="flex flex-wrap gap-x-1 @2xl:flex-col @2xl:flex-nowrap"
790
+ aria-label="Kinds"
791
+ >
792
+ {#each legendRows as row (row.kind.id)}
793
+ {@const off = hiddenKinds.includes(row.kind.id)}
794
+ <li class="shrink-0">
795
+ <button
796
+ type="button"
797
+ class={cn(
798
+ 'hover:bg-accent flex w-full items-center gap-1.5 rounded-md border px-1.5 py-1 text-xs whitespace-nowrap transition-colors @2xl:gap-2 @2xl:px-2 @2xl:text-sm',
799
+ off ? 'text-muted-foreground border-dashed border-border [&_.ds-kind]:line-through' : 'bg-background/60 border-transparent'
800
+ )}
801
+ aria-pressed={!off}
802
+ onclick={() => toggleKind(row.kind.id)}
803
+ >
804
+ {@render swatch(row.kind.id, off)}
805
+ <span class={cn('ds-kind', !off && 'text-foreground')}>{row.kind.label}</span>
806
+ <span class="text-muted-foreground ml-auto pl-1 text-xs tabular-nums @2xl:pl-2">{formatNumber(row.count)}</span>
807
+ </button>
808
+ </li>
809
+ {/each}
810
+ </ul>
811
+ {/if}
812
+ </div>
813
+
814
+ <!-- View controls. A narrow graph's card covers them, so they step aside for it. -->
815
+ <div
816
+ class={cn(
817
+ 'border-border bg-surface-2/90 absolute right-3 bottom-3 z-10 flex flex-col ds-edge rounded-xl border p-1 backdrop-blur-md @2xl:right-4 @2xl:bottom-4',
818
+ current && 'hidden @2xl:flex'
819
+ )}
820
+ >
821
+ <Button variant="ghost" size="icon-sm" aria-label="Zoom in" title="Zoom in" onclick={() => zoomBy(1.4)}>
822
+ <Plus />
823
+ </Button>
824
+ <Button variant="ghost" size="icon-sm" aria-label="Zoom out" title="Zoom out" onclick={() => zoomBy(1 / 1.4)}>
825
+ <Minus />
826
+ </Button>
827
+ <Button variant="ghost" size="icon-sm" aria-label="Fit to view" title="Fit to view" onclick={fit}>
828
+ <Maximize />
829
+ </Button>
830
+ <Button variant="ghost" size="icon-sm" aria-label="Lay out again" title="Lay out again" onclick={relayout}>
831
+ <Shuffle />
832
+ </Button>
833
+ </div>
834
+
835
+ <p
836
+ class="text-muted-foreground pointer-events-none absolute bottom-4 left-4 z-10 hidden text-xs tabular-nums @2xl:block"
837
+ >
838
+ {summary}{settled ? '' : ' · laying out'}
839
+ </p>
840
+
841
+ {#if current}
842
+ <section
843
+ bind:this={card}
844
+ class="border-border bg-surface-2/95 absolute inset-x-3 bottom-3 z-20 flex max-h-[48%] flex-col ds-edge overflow-hidden rounded-xl border backdrop-blur-md @2xl:inset-x-auto @2xl:top-4 @2xl:right-4 @2xl:bottom-auto @2xl:max-h-[calc(100%-2rem)] @2xl:w-80"
845
+ aria-label={current.label}
846
+ >
847
+ <div class="overflow-y-auto overscroll-contain p-4">
848
+ <div class="flex items-start gap-2">
849
+ <div class="min-w-0 flex-1">
850
+ <p class="text-muted-foreground flex items-center gap-1.5 text-xs font-medium">
851
+ {@render swatch(current.kind)}
852
+ {kindLabel(current.kind)}
853
+ </p>
854
+ <h3 class="font-display text-foreground mt-1 text-lg leading-snug font-semibold break-words">
855
+ {current.label}
856
+ </h3>
857
+ {#if current.description}
858
+ <p class="text-muted-foreground mt-1 text-sm leading-relaxed">{current.description}</p>
859
+ {/if}
860
+ </div>
861
+ <Button
862
+ variant="ghost"
863
+ size="icon-sm"
864
+ class="-mt-1 -mr-2"
865
+ aria-label="Close"
866
+ onclick={() => choose(null)}
867
+ >
868
+ <X />
869
+ </Button>
870
+ </div>
871
+
872
+ <div class="mt-3 flex gap-2">
873
+ <Button variant="outline" size="sm" onclick={() => current && focus(current.id)}>
874
+ <Focus />
875
+ Focus
876
+ </Button>
877
+ {#if onactivate}
878
+ <Button size="sm" onclick={() => current && onactivate(current)}>
879
+ {activateLabel}
880
+ <ArrowUpRight />
881
+ </Button>
882
+ {/if}
883
+ </div>
884
+
885
+ {#if detail}
886
+ <div class="mt-3">{@render detail(current, neighbours)}</div>
887
+ {/if}
888
+
889
+ {#if neighbours.length}
890
+ <p class="text-muted-foreground mt-4 text-xs">
891
+ {formatNumber(neighbours.length)}
892
+ {neighbours.length === 1 ? 'connection' : 'connections'}
893
+ </p>
894
+ {#each relations as group, g (g)}
895
+ <p class="text-foreground mt-3 mb-0.5 text-xs font-semibold">{group.heading}</p>
896
+ <ul class="-mx-2 flex flex-col">
897
+ {#each group.rows as nb, i (nb.node.id + '|' + i)}
898
+ <li>
899
+ <button
900
+ type="button"
901
+ class="hover:bg-accent flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm"
902
+ onclick={() => focus(nb.node.id)}
903
+ >
904
+ {@render swatch(nb.node.kind)}
905
+ <span class="text-foreground min-w-0 flex-1 truncate">{nb.node.label}</span>
906
+ </button>
907
+ </li>
908
+ {/each}
909
+ </ul>
910
+ {/each}
911
+ {#if !showAll && neighbours.length > NEIGHBOURS}
912
+ <Button variant="link" size="sm" class="mt-1 px-0" onclick={() => (showAll = true)}>
913
+ Show all {formatNumber(neighbours.length)}
914
+ </Button>
915
+ {/if}
916
+ {/if}
917
+ </div>
918
+ </section>
919
+ {/if}
920
+
921
+ <p class="sr-only" aria-live="polite">{announcement}</p>
922
+ </div>