@smartmemory/wiki-components 0.5.0 → 0.6.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.
Files changed (62) hide show
  1. package/README.md +14 -2
  2. package/package.json +26 -4
  3. package/src/data-client.d.ts +27 -1
  4. package/src/data-client.jsx +3 -0
  5. package/src/graph/components/wiki/DocCard.jsx +46 -0
  6. package/src/graph/components/wiki/GraphBreadcrumb.jsx +23 -0
  7. package/src/graph/components/wiki/GraphCanvas.jsx +663 -0
  8. package/src/graph/components/wiki/GraphCanvas3D.jsx +1366 -0
  9. package/src/graph/components/wiki/GraphDocPane.jsx +56 -0
  10. package/src/graph/components/wiki/GraphLegend.jsx +119 -0
  11. package/src/graph/components/wiki/GraphLevelPicker.jsx +35 -0
  12. package/src/graph/components/wiki/graph/FilterChips.jsx +45 -0
  13. package/src/graph/components/wiki/graph/Omnibox.jsx +74 -0
  14. package/src/graph/components/wiki/graph/badges.js +46 -0
  15. package/src/graph/components/wiki/graph/bubbles3d.js +95 -0
  16. package/src/graph/components/wiki/graph/canvasBase.js +138 -0
  17. package/src/graph/components/wiki/graph/containment.js +49 -0
  18. package/src/graph/components/wiki/graph/overlay.js +134 -0
  19. package/src/graph/components/wiki/graph/pixiBase.js +210 -0
  20. package/src/graph/components/wiki/graph/threeBase.js +231 -0
  21. package/src/graph/hooks/useDebounced.js +11 -0
  22. package/src/graph/host.jsx +56 -0
  23. package/src/graph/index.d.ts +26 -0
  24. package/src/graph/index.js +4 -0
  25. package/src/graph/lib/areaFamilies.js +32 -0
  26. package/src/graph/lib/camFollow.js +30 -0
  27. package/src/graph/lib/canvasPort.js +93 -0
  28. package/src/graph/lib/cardVisibility.js +58 -0
  29. package/src/graph/lib/docCard.js +28 -0
  30. package/src/graph/lib/forceWorker.js +123 -0
  31. package/src/graph/lib/forceWorker3d.js +136 -0
  32. package/src/graph/lib/graph3d.js +330 -0
  33. package/src/graph/lib/graphArrows.js +108 -0
  34. package/src/graph/lib/graphExplore.js +546 -0
  35. package/src/graph/lib/graphFind.js +46 -0
  36. package/src/graph/lib/graphLasso.js +44 -0
  37. package/src/graph/lib/graphLevels.js +367 -0
  38. package/src/graph/lib/graphOrient.js +145 -0
  39. package/src/graph/lib/graphPath.js +86 -0
  40. package/src/graph/lib/graphPin.js +48 -0
  41. package/src/graph/lib/graphPress.js +27 -0
  42. package/src/graph/lib/graphShell.js +296 -0
  43. package/src/graph/lib/graphTheme.js +50 -0
  44. package/src/graph/lib/graphView.js +343 -0
  45. package/src/graph/lib/graphViews.js +103 -0
  46. package/src/graph/lib/groupBubbles.js +128 -0
  47. package/src/graph/lib/hubLines.js +51 -0
  48. package/src/graph/lib/idleTurn.js +31 -0
  49. package/src/graph/lib/labelDepth.js +59 -0
  50. package/src/graph/lib/layoutClient.js +69 -0
  51. package/src/graph/lib/layoutEngine.js +184 -0
  52. package/src/graph/lib/linkWeight.js +67 -0
  53. package/src/graph/lib/orphanPull.js +10 -0
  54. package/src/graph/lib/perf.js +14 -0
  55. package/src/graph/lib/reducedMotion.js +36 -0
  56. package/src/graph/lib/robustFit.js +62 -0
  57. package/src/graph/lib/wikiGraph.js +298 -0
  58. package/src/graph/pages/GraphPage.jsx +703 -0
  59. package/src/index.d.ts +6 -1
  60. package/src/index.js +2 -1
  61. package/src/lib.js +3 -0
  62. package/src/parts/Markdown.jsx +22 -0
@@ -0,0 +1,298 @@
1
+ // WIKI-GRAPH-VIEW-UX-1: the whole-workspace graph, Obsidian-style. Pure helpers (no DOM, no React) so the page, the
2
+ // canvas and the tests share one definition of what is drawn. Conventions copied from @smartmemory/graph (labels
3
+ // #cbd5e1 outlined #0f172a, labels hidden at small zoom), not the package itself: REPORT.md "Dependency decision".
4
+
5
+ import { perfNow, perfSpan } from "./perf";
6
+ import { AREA_FAMILIES, UNMAPPED, familyOf } from "./areaFamilies";
7
+
8
+ /** At most this many docs are fetched and drawn; over it the page says "showing first N of total". The eng wiki has
9
+ * ~4.8k docs, so it fits whole; the WebGL view (WIKI-GRAPH-OBSIDIAN-1) is benched to 50k. */
10
+ export const MAX_NODES = 60000;
11
+ /** Level of detail: above LOD_N nodes the graph draws small nodes, faint edges, and labels only when zoomed in. */
12
+ export const LOD_N = 1500;
13
+ /** Every label is drawn when the graph has at most this many nodes. */
14
+ export const LABEL_ALL = 150;
15
+ /** Never more than this many labels in one frame (the focused doc and its neighbours come first). */
16
+ export const LABEL_CAP = 300;
17
+ /** Graph colours on the dark field (`#0a0f1a`): Okabe-Ito's seven chromatic colours, which stay apart for the common kinds of
18
+ * colour blindness (its black is dropped, the field is that dark). One per group, the biggest first; the rest share OTHER.
19
+ * WIKI-GRAPH-3D-COLOR-SPHERES-1. */
20
+ export const PALETTE = ["#E69F00", "#56B4E9", "#009E73", "#F0E442", "#0072B2", "#D55E00", "#CC79A7"];
21
+ export const OTHER = "#7f8ea5";
22
+ /** Area mode: an area in no family (AREA_FAMILIES) is coloured and listed apart: not OTHER (that is "(no area)") and not a family colour. */
23
+ export const UNMAPPED_COLOR = "#B9A7FF";
24
+ const FAMILY_COLOR = new Map(Object.keys(AREA_FAMILIES).map((f, i) => [f, PALETTE[i]]));
25
+ /** Docs outside the current scope (`by=scope`): a dim slate, so the scope's own groups stand out. */
26
+ export const OUTSIDE = "#3b4556";
27
+ export const OUTSIDE_KEY = "(outside)";
28
+ const NO_AREA = "(no area)", ROOT_FOLDER = "(root)";
29
+
30
+ /** The group a doc is coloured by: its area, or the first two segments of its folder ("docs/core"), or "(root)". */
31
+ export function groupOf(item, by = "area") {
32
+ if (by === "area") return item.area || NO_AREA;
33
+ const segs = String(item.folder || "").split("/").filter(Boolean);
34
+ return segs.length ? segs.slice(0, 2).join("/") : ROOT_FOLDER;
35
+ }
36
+
37
+ /** `items` (graph_node rows) and `edges` ({from,to}) as drawable nodes and links. A link to a doc not in the set is
38
+ * dropped (the server only sends links into the live set; a capped fetch can still leave some out). */
39
+ export function buildGraph(items, edges, { by = "area", layout = null } = {}) {
40
+ const t0 = perfNow(); // the `wiki-graph:build` measure (WIKI-GRAPH-LOAD-1)
41
+ const counts = new Map();
42
+ for (const it of items) counts.set(groupOf(it, by), (counts.get(groupOf(it, by)) || 0) + 1);
43
+ const loose = (name) => name === NO_AREA || name === ROOT_FOLDER; // docs with no group of their own are grey, listed last
44
+ const familied = by === "area"; // area mode colours by family: one fixed colour per family, `family` on each (still per-area) group
45
+ const groups = [...counts.entries()]
46
+ .sort((a, b) => loose(a[0]) - loose(b[0]) || b[1] - a[1] || String(a[0]).localeCompare(String(b[0])))
47
+ .map(([name, count], i) => {
48
+ if (!familied) return { name, count, color: loose(name) ? OTHER : PALETTE[i] || OTHER };
49
+ if (loose(name)) return { name, count, color: OTHER };
50
+ const family = familyOf(name);
51
+ return { name, count, family, color: family === UNMAPPED ? UNMAPPED_COLOR : FAMILY_COLOR.get(family) };
52
+ });
53
+ const colour = new Map(groups.map((g) => [g.name, g.color]));
54
+ const nodes = items.map((it) => {
55
+ const group = groupOf(it, by);
56
+ return { id: it.doc_id, title: it.title || it.doc_id, area: it.area ?? null, folder: it.folder ?? "", group, color: colour.get(group), degree: 0 };
57
+ });
58
+ const byId = new Map(nodes.map((n) => [n.id, n]));
59
+ serveLayout(nodes, layout);
60
+ const links = [];
61
+ for (const e of edges) {
62
+ const a = byId.get(e.from), b = byId.get(e.to);
63
+ if (!a || !b || a === b) continue;
64
+ a.degree++; b.degree++;
65
+ links.push({ source: a.id, target: b.id });
66
+ }
67
+ perfSpan("wiki-graph:build", t0);
68
+ return { nodes, links, groups, byId };
69
+ }
70
+
71
+ /** WIKI-GRAPH-WARMSTART-3D-1: the host's served layout (`layout`: {xy?, xyz?}, one number pair or triple per item, in item order)
72
+ * onto the nodes as `warm2` / `warm3` ([x, y] / [x, y, z]) and `warmN` (how many nodes it covers). Each canvas starts a node there
73
+ * when it has no position of its own (`serverFits`). A part that is absent, the wrong length or not all finite is ignored. */
74
+ export function serveLayout(nodes, layout) {
75
+ for (const [key, dims] of [["xy", 2], ["xyz", 3]]) {
76
+ const at = layout?.[key];
77
+ if (!Array.isArray(at) || at.length !== dims * nodes.length || !at.every(Number.isFinite)) continue;
78
+ nodes.forEach((nd, i) => { nd[dims === 3 ? "warm3" : "warm2"] = at.slice(dims * i, dims * i + dims); nd.warmN = nodes.length; });
79
+ }
80
+ }
81
+
82
+ /** Whether `nodes` (a canvas's drawn set) is exactly the graph the served `dims`-D layout was computed for: every node has its
83
+ * position and the set is as big as the whole graph. A local graph, a level view (folders carry none) or a hidden-group subset
84
+ * never fits: a layout of the whole graph stretched over a part of it is no warm start. */
85
+ export const serverFits = (nodes, dims) => nodes.length > 0 && nodes.every((nd) => nd[dims === 3 ? "warm3" : "warm2"] && nd.warmN === nodes.length);
86
+
87
+ const segs = (path) => String(path || "").split("/").filter(Boolean);
88
+
89
+ // scopeColors results by scope, newest last, so `groupColor(name, scope)` can answer without the graph (CLUSTER-NORELAYOUT).
90
+ const scopeCache = new Map();
91
+ const SCOPE_CACHE_MAX = 8;
92
+
93
+ /** Colours relative to a SCOPE (the folder the reader is looking into; "" or null is the root). A doc under the scope is
94
+ * grouped by the next segment of its folder, and the group key is that child's FULL path (`core/api`), so hiding it never
95
+ * leaks to a same-named folder elsewhere. Docs directly in the scope take the key `<scope>/` (label "(here)", "(root)" at
96
+ * the root), which `cleanPath` can never produce. Docs outside the scope take OUTSIDE_KEY, listed last in colour OUTSIDE.
97
+ * Colours go by size rank within the scope (ties by name) over the graph given, so call it on the UNFILTERED graph and
98
+ * hiding never recolours; groups past the palette share OTHER.
99
+ * @returns {{groups: {name: string, label: string, count: number, color: string, path: string|null}[], groupOf: Map<string, string>}}
100
+ * `path` is the folder a legend drill opens (null for here / outside). */
101
+ export function scopeColors(graph, scope = null, palette = PALETTE) {
102
+ const base = segs(scope), scopePath = base.join("/");
103
+ const counts = new Map(), groupOf = new Map();
104
+ let outside = 0;
105
+ for (const n of graph.nodes) {
106
+ const f = segs(n.folder);
107
+ let key;
108
+ if (f.length < base.length || base.some((s, i) => f[i] !== s)) { key = OUTSIDE_KEY; outside++; }
109
+ else if (f.length === base.length) key = scopePath + "/";
110
+ else { key = f.slice(0, base.length + 1).join("/"); }
111
+ groupOf.set(n.id, key);
112
+ if (key !== OUTSIDE_KEY) counts.set(key, (counts.get(key) || 0) + 1);
113
+ }
114
+ const ranked = [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
115
+ const groups = ranked.map(([name, count], i) => {
116
+ const here = name.endsWith("/");
117
+ return {
118
+ name, count, color: palette[i] || OTHER, path: here ? null : name,
119
+ label: here ? (scopePath ? "(here)" : "(root)") : name.slice(scopePath ? scopePath.length + 1 : 0),
120
+ };
121
+ });
122
+ if (outside) groups.push({ name: OUTSIDE_KEY, label: OUTSIDE_KEY, count: outside, color: OUTSIDE, path: null });
123
+ scopeCache.delete(scopePath);
124
+ scopeCache.set(scopePath, new Map(groups.map((g) => [g.name, g.color])));
125
+ if (scopeCache.size > SCOPE_CACHE_MAX) scopeCache.delete(scopeCache.keys().next().value);
126
+ return { groups, groupOf };
127
+ }
128
+
129
+ /** The colour a group key has under `scope`, from the last scopeColors call for that scope (the page makes it before any
130
+ * consumer asks); OTHER when the scope was never coloured or the key is not in it. Same colour in 2D and 3D. */
131
+ export function groupColor(name, scope = null) {
132
+ return scopeCache.get(segs(scope).join("/"))?.get(name) ?? OTHER;
133
+ }
134
+
135
+ /** `graph` (buildGraph output) with every node's `group` and `color` taken from `scoped` (scopeColors output): new node
136
+ * objects, the same ids and links, so a canvas holding the old graph recolours in place with no relayout. */
137
+ export function recolour(graph, scoped) {
138
+ const colour = new Map(scoped.groups.map((g) => [g.name, g.color]));
139
+ const nodes = graph.nodes.map((n) => { const group = scoped.groupOf.get(n.id); return { ...n, group, color: colour.get(group) }; });
140
+ return { ...graph, nodes, groups: scoped.groups, byId: new Map(nodes.map((n) => [n.id, n])) };
141
+ }
142
+
143
+ /** Starting positions. Clustered (`cluster`, the default): group centres on a circle, each group's docs on a sunflower
144
+ * spiral around its centre; good seeds let the force layout settle in far fewer ticks. Not clustered (the Graph page's
145
+ * default): every doc at a pseudo-random point of one disc, no group structure and no rings (a spiral or ring seed
146
+ * survives as concentric rings when the pull is weak), deterministic by index so a reload lays out the same way; the
147
+ * returned centre of every group is 0,0. Mutates `nodes` (x, y). */
148
+ export function seedPositions(nodes, cluster = true) {
149
+ if (!cluster) {
150
+ const R = Math.max(80, Math.sqrt(nodes.length) * 9);
151
+ const rnd = (i) => { let h = Math.imul(i + 1, 0x9e3779b1) ^ 0x85ebca6b; h = Math.imul(h ^ (h >>> 15), 0x2c1b3c6d); h = Math.imul(h ^ (h >>> 12), 0x297a2d39); return ((h ^ (h >>> 15)) >>> 0) / 4294967296; };
152
+ nodes.forEach((n, i) => { const r = R * Math.sqrt(rnd(2 * i)), a = 2 * Math.PI * rnd(2 * i + 1); n.x = Math.cos(a) * r; n.y = Math.sin(a) * r; });
153
+ return new Map(nodes.map((n) => [n.group, { x: 0, y: 0 }]));
154
+ }
155
+ const groups = [...new Set(nodes.map((n) => n.group))];
156
+ const R = Math.max(80, Math.sqrt(nodes.length) * 9);
157
+ const centre = new Map(groups.map((g, i) => {
158
+ const a = (2 * Math.PI * i) / Math.max(groups.length, 1);
159
+ return [g, groups.length === 1 ? { x: 0, y: 0 } : { x: Math.cos(a) * R, y: Math.sin(a) * R }];
160
+ }));
161
+ const seen = new Map();
162
+ const golden = Math.PI * (3 - Math.sqrt(5));
163
+ for (const n of nodes) {
164
+ const k = seen.get(n.group) || 0;
165
+ seen.set(n.group, k + 1);
166
+ const c = centre.get(n.group), r = 6 * Math.sqrt(k + 0.5);
167
+ n.x = c.x + Math.cos(k * golden) * r;
168
+ n.y = c.y + Math.sin(k * golden) * r;
169
+ }
170
+ return centre;
171
+ }
172
+
173
+ /** The ids one link away from `id`, with `id` itself. Links may carry ids or (after d3-force) node objects. */
174
+ export function neighbourhood(links, id) {
175
+ const out = new Set(id ? [id] : []);
176
+ if (!id) return out;
177
+ for (const l of links) {
178
+ const s = typeof l.source === "object" ? l.source.id : l.source;
179
+ const t = typeof l.target === "object" ? l.target.id : l.target;
180
+ if (s === id) out.add(t);
181
+ else if (t === id) out.add(s);
182
+ }
183
+ return out;
184
+ }
185
+
186
+ /** Docs whose title holds `q` (case-insensitive), title-prefix matches first, then shorter titles; at most `limit`. */
187
+ export function searchNodes(nodes, q, limit = 8) {
188
+ const needle = String(q || "").trim().toLowerCase();
189
+ if (!needle) return [];
190
+ const hits = [];
191
+ for (const n of nodes) {
192
+ const t = n.title.toLowerCase();
193
+ const at = t.indexOf(needle);
194
+ if (at >= 0) hits.push([at === 0 ? 0 : 1, t.length, n]);
195
+ }
196
+ hits.sort((a, b) => a[0] - b[0] || a[1] - b[1] || a[2].title.localeCompare(b[2].title));
197
+ return hits.slice(0, limit).map((h) => h[2]);
198
+ }
199
+
200
+ /** What one frame draws for `count` nodes at zoom `k`: node radius (screen px), edge alpha (the starting value; a drawn
201
+ * frame uses edgeAlphaFor, WIKI-GRAPH-DECLUTTER-1), which labels, and `labelK`, the zoom the non-hub labels start fading
202
+ * in at (0 = no fade). labels: "all" (every node in view), "zoomed" (hubs, then the rest fading in), "focus" (the lit
203
+ * docs and the hubs only). */
204
+ export function lodFor(count, k) {
205
+ const big = count > LOD_N;
206
+ const labelK = count <= LABEL_ALL ? 0 : big ? 2 : 1;
207
+ const labels = !labelK ? "all" : k >= labelK ? "zoomed" : "focus";
208
+ return { big, radius: big ? 2.5 : 4, edgeAlpha: big ? 0.07 : 0.22, labels, labelK };
209
+ }
210
+
211
+ // WIKI-GRAPH-DECLUTTER-1: the declutter rules both renderers share (the 2D overlay and bases, and the 3D base, which feeds
212
+ // the same overlay its projection). Owner, 2026-10-20: labels only for hubs, the lit docs and what fits; never two labels
213
+ // overlapping; the rest fade in with zoom; arrowheads never stacked on hubs (graphArrows.js); edges quieter when dense.
214
+
215
+ /** One label per this many px² of the view, so a 1300 x 670 view shows at most ~36 labels besides the lit docs. */
216
+ export const LABEL_AREA = 24000;
217
+ /** A view never gets fewer label slots than this. */
218
+ export const LABEL_MIN = 8;
219
+ /** A hub: a doc with at least this many links, or a folder with at least this many docs (the level view's folder degree
220
+ * is its size, GraphPage). Hubs keep their labels zoomed out; the same line as graphArrows ARROW_HUB_DEGREE (over 8). */
221
+ export const HUB_DEGREE = 9;
222
+ /** Non-hub labels fade from alpha 0 at lodFor's `labelK` to 1 at LABEL_FADE x labelK. */
223
+ export const LABEL_FADE = 1.6;
224
+ /** Below this alpha a label is not drawn at all (it would still take a slot and block others). */
225
+ export const LABEL_ALPHA_MIN = 0.05;
226
+
227
+ /** The label slots of a `w` x `h` px view: area / LABEL_AREA, at least LABEL_MIN, never over LABEL_CAP. */
228
+ export const labelBudget = (w, h) => Math.max(LABEL_MIN, Math.min(LABEL_CAP, Math.round((Math.max(0, w) * Math.max(0, h)) / LABEL_AREA)));
229
+
230
+ /** A non-hub label's alpha at zoom `k` when labels start at `k0` (lodFor labelK): 0 up to k0, rising linearly to 1 at
231
+ * LABEL_FADE x k0, 1 from there on. k0 = 0 (a small graph): always 1. Never falls as k grows. */
232
+ export function labelFade(k, k0) {
233
+ if (!(k0 > 0)) return 1;
234
+ return Math.max(0, Math.min(1, (k / k0 - 1) / (LABEL_FADE - 1)));
235
+ }
236
+
237
+ /** Edge alpha from how many links cross the view: EDGE_ALPHA.max up to `ref` links per million px², then falling with the
238
+ * square root of the density, never under `min`. */
239
+ export const EDGE_ALPHA = { max: 0.28, min: 0.05, ref: 300 };
240
+
241
+ /** How many of the first `count` links (`pairs`: node index pairs) touch a `w` x `h` view: a link counts when the box of its
242
+ * two ends meets the view. Node `i` is at screen (xs[stride*i] * k + x, xs[stride*i+1] * k + y) for `view` {k, x, y}: the
243
+ * 2D world positions (stride 2) or the 3D projection (stride 3, view {k: 1, x: 0, y: 0}). */
244
+ export function linksInView(xs, pairs, count, view, w, h, stride = 2) {
245
+ const { k, x, y } = view;
246
+ const x0 = -x / k, y0 = -y / k, x1 = (w - x) / k, y1 = (h - y) / k;
247
+ let c = 0;
248
+ for (let e = 0; e < count; e++) {
249
+ const a = stride * pairs[2 * e], b = stride * pairs[2 * e + 1];
250
+ const ax = xs[a], ay = xs[a + 1], bx = xs[b], by = xs[b + 1];
251
+ if ((ax < x0 && bx < x0) || (ax > x1 && bx > x1) || (ay < y0 && by < y0) || (ay > y1 && by > y1)) continue;
252
+ c++;
253
+ }
254
+ return c;
255
+ }
256
+
257
+ /** The edge alpha for `inView` links crossing a `w` x `h` view (EDGE_ALPHA). Never rises as the count grows. */
258
+ export function edgeAlphaFor(inView, w, h) {
259
+ const d = inView / Math.max(1e-6, (Math.max(1, w) * Math.max(1, h)) / 1e6);
260
+ return Math.max(EDGE_ALPHA.min, Math.min(EDGE_ALPHA.max, EDGE_ALPHA.max * Math.sqrt(EDGE_ALPHA.ref / Math.max(d, EDGE_ALPHA.ref))));
261
+ }
262
+
263
+ /** A view (screen = world * k + {x, y}) that fits every node in a `w` x `h` box with a margin. */
264
+ export function fitView(nodes, w, h, pad = 40) {
265
+ if (!nodes.length) return { k: 1, x: w / 2, y: h / 2 };
266
+ let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
267
+ for (const n of nodes) {
268
+ if (n.x < x0) x0 = n.x; if (n.x > x1) x1 = n.x;
269
+ if (n.y < y0) y0 = n.y; if (n.y > y1) y1 = n.y;
270
+ }
271
+ const k = Math.min(4, Math.max(0.02, Math.min((w - 2 * pad) / Math.max(x1 - x0, 1), (h - 2 * pad) / Math.max(y1 - y0, 1))));
272
+ return { k, x: w / 2 - ((x0 + x1) / 2) * k, y: h / 2 - ((y0 + y1) / 2) * k };
273
+ }
274
+
275
+ /** fitView over a flat positions array (x, y per node index, as the layout worker writes it): a view that fits the nodes
276
+ * at indices `idx` (any iterable) in a `w` x `h` box with a margin. `sizes` (diameters per node index, optional): fit the
277
+ * circles' edges, not their centres, so a big folder is shown whole (WIKI-GRAPH-P0-LEVEL0-ZOOM-1). */
278
+ export function fitBox(pos, idx, w, h, pad = 40, sizes = null) {
279
+ let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
280
+ for (const i of idx) {
281
+ const x = pos[2 * i], y = pos[2 * i + 1], r = sizes ? sizes[i] / 2 : 0;
282
+ if (x - r < x0) x0 = x - r; if (x + r > x1) x1 = x + r;
283
+ if (y - r < y0) y0 = y - r; if (y + r > y1) y1 = y + r;
284
+ }
285
+ if (x0 === Infinity) return { k: 1, x: w / 2, y: h / 2 };
286
+ const k = Math.min(4, Math.max(0.02, Math.min((w - 2 * pad) / Math.max(x1 - x0, 1), (h - 2 * pad) / Math.max(y1 - y0, 1))));
287
+ return { k, x: w / 2 - ((x0 + x1) / 2) * k, y: h / 2 - ((y0 + y1) / 2) * k };
288
+ }
289
+
290
+ /** A view centred on world point (x, y) at zoom `k`. */
291
+ export const centreOn = (x, y, w, h, k) => ({ k, x: w / 2 - x * k, y: h / 2 - y * k });
292
+
293
+ /** Zoom by `factor` keeping screen point (sx, sy) fixed, clamped to [kMin, 8]. */
294
+ export function zoomAt(view, sx, sy, factor, kMin = 0.02) {
295
+ const k = Math.min(8, Math.max(kMin, view.k * factor));
296
+ const f = k / view.k;
297
+ return { k, x: sx - (sx - view.x) * f, y: sy - (sy - view.y) * f };
298
+ }