@poodle64/ui 2026.10.16 → 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,562 @@
1
+ /**
2
+ * The graph's world: d3-force for the layout, a Canvas 2D painter, the camera
3
+ * and hit-testing. No Svelte in here; the component owns the loop, the
4
+ * pointer and the chrome, and calls in.
5
+ *
6
+ * Paint cost is bounded at any size: edges and nodes are culled to the
7
+ * viewport and stroked as a handful of batched paths, labels are placed
8
+ * greedily in screen space against a grid of what is already drawn, and the
9
+ * label pass stops at a fixed budget, so a few thousand nodes paint in a few
10
+ * milliseconds and the layout's own ticks are the cost that dominates.
11
+ */
12
+ import { forceCollide, forceLink, forceManyBody, forceSimulation, forceX, forceY } from 'd3-force';
13
+ const MAX_LABELS = 240;
14
+ /** Screen area per label at rest; past the quota only a node drawn large enough is named. */
15
+ const LABEL_AREA = 30_000;
16
+ const MAX_LABEL_TRIES = 1500;
17
+ const MAX_EDGE_LABELS = 160;
18
+ const CELL = 48;
19
+ export class GraphScene {
20
+ nodes = [];
21
+ edges = [];
22
+ byId = new Map();
23
+ adj = new Map();
24
+ /** Label priority, fixed per data set: emphasis, then degree. */
25
+ ranked = [];
26
+ cam = { k: 1, x: 0, y: 0 };
27
+ width = 0;
28
+ height = 0;
29
+ dpr = 1;
30
+ palette = null;
31
+ shapes = new Map();
32
+ hidden = new Set();
33
+ directed = false;
34
+ selected = null;
35
+ hovered = null;
36
+ matches = new Set();
37
+ sim;
38
+ link = forceLink([]);
39
+ charge = forceManyBody().strength((d) => -28 - 14 * Math.sqrt(d.degree));
40
+ collide = forceCollide((d) => d.r + 3);
41
+ ticks = 0;
42
+ gx = forceX(0).strength(0.05);
43
+ gy = forceY(0).strength(0.05);
44
+ widths = new Map();
45
+ grid = new Map();
46
+ boxes = [];
47
+ constructor() {
48
+ this.link.distance((l) => 24 + l.source.r + l.target.r);
49
+ this.sim = forceSimulation([])
50
+ .stop()
51
+ .alphaMin(0.008)
52
+ .alphaDecay(0.0228)
53
+ .velocityDecay(0.4)
54
+ .force('charge', this.charge)
55
+ .force('link', this.link)
56
+ // Overlap only matters once the graph has opened out, and collision is
57
+ // a third of a tick's cost: it waits for the layout to cool, then
58
+ // resolves on every other tick, which still converges.
59
+ .force('collide', Object.assign((alpha) => (alpha < 0.45 && ++this.ticks % 2 ? this.collide(alpha) : undefined), {
60
+ initialize: (nodes, random) => this.collide.initialize(nodes, random)
61
+ }))
62
+ .force('x', this.gx)
63
+ .force('y', this.gy);
64
+ }
65
+ /** Gravity follows the frame's shape, so a wide view lays out wide and a phone tall. */
66
+ setAspect(width, height) {
67
+ const ratio = Math.min(Math.max(width / Math.max(height, 1), 0.4), 2.5);
68
+ this.gx.strength(0.05 * ratio ** -0.75);
69
+ this.gy.strength(0.05 * ratio ** 0.75);
70
+ }
71
+ /** Replace the data, keeping every surviving node where it is. */
72
+ setData(nodes, edges) {
73
+ const prev = this.byId;
74
+ const byId = new Map();
75
+ const fresh = [];
76
+ for (const node of nodes) {
77
+ if (byId.has(node.id))
78
+ continue;
79
+ const old = prev.get(node.id);
80
+ const s = old ?? { id: node.id, node, x: NaN, y: NaN, r: 0, degree: 0 };
81
+ s.node = node;
82
+ s.degree = 0;
83
+ if (!old)
84
+ fresh.push(s);
85
+ byId.set(node.id, s);
86
+ }
87
+ const adj = new Map();
88
+ for (const s of byId.values())
89
+ adj.set(s, new Set());
90
+ const pairs = new Map();
91
+ const list = [];
92
+ for (const edge of edges) {
93
+ const a = byId.get(edge.source);
94
+ const b = byId.get(edge.target);
95
+ if (!a || !b || a === b)
96
+ continue;
97
+ const key = a.id < b.id ? `${a.id}\u0000${b.id}` : `${b.id}\u0000${a.id}`;
98
+ const i = pairs.get(key) ?? 0;
99
+ pairs.set(key, i + 1);
100
+ // 1, -1, 2, -2 … relative to the pair's own lower-id → higher-id axis.
101
+ const fan = (i % 2 ? -1 : 1) * (1 + Math.floor(i / 2));
102
+ list.push({ source: a, target: b, edge, bend: a.id < b.id ? fan : -fan });
103
+ a.degree++;
104
+ b.degree++;
105
+ adj.get(a)?.add(b);
106
+ adj.get(b)?.add(a);
107
+ }
108
+ for (const s of byId.values()) {
109
+ s.r = s.node.size ?? Math.min(2.5 + 2.8 * Math.sqrt(s.degree), 26) + (s.node.emphasis ? 3 : 0);
110
+ }
111
+ // A newcomer starts beside the nodes it joins, so a live graph grows in
112
+ // place; with no placed neighbour, d3 seeds it on its phyllotaxis.
113
+ for (const s of fresh) {
114
+ let n = 0;
115
+ let x = 0;
116
+ let y = 0;
117
+ for (const m of adj.get(s) ?? []) {
118
+ if (Number.isNaN(m.x))
119
+ continue;
120
+ x += m.x;
121
+ y += m.y;
122
+ n++;
123
+ }
124
+ if (n) {
125
+ const a = Math.random() * Math.PI * 2;
126
+ s.x = x / n + Math.cos(a) * (s.r + 16);
127
+ s.y = y / n + Math.sin(a) * (s.r + 16);
128
+ }
129
+ }
130
+ this.byId = byId;
131
+ this.adj = adj;
132
+ this.nodes = [...byId.values()];
133
+ this.edges = list;
134
+ this.ranked = [...this.nodes].sort((p, q) => Number(!!q.node.emphasis) - Number(!!p.node.emphasis) || q.degree - p.degree);
135
+ // A big graph trades a little accuracy for ticks it can afford.
136
+ const big = this.nodes.length > 1200;
137
+ this.charge.theta(big ? 1.1 : 0.9).distanceMax(big ? 480 : 900);
138
+ this.sim.alphaDecay(big ? 0.032 : 0.0228);
139
+ this.sim.nodes(this.nodes);
140
+ this.link.links(list);
141
+ if (this.selected)
142
+ this.selected = byId.get(this.selected.id) ?? null;
143
+ if (this.hovered)
144
+ this.hovered = byId.get(this.hovered.id) ?? null;
145
+ this.matches = new Set([...this.matches].flatMap((m) => byId.get(m.id) ?? []));
146
+ return fresh.length;
147
+ }
148
+ hot() {
149
+ return this.sim.alpha() >= this.sim.alphaMin();
150
+ }
151
+ heat(alpha) {
152
+ this.sim.alpha(Math.max(this.sim.alpha(), alpha));
153
+ }
154
+ /** Advance the layout for at most `budgetMs`, and at least one tick. */
155
+ advance(budgetMs) {
156
+ const t0 = performance.now();
157
+ do
158
+ this.sim.tick();
159
+ while (this.hot() && performance.now() - t0 < budgetMs);
160
+ }
161
+ visible(s) {
162
+ return !this.hidden.has(s.node.kind);
163
+ }
164
+ neighbourhood(s) {
165
+ return [s, ...(this.adj.get(s) ?? [])].filter((n) => this.visible(n));
166
+ }
167
+ /** The camera that frames `subset` (default: every visible node) inside the inset box. */
168
+ fit(subset, inset, maxK = 1.8) {
169
+ let x0 = Infinity;
170
+ let y0 = Infinity;
171
+ let x1 = -Infinity;
172
+ let y1 = -Infinity;
173
+ for (const s of subset ?? this.nodes) {
174
+ if (!this.visible(s) || Number.isNaN(s.x))
175
+ continue;
176
+ x0 = Math.min(x0, s.x - s.r);
177
+ y0 = Math.min(y0, s.y - s.r);
178
+ x1 = Math.max(x1, s.x + s.r);
179
+ y1 = Math.max(y1, s.y + s.r + 16);
180
+ }
181
+ if (x0 === Infinity)
182
+ return null;
183
+ const pad = Math.min(48, Math.min(this.width, this.height) * 0.06);
184
+ // Names hang below and either side of a node: room for half a name.
185
+ const side = pad + Math.min(56, this.width * 0.1);
186
+ const box = {
187
+ left: (inset?.left ?? 0) + side,
188
+ top: (inset?.top ?? 0) + pad,
189
+ right: this.width - (inset?.right ?? 0) - side,
190
+ bottom: this.height - (inset?.bottom ?? 0) - pad
191
+ };
192
+ const bw = Math.max(box.right - box.left, 40);
193
+ const bh = Math.max(box.bottom - box.top, 40);
194
+ const k = Math.min(bw / Math.max(x1 - x0, 1), bh / Math.max(y1 - y0, 1), maxK);
195
+ return {
196
+ k,
197
+ x: box.left + bw / 2 - ((x0 + x1) / 2) * k,
198
+ y: box.top + bh / 2 - ((y0 + y1) / 2) * k
199
+ };
200
+ }
201
+ toWorld(sx, sy) {
202
+ return [(sx - this.cam.x) / this.cam.k, (sy - this.cam.y) / this.cam.k];
203
+ }
204
+ /** Zoom by `factor` about the screen point (sx, sy). */
205
+ zoomAt(sx, sy, factor, min, max) {
206
+ const k = Math.min(Math.max(this.cam.k * factor, min), max);
207
+ const [wx, wy] = this.toWorld(sx, sy);
208
+ this.cam = { k, x: sx - wx * k, y: sy - wy * k };
209
+ }
210
+ /** The node under a screen point, with `slop` px of grace for a finger. */
211
+ pick(sx, sy, slop) {
212
+ const [wx, wy] = this.toWorld(sx, sy);
213
+ const k = this.cam.k;
214
+ let best = null;
215
+ let bd = Infinity;
216
+ for (const s of this.nodes) {
217
+ if (!this.visible(s))
218
+ continue;
219
+ const d = Math.hypot(s.x - wx, s.y - wy);
220
+ if (d < Math.max(s.r, 2 / k) + slop / k && d < bd) {
221
+ bd = d;
222
+ best = s;
223
+ }
224
+ }
225
+ return best;
226
+ }
227
+ clearTextCache() {
228
+ this.widths.clear();
229
+ }
230
+ draw(ctx) {
231
+ const P = this.palette;
232
+ const { dpr, width: W, height: H } = this;
233
+ const { k, x: tx, y: ty } = this.cam;
234
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
235
+ ctx.clearRect(0, 0, W, H);
236
+ if (!P)
237
+ return;
238
+ const chosen = this.selected && this.visible(this.selected) ? this.selected : null;
239
+ const focus = this.hovered ?? chosen;
240
+ const near = focus ? this.adj.get(focus) : undefined;
241
+ const lit = (s) => !focus || s === focus || !!near?.has(s);
242
+ const margin = 40 / k;
243
+ const vx0 = -tx / k - margin;
244
+ const vy0 = -ty / k - margin;
245
+ const vx1 = (W - tx) / k + margin;
246
+ const vy1 = (H - ty) / k + margin;
247
+ const onScreen = (s) => s.x > vx0 && s.x < vx1 && s.y > vy0 && s.y < vy1;
248
+ const rad = (s) => Math.max(s.r, 1.8 / k);
249
+ ctx.setTransform(dpr * k, 0, 0, dpr * k, dpr * tx, dpr * ty);
250
+ ctx.lineCap = 'butt';
251
+ ctx.lineJoin = 'round';
252
+ const shown = [];
253
+ for (const e of this.edges) {
254
+ const a = e.source;
255
+ const b = e.target;
256
+ if (!this.visible(a) || !this.visible(b))
257
+ continue;
258
+ if (Math.max(a.x, b.x) < vx0 ||
259
+ Math.min(a.x, b.x) > vx1 ||
260
+ Math.max(a.y, b.y) < vy0 ||
261
+ Math.min(a.y, b.y) > vy1)
262
+ continue;
263
+ shown.push(e);
264
+ }
265
+ // Detail follows how many edges are on screen, not the zoom: past a
266
+ // thousand, curves read as straight lines and arrowheads as noise, and
267
+ // both cost more to fill than the rest of the frame.
268
+ const curved = shown.length < 1200;
269
+ // Edges: one batched path for the rest, one per colour for the lit ones.
270
+ const rest = new Path2D();
271
+ const restHeads = new Path2D();
272
+ const litPaths = new Map();
273
+ const edgeLabels = [];
274
+ const heads = this.directed && (shown.length < 500 || !!focus);
275
+ // A hub's relations crowd its own spokes; past a dozen the card lists them instead.
276
+ const relationWords = focus ? (this.adj.get(focus)?.size ?? 0) <= 12 : shown.length < 300;
277
+ for (const e of shown) {
278
+ const a = e.source;
279
+ const b = e.target;
280
+ const hot = !!focus && (a === focus || b === focus);
281
+ let path = rest;
282
+ let head = restHeads;
283
+ if (hot) {
284
+ const colour = this.colour(a === focus ? b : a);
285
+ let p = litPaths.get(colour);
286
+ if (!p)
287
+ litPaths.set(colour, (p = { line: new Path2D(), head: new Path2D() }));
288
+ path = p.line;
289
+ head = p.head;
290
+ }
291
+ const [cx, cy] = curved || hot ? this.control(e) : [(a.x + b.x) / 2, (a.y + b.y) / 2];
292
+ path.moveTo(a.x, a.y);
293
+ if (curved || hot)
294
+ path.quadraticCurveTo(cx, cy, b.x, b.y);
295
+ else
296
+ path.lineTo(b.x, b.y);
297
+ if (heads && (hot || !focus))
298
+ this.arrow(head, cx, cy, b, rad(b), k);
299
+ if (e.edge.label && relationWords && (hot || !focus))
300
+ edgeLabels.push(e);
301
+ }
302
+ ctx.strokeStyle = P.edge;
303
+ ctx.fillStyle = P.edge;
304
+ ctx.globalAlpha = focus ? 0.07 : Math.min(0.65, 0.12 + 0.35 * k);
305
+ ctx.lineWidth = 1 / k;
306
+ ctx.stroke(rest);
307
+ ctx.fill(restHeads);
308
+ ctx.globalAlpha = 0.95;
309
+ ctx.lineWidth = 1.75 / k;
310
+ for (const [colour, p] of litPaths) {
311
+ ctx.strokeStyle = colour;
312
+ ctx.fillStyle = colour;
313
+ ctx.stroke(p.line);
314
+ ctx.fill(p.head);
315
+ }
316
+ // Nodes: dimmed first, then lit, each batched by kind. A dot too small to
317
+ // overlap its neighbour visibly skips the ground-coloured halo.
318
+ const halo = k >= 0.5 ? 1.4 / k : 0;
319
+ for (const pass of [false, true]) {
320
+ if (pass === false && !focus)
321
+ continue;
322
+ const groups = new Map();
323
+ for (const s of this.nodes) {
324
+ if (!this.visible(s) || !onScreen(s) || lit(s) !== pass)
325
+ continue;
326
+ const kind = s.node.kind;
327
+ let p = groups.get(kind);
328
+ if (!p)
329
+ groups.set(kind, (p = new Path2D()));
330
+ this.shape(p, this.shapes.get(kind) ?? 'circle', s.x, s.y, rad(s));
331
+ }
332
+ ctx.globalAlpha = pass ? 1 : 0.12;
333
+ ctx.lineWidth = halo;
334
+ ctx.strokeStyle = P.ground;
335
+ for (const [kind, p] of groups) {
336
+ ctx.fillStyle = P.kinds.get(kind) ?? P.fallback;
337
+ ctx.fill(p);
338
+ if (halo)
339
+ ctx.stroke(p);
340
+ }
341
+ }
342
+ ctx.globalAlpha = 1;
343
+ // Rings: emphasis (double, in the kind's colour), search hits, the selection.
344
+ for (const s of this.nodes) {
345
+ if (!this.visible(s) || !onScreen(s))
346
+ continue;
347
+ const ring = s === this.selected || s === focus;
348
+ if (!s.node.emphasis && !ring && !this.matches.has(s))
349
+ continue;
350
+ const r = rad(s);
351
+ const shape = this.shapes.get(s.node.kind) ?? 'circle';
352
+ ctx.globalAlpha = lit(s) ? 1 : 0.2;
353
+ if (ring) {
354
+ const glow = new Path2D();
355
+ this.shape(glow, shape, s.x, s.y, r + 7 / k);
356
+ ctx.fillStyle = this.colour(s);
357
+ ctx.globalAlpha = 0.22;
358
+ ctx.fill(glow);
359
+ ctx.globalAlpha = 1;
360
+ }
361
+ const p = new Path2D();
362
+ this.shape(p, shape, s.x, s.y, r + 3.5 / k);
363
+ ctx.lineWidth = (ring ? 2 : 1.4) / k;
364
+ ctx.strokeStyle = ring || this.matches.has(s) ? P.ink : this.colour(s);
365
+ ctx.stroke(p);
366
+ }
367
+ ctx.globalAlpha = 1;
368
+ this.labels(ctx, P, focus, near, edgeLabels, onScreen, rad);
369
+ }
370
+ colour(s) {
371
+ return this.palette?.kinds.get(s.node.kind) ?? this.palette?.fallback ?? 'currentColor';
372
+ }
373
+ control(e) {
374
+ const a = e.source;
375
+ const b = e.target;
376
+ const dx = b.x - a.x;
377
+ const dy = b.y - a.y;
378
+ const len = Math.hypot(dx, dy) || 1;
379
+ const curve = Math.min(len * 0.1, 22) * e.bend;
380
+ return [(a.x + b.x) / 2 - (dy / len) * curve, (a.y + b.y) / 2 + (dx / len) * curve];
381
+ }
382
+ arrow(p, cx, cy, b, r, k) {
383
+ const dx = b.x - cx;
384
+ const dy = b.y - cy;
385
+ const len = Math.hypot(dx, dy) || 1;
386
+ const ux = dx / len;
387
+ const uy = dy / len;
388
+ const tipX = b.x - ux * (r + 2 / k);
389
+ const tipY = b.y - uy * (r + 2 / k);
390
+ const size = 7 / k;
391
+ p.moveTo(tipX, tipY);
392
+ p.lineTo(tipX - ux * size - uy * size * 0.5, tipY - uy * size + ux * size * 0.5);
393
+ p.lineTo(tipX - ux * size + uy * size * 0.5, tipY - uy * size - ux * size * 0.5);
394
+ p.closePath();
395
+ }
396
+ shape(p, shape, x, y, r) {
397
+ if (shape === 'square') {
398
+ const h = r * 0.89;
399
+ p.rect(x - h, y - h, h * 2, h * 2);
400
+ }
401
+ else if (shape === 'diamond') {
402
+ const h = r * 1.22;
403
+ p.moveTo(x, y - h);
404
+ p.lineTo(x + h, y);
405
+ p.lineTo(x, y + h);
406
+ p.lineTo(x - h, y);
407
+ p.closePath();
408
+ }
409
+ else if (shape === 'triangle') {
410
+ const h = r * 1.3;
411
+ p.moveTo(x, y - h);
412
+ p.lineTo(x + h * 0.866, y + h * 0.5);
413
+ p.lineTo(x - h * 0.866, y + h * 0.5);
414
+ p.closePath();
415
+ }
416
+ else {
417
+ p.moveTo(x + r, y);
418
+ p.arc(x, y, r, 0, Math.PI * 2);
419
+ }
420
+ }
421
+ measure(ctx, font, text) {
422
+ const key = `${font}|${text}`;
423
+ let w = this.widths.get(key);
424
+ if (w === undefined) {
425
+ ctx.font = font;
426
+ w = ctx.measureText(text).width;
427
+ this.widths.set(key, w);
428
+ }
429
+ return w;
430
+ }
431
+ /** Reserve a screen box unless it overlaps one already reserved. */
432
+ claim(x0, y0, x1, y1) {
433
+ const c0 = Math.floor(x0 / CELL);
434
+ const c1 = Math.floor(x1 / CELL);
435
+ const r0 = Math.floor(y0 / CELL);
436
+ const r1 = Math.floor(y1 / CELL);
437
+ const b = this.boxes;
438
+ for (let c = c0; c <= c1; c++) {
439
+ for (let r = r0; r <= r1; r++) {
440
+ for (const i of this.grid.get(c * 4096 + r) ?? []) {
441
+ if (x0 < b[i + 2] && x1 > b[i] && y0 < b[i + 3] && y1 > b[i + 1])
442
+ return false;
443
+ }
444
+ }
445
+ }
446
+ const i = b.length;
447
+ b.push(x0, y0, x1, y1);
448
+ for (let c = c0; c <= c1; c++) {
449
+ for (let r = r0; r <= r1; r++) {
450
+ const key = c * 4096 + r;
451
+ const cell = this.grid.get(key);
452
+ if (cell)
453
+ cell.push(i);
454
+ else
455
+ this.grid.set(key, [i]);
456
+ }
457
+ }
458
+ return true;
459
+ }
460
+ labels(ctx, P, focus, near, edgeLabels, onScreen, rad) {
461
+ const { k, x: tx, y: ty } = this.cam;
462
+ const { dpr, width: W, height: H } = this;
463
+ this.grid.clear();
464
+ this.boxes = [];
465
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
466
+ ctx.textAlign = 'center';
467
+ ctx.lineJoin = 'round';
468
+ ctx.globalAlpha = 1;
469
+ // The discs a label must not cover: under a focus, the lit ones; at rest,
470
+ // only nodes drawn large. A haloed name reads fine across small dots.
471
+ for (const s of this.nodes) {
472
+ if (!this.visible(s) || !onScreen(s))
473
+ continue;
474
+ const r = rad(s) * k;
475
+ if (focus ? s !== focus && !near?.has(s) : r < 8)
476
+ continue;
477
+ const sx = tx + s.x * k;
478
+ const sy = ty + s.y * k;
479
+ this.claim(sx - r, sy - r, sx + r, sy + r);
480
+ }
481
+ const order = [];
482
+ if (focus) {
483
+ order.push(focus);
484
+ const ring = [...(near ?? [])].sort((p, q) => q.degree - p.degree);
485
+ order.push(...ring);
486
+ }
487
+ else {
488
+ if (this.selected)
489
+ order.push(this.selected);
490
+ order.push(...this.matches);
491
+ order.push(...this.ranked);
492
+ }
493
+ const seen = new Set();
494
+ const quota = Math.ceil((W * H) / LABEL_AREA);
495
+ let placed = 0;
496
+ let tries = 0;
497
+ const body = `500 12px ${P.font}`;
498
+ const strong = `600 13px ${P.font}`;
499
+ for (const s of order) {
500
+ if (placed >= MAX_LABELS || tries >= MAX_LABEL_TRIES)
501
+ break;
502
+ if (seen.has(s) || !this.visible(s) || !onScreen(s))
503
+ continue;
504
+ seen.add(s);
505
+ tries++;
506
+ const text = clip(s.node.label);
507
+ const prominent = s === focus || s === this.selected || !!s.node.emphasis;
508
+ if (!focus && placed >= quota && rad(s) * k < 7 && !prominent && !this.matches.has(s))
509
+ continue;
510
+ const font = prominent ? strong : body;
511
+ const w = this.measure(ctx, font, text);
512
+ const h = prominent ? 15 : 14;
513
+ const sx = tx + s.x * k;
514
+ const r = rad(s) * k;
515
+ if (sx + w / 2 < 0 || sx - w / 2 > W)
516
+ continue;
517
+ // Below the node, else above it; the focused or selected name is drawn
518
+ // whatever it lands on, since it is the one the reader is looking for.
519
+ const below = ty + s.y * k + r + 4;
520
+ const above = ty + s.y * k - r - 4 - h;
521
+ const fits = (y) => this.claim(sx - w / 2 - 3, y - 2, sx + w / 2 + 3, y + h + 2);
522
+ const sy = fits(below) ? below : fits(above) ? above : s === focus || s === this.selected ? below : null;
523
+ if (sy === null || sy > H || sy + h < 0)
524
+ continue;
525
+ this.text(ctx, font, text, sx, sy, 'top', P.ink, P.ground);
526
+ placed++;
527
+ }
528
+ const small = `600 10px ${P.font}`;
529
+ let edgesPlaced = 0;
530
+ for (const e of edgeLabels) {
531
+ if (edgesPlaced >= MAX_EDGE_LABELS)
532
+ break;
533
+ const label = e.edge.label;
534
+ if (!label)
535
+ continue;
536
+ const [cx, cy] = this.control(e);
537
+ const mx = tx + (0.25 * e.source.x + 0.5 * cx + 0.25 * e.target.x) * k;
538
+ const my = ty + (0.25 * e.source.y + 0.5 * cy + 0.25 * e.target.y) * k;
539
+ if (mx < 0 || mx > W || my < 0 || my > H)
540
+ continue;
541
+ const text = clip(label);
542
+ const w = this.measure(ctx, small, text);
543
+ if (!this.claim(mx - w / 2 - 3, my - 7, mx + w / 2 + 3, my + 7))
544
+ continue;
545
+ const hot = !!focus && (e.source === focus || e.target === focus);
546
+ this.text(ctx, small, text, mx, my, 'middle', hot ? P.ink : P.inkSoft, P.ground);
547
+ edgesPlaced++;
548
+ }
549
+ }
550
+ text(ctx, font, text, x, y, baseline, fill, halo) {
551
+ ctx.font = font;
552
+ ctx.textBaseline = baseline;
553
+ ctx.lineWidth = 3.5;
554
+ ctx.strokeStyle = halo;
555
+ ctx.strokeText(text, x, y);
556
+ ctx.fillStyle = fill;
557
+ ctx.fillText(text, x, y);
558
+ }
559
+ }
560
+ function clip(text) {
561
+ return text.length > 32 ? `${text.slice(0, 31)}…` : text;
562
+ }
@@ -0,0 +1,33 @@
1
+ /** One entity. `kind` keys it to a `GraphKind`; extra fields ride along to the callbacks. */
2
+ export interface GraphNode {
3
+ id: string;
4
+ label: string;
5
+ kind: string;
6
+ /** One line under the title in the detail card. */
7
+ description?: string;
8
+ /** A second ring: the subject of the page, a watched entity. */
9
+ emphasis?: boolean;
10
+ /** Radius in px at zoom 1. Defaults from the node's degree. */
11
+ size?: number;
12
+ }
13
+ /** One relation. Edges naming an id not in `nodes` are dropped. */
14
+ export interface GraphEdge {
15
+ source: string;
16
+ target: string;
17
+ label?: string;
18
+ }
19
+ export type GraphShape = 'circle' | 'square' | 'diamond' | 'triangle';
20
+ /** A legend row. The colour is any CSS colour; a `var(--…)` resolves against the graph's own element. */
21
+ export interface GraphKind {
22
+ id: string;
23
+ label: string;
24
+ color?: string;
25
+ shape?: GraphShape;
26
+ }
27
+ /** A node beside the selected one, with the relation that joins them. */
28
+ export interface GraphNeighbour<N extends GraphNode = GraphNode> {
29
+ node: N;
30
+ label?: string;
31
+ /** `out` when the selected node is the edge's source. */
32
+ direction: 'in' | 'out';
33
+ }
@@ -0,0 +1 @@
1
+ export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@poodle64/ui",
3
- "version": "2026.10.16",
3
+ "version": "2026.10.17",
4
4
  "description": "Household shared component layer: shadcn-svelte primitives (bits-ui) plus the composed page chrome every app builds its routes from, restyled by each app's @poodle64/design-tokens alias layer. One fix reaches every app.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -83,6 +83,7 @@
83
83
  "@lucide/svelte": "^1.25.0",
84
84
  "@tanstack/table-core": "^8.21.3",
85
85
  "clsx": "^2.1.1",
86
+ "d3-force": "^3.0.0",
86
87
  "esm-env": "^1.2.2",
87
88
  "html-to-image": "^1.11.13",
88
89
  "tailwind-merge": "^3.6.0",
@@ -97,6 +98,7 @@
97
98
  "@tailwindcss/cli": "^4.3.2",
98
99
  "@testing-library/jest-dom": "^7.0.0",
99
100
  "@testing-library/svelte": "^5.4.2",
101
+ "@types/d3-force": "^3.0.10",
100
102
  "@types/node": "^22.20.1",
101
103
  "bits-ui": "^2.18.1",
102
104
  "formsnap": "^2.0.1",
@@ -119,7 +121,7 @@
119
121
  "registry:check": "node scripts/generate-registry.mjs --check",
120
122
  "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
121
123
  "test": "pnpm run build && vitest run",
122
- "test:browser": "pnpm run harness:build && node harness/drive.mjs",
124
+ "test:browser": "pnpm run harness:build && node harness/drive.mjs && node harness/graph.mjs",
123
125
  "prepublishOnly": "pnpm run build",
124
126
  "harness:build": "vite build --config harness/vite.config.ts && tailwindcss -i harness/harness.css -o harness/dist/app.css --minify",
125
127
  "harness:serve": "python3 -m http.server 4180 --directory harness/dist"
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "meta": {
3
3
  "package": "@poodle64/ui",
4
- "version": "2026.10.16",
4
+ "version": "2026.10.17",
5
5
  "generatedBy": "scripts/generate-registry.mjs",
6
6
  "source": "scripts/situations.json + package source (DO NOT EDIT the outputs by hand)",
7
- "componentCount": 69,
8
- "situationCount": 19
7
+ "componentCount": 70,
8
+ "situationCount": 20
9
9
  },
10
10
  "situations": [
11
11
  {
@@ -344,6 +344,20 @@
344
344
  }
345
345
  ]
346
346
  },
347
+ {
348
+ "key": "relations-between-things",
349
+ "title": "Who is connected to what",
350
+ "description": "People, organisations, sources or any entities as nodes and their relations as edges: a live, explorable network rather than a list of links.",
351
+ "components": [
352
+ {
353
+ "name": "GraphCanvas",
354
+ "dir": "graph-canvas",
355
+ "import": "@poodle64/ui/graph-canvas",
356
+ "props": "nodes, edges, kinds?, selected?, hiddenKinds?, directed?, onselect?, onactivate?, detail?",
357
+ "insteadOf": "a hand-built canvas or SVG force layout, or a graph library's own chrome and colours"
358
+ }
359
+ ]
360
+ },
347
361
  {
348
362
  "key": "page-frame",
349
363
  "title": "The frame every route shares",
@@ -1,7 +1,7 @@
1
1
  # @poodle64/ui — situation → component map
2
2
 
3
3
  <!-- GENERATED by scripts/generate-registry.mjs from scripts/situations.json + package source. DO NOT EDIT. -->
4
- Generated from `@poodle64/ui@2026.10.16`. 69 components, 19 situations.
4
+ Generated from `@poodle64/ui@2026.10.17`. 70 components, 20 situations.
5
5
 
6
6
  **Read this before writing a `<div>`.** Find the SITUATION you are in below, then compose the component named for it — do not hand-build it from raw `Card` or utility classes. Import is `import { Name } from '<import path>'`. Props marked `?` are optional. This map is the retrieval step the [`frontend-design` skill] makes mandatory; the [CHI 2026 study] measured composing-from-a-registry at 95% design-system compliance against 71% for writing the CSS from a prose style guide.
7
7
 
@@ -146,6 +146,14 @@ _One page at a time, fitted to its pane both ways so an A4 statement and a tall
146
146
  | `PageCanvas` | `@poodle64/ui/page-canvas` | `pageCount, page, currentPage?, regions?, lookedRegions?, activeRegionId?, focusedRegionId?, zoom?` | a hand-built page viewer per app (godswood's DocumentViewer, pebblestone's PdfViewer) each re-deriving fit/zoom/pan, now with no way to outline what was read (#839) |
147
147
  | `DocumentPane` | `@poodle64/ui/document-pane` | `title, subtitle?, pages?, pageCount?, page?, currentPage?, zoom?, actions?, onClose?` | a PageCanvas framed by hand with its own title row, download and close; the pane the record page opens a document in, beside the column or in its place |
148
148
 
149
+ ## Who is connected to what
150
+
151
+ _People, organisations, sources or any entities as nodes and their relations as edges: a live, explorable network rather than a list of links._
152
+
153
+ | Component | Import | Key props | Reach for it instead of |
154
+ | --- | --- | --- | --- |
155
+ | `GraphCanvas` | `@poodle64/ui/graph-canvas` | `nodes, edges, kinds?, selected?, hiddenKinds?, directed?, onselect?, onactivate?, detail?` | a hand-built canvas or SVG force layout, or a graph library's own chrome and colours |
156
+
149
157
  ## The frame every route shares
150
158
 
151
159
  _The standing chrome — the app shell and its nav, the one page-title treatment, the persistent right-hand context column, a foldable working list beside the page's own content._