@smartmemory/wiki-components 0.4.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 (64) hide show
  1. package/README.md +23 -3
  2. package/package.json +26 -4
  3. package/src/Ledger.jsx +47 -0
  4. package/src/data-client.d.ts +40 -1
  5. package/src/data-client.jsx +5 -0
  6. package/src/graph/components/wiki/DocCard.jsx +46 -0
  7. package/src/graph/components/wiki/GraphBreadcrumb.jsx +23 -0
  8. package/src/graph/components/wiki/GraphCanvas.jsx +663 -0
  9. package/src/graph/components/wiki/GraphCanvas3D.jsx +1366 -0
  10. package/src/graph/components/wiki/GraphDocPane.jsx +56 -0
  11. package/src/graph/components/wiki/GraphLegend.jsx +119 -0
  12. package/src/graph/components/wiki/GraphLevelPicker.jsx +35 -0
  13. package/src/graph/components/wiki/graph/FilterChips.jsx +45 -0
  14. package/src/graph/components/wiki/graph/Omnibox.jsx +74 -0
  15. package/src/graph/components/wiki/graph/badges.js +46 -0
  16. package/src/graph/components/wiki/graph/bubbles3d.js +95 -0
  17. package/src/graph/components/wiki/graph/canvasBase.js +138 -0
  18. package/src/graph/components/wiki/graph/containment.js +49 -0
  19. package/src/graph/components/wiki/graph/overlay.js +134 -0
  20. package/src/graph/components/wiki/graph/pixiBase.js +210 -0
  21. package/src/graph/components/wiki/graph/threeBase.js +231 -0
  22. package/src/graph/hooks/useDebounced.js +11 -0
  23. package/src/graph/host.jsx +56 -0
  24. package/src/graph/index.d.ts +26 -0
  25. package/src/graph/index.js +4 -0
  26. package/src/graph/lib/areaFamilies.js +32 -0
  27. package/src/graph/lib/camFollow.js +30 -0
  28. package/src/graph/lib/canvasPort.js +93 -0
  29. package/src/graph/lib/cardVisibility.js +58 -0
  30. package/src/graph/lib/docCard.js +28 -0
  31. package/src/graph/lib/forceWorker.js +123 -0
  32. package/src/graph/lib/forceWorker3d.js +136 -0
  33. package/src/graph/lib/graph3d.js +330 -0
  34. package/src/graph/lib/graphArrows.js +108 -0
  35. package/src/graph/lib/graphExplore.js +546 -0
  36. package/src/graph/lib/graphFind.js +46 -0
  37. package/src/graph/lib/graphLasso.js +44 -0
  38. package/src/graph/lib/graphLevels.js +367 -0
  39. package/src/graph/lib/graphOrient.js +145 -0
  40. package/src/graph/lib/graphPath.js +86 -0
  41. package/src/graph/lib/graphPin.js +48 -0
  42. package/src/graph/lib/graphPress.js +27 -0
  43. package/src/graph/lib/graphShell.js +296 -0
  44. package/src/graph/lib/graphTheme.js +50 -0
  45. package/src/graph/lib/graphView.js +343 -0
  46. package/src/graph/lib/graphViews.js +103 -0
  47. package/src/graph/lib/groupBubbles.js +128 -0
  48. package/src/graph/lib/hubLines.js +51 -0
  49. package/src/graph/lib/idleTurn.js +31 -0
  50. package/src/graph/lib/labelDepth.js +59 -0
  51. package/src/graph/lib/layoutClient.js +69 -0
  52. package/src/graph/lib/layoutEngine.js +184 -0
  53. package/src/graph/lib/linkWeight.js +67 -0
  54. package/src/graph/lib/orphanPull.js +10 -0
  55. package/src/graph/lib/perf.js +14 -0
  56. package/src/graph/lib/reducedMotion.js +36 -0
  57. package/src/graph/lib/robustFit.js +62 -0
  58. package/src/graph/lib/wikiGraph.js +298 -0
  59. package/src/graph/pages/GraphPage.jsx +703 -0
  60. package/src/index.d.ts +11 -2
  61. package/src/index.js +4 -1
  62. package/src/lib.js +3 -0
  63. package/src/parts/LedgerTimeline.jsx +20 -0
  64. package/src/parts/Markdown.jsx +22 -0
@@ -0,0 +1,663 @@
1
+ import React, { forwardRef, useEffect, useImperativeHandle, useRef } from "react";
2
+ import { quadtree } from "d3-quadtree";
3
+ import { FALLBACK_WARNING, PHYSICS_DEFAULTS, SIZE_MAX, DIM_DEFAULT, chooseRenderer, dimBase, drawRadius, layoutRadii, levelSize, litOf, localKeep, nodeLooks, pickable, visibleLinks, visibleMask } from "../../lib/graphView";
4
+ import { startLayout } from "../../lib/layoutClient";
5
+ import { perfSpan } from "../../lib/perf";
6
+ import { dirOf, hotHeads } from "../../lib/graphArrows";
7
+ import { LOD_N, OTHER, centreOn, lodFor, seedPositions, serverFits, zoomAt } from "../../lib/wikiGraph";
8
+ import { robustBox } from "../../lib/robustFit";
9
+ import { layout2d, placeAppearing } from "../../lib/groupBubbles";
10
+ import { linkArrays, refreshSameTopology } from "../../lib/graph3d";
11
+ import { linkBuckets } from "../../lib/linkWeight";
12
+ import { createCanvasBase } from "./graph/canvasBase";
13
+ import { drawOverlay } from "./graph/overlay";
14
+ import { containPairs, drawContainment } from "./graph/containment";
15
+ import { drawnMemo, layoutPairs } from "../../lib/hubLines";
16
+ import { followDone, followFor, follows } from "../../lib/camFollow";
17
+ import { badgeAt, drawBadges } from "./graph/badges";
18
+ import { clusterParams, clusterShift, clusterTargets, hopRings } from "../../lib/layoutEngine";
19
+ import { collapseCentroids, drillTargets } from "../../lib/graphLevels";
20
+ import { graphTheme, hueFor } from "../../lib/graphTheme";
21
+
22
+ // WIKI-GRAPH-OBSIDIAN-1: the graph surface. Two stacked canvases: the base (every edge and node, on the GPU through Pixi
23
+ // when the browser has WebGL, else our Canvas2D drawing) and the 2D overlay on top (labels, hover/focus highlight; it
24
+ // takes the pointer events). The d3-force layout runs in a worker (layoutClient.js), so the main thread only copies
25
+ // positions in and draws. One requestAnimationFrame loop redraws whichever layer is dirty. Pixi is imported lazily, so
26
+ // the Canvas2D fallback never downloads it. Bench contract (bench/bench.mjs): the `[data-graph-surface]` box gets
27
+ // `data-graph-info`, `data-graph-renderer`, `data-graph-ready` on its first drawn frame and `data-graph-settled-ms` when
28
+ // the worker reports the layout settled; the overlay canvas is the surface's last canvas.
29
+ const NONE = new Set();
30
+ const GOLDEN = Math.PI * (3 - Math.sqrt(5));
31
+ let warned = false; // the fallback warning is logged once per page load, not once per graph
32
+
33
+ /** Is WebGL available? Probed on a throwaway canvas: a canvas that once gave a webgl context can never give a 2D one. */
34
+ function hasWebGL() {
35
+ try {
36
+ const c = document.createElement("canvas");
37
+ return !!(c.getContext("webgl2") || c.getContext("webgl"));
38
+ } catch {
39
+ return false;
40
+ }
41
+ }
42
+
43
+ /** `graph` ({nodes, links} from buildGraph, the drawn subset); `layoutKey` names the layout: a new key re-lays-out (warm
44
+ * from cached positions by id), a new graph under the same key (colour by area/folder) only recolours. `hidden`: legend
45
+ * groups not drawn. `physics`: slider values, sent to the running layout live. `cluster` (WIKI-GRAPH-2D-CLUSTER-ONCE-1, owner
46
+ * 2026-10-22; was a hull per group with the layout frozen in place): turning it on runs ONE clustered layout (each group pulled
47
+ * to its own centre, layoutEngine clusterTargets/clusterParams, the same frame 3D uses) until it settles, then stops it. After
48
+ * that nothing moves and no layout message is posted (hover, focus, find, legend filter, resize, a same-topology refresh, a
49
+ * slider); a new node set that overlaps the drawn one only places its new docs beside their group (placeAppearing). Reheat
50
+ * re-runs the clustered layout once; turning it off moves nothing and Resume / Reheat offer the plain layout again. The rule is
51
+ * groupBubbles `layout2d`. No group outline is drawn in 2D. `forced`: `?renderer=` ("canvas" forces
52
+ * the fallback). `onOpen(node)` on a click, `onExpand(node)` on a double click (WIKI-GRAPH-LEVELS-1: the page opens a folder); `onLayoutState(state, ms)` on running|paused|settled. The ref exposes
53
+ * fit() (fit the whole graph until you move it), pause(), resume() and reheat(). `height`: pixels, or "fill" to take the
54
+ * container's whole height and follow it (WIKI-GRAPH-FULLVIEW-1). WIKI-GRAPH-FIND-FILTER-LIVE-1: `match` (ids, or null with
55
+ * no find term) lights those nodes and dims the rest through the focus treatment (relight), never a layout pass; `aim`
56
+ * ({id, n}: n re-aims) frames the camera on that node and its neighbours until you move it, without changing the focus.
57
+ * WIKI-GRAPH-FOCUS-HOPS-1 (2D, no position change): a focus lights its hop rings (layoutEngine hopRings, `hops`, default
58
+ * 2), the hub guard caps a big hub with a "+N" badge (`onBadge(node)` on a click; `open` is the page's Set of opened hub
59
+ * ids), a click on empty space calls `onBackground()`, and the camera frames the lit rings. Cluster by group places a folder
60
+ * by its `bubbleGroup` (the page's folderGroups). */
61
+ const GraphCanvas = forwardRef(function GraphCanvas(
62
+ { graph, layoutKey, hidden = NONE, focusId = null, sizeByDegree = true, physics = PHYSICS_DEFAULTS, cluster = false, forced = null, drill = null, match = null, aim = null, hops = 2, dim = DIM_DEFAULT, open = null, onOpen, onExpand, onBadge, onBackground, onLayoutState, height = 680 },
63
+ ref,
64
+ ) {
65
+ const fill = height === "fill";
66
+ const wrap = useRef(null);
67
+ const over = useRef(null);
68
+ const st = useRef(null);
69
+ const latest = useRef(null);
70
+ latest.current = { graph, layoutKey, hidden, focusId, sizeByDegree, physics, cluster, drill, match, aim, hops, dim, open, onOpen, onExpand, onBadge, onBackground, onLayoutState };
71
+
72
+ useImperativeHandle(ref, () => ({
73
+ fit() {
74
+ const s = st.current;
75
+ if (!s) return;
76
+ s.fitAll = true;
77
+ s.drill = null;
78
+ s.aim = -1;
79
+ s.userMoved = false;
80
+ placeView(s);
81
+ },
82
+ pause: () => st.current?.layout?.pause(),
83
+ resume: () => restartOr(st.current, "resume"),
84
+ reheat: () => restartOr(st.current, "reheat"),
85
+ }), []);
86
+
87
+ useEffect(() => {
88
+ const box = wrap.current, el = over.current;
89
+ let octx = null;
90
+ try { octx = el.getContext("2d"); } catch { octx = null; } // jsdom has no canvas: nothing is drawn, nothing breaks
91
+ const baseEl = document.createElement("canvas"); // made here, not by React: a failed WebGL try may need a fresh one
92
+ baseEl.style.cssText = "position:absolute;left:0;top:0;pointer-events:none";
93
+ box.insertBefore(baseEl, el);
94
+ const s = {
95
+ box, el, baseEl, octx, latest, w: box.clientWidth || 800, h: fill ? box.clientHeight || 680 : height, dpr: window.devicePixelRatio || 1,
96
+ view: { k: 1, x: 0, y: 0 }, base: null, sc: null, key: undefined, graph: null, hidden: null, sizeByDegree: null,
97
+ focusId: undefined, ids: [], index: new Map(), titles: [], labelW: null, off: null, adj: null, pairs: null,
98
+ visIdx: new Uint32Array(0), cache: new Map(), layout: null, physics: null, t0: 0, focus: -1, hover: -1,
99
+ focusNear: [], lit: [], litLinks: [], tree: null, baseDirty: false, overlayDirty: false, userMoved: false, fitAll: false, drill: null, drillKey: "", cpairs: new Uint32Array(0), cdraw: {}, follow: null,
100
+ drag: null, raf: 0, alive: true, ready: false, cluster: undefined, layoutTok: null, layoutState: null, resumeState: null,
101
+ clustered: false, matchIds: null, match: null, aim: -1, aimKey: "",
102
+ hops: 2, dimLevel: DIM_DEFAULT, open: null, rings: null, ringsKey: null, capped: new Map(), badges: [], litIdx: new Uint32Array(0),
103
+ };
104
+ st.current = s;
105
+
106
+ const attach = (base) => {
107
+ if (!s.alive) { base.destroy(); return; }
108
+ s.base = base;
109
+ box.dataset.graphRenderer = base.kind;
110
+ if (s.sc) { base.setData(s.sc); base.setView(s.sc); }
111
+ dimBase(s.baseEl, !!s.dim, s.dimLevel); // a fallback base is a fresh canvas: it takes the current dim
112
+ s.baseDirty = true;
113
+ };
114
+ const fallback = (why) => {
115
+ if (!warned) { warned = true; console.warn(FALLBACK_WARNING, why || ""); }
116
+ let target = baseEl;
117
+ if (why) { // the WebGL try may have claimed this canvas: draw on a fresh one
118
+ target = baseEl.cloneNode();
119
+ baseEl.replaceWith(target);
120
+ s.baseEl = target;
121
+ }
122
+ attach(createCanvasBase(target, { w: s.w, h: s.h, dpr: s.dpr, background: graphTheme().field }));
123
+ };
124
+ if (chooseRenderer({ webgl: hasWebGL(), forced }) === "webgl") {
125
+ import("./graph/pixiBase")
126
+ .then((m) => m.createPixiBase(baseEl, { w: s.w, h: s.h, dpr: s.dpr, background: graphTheme().field }))
127
+ .then(attach, (e) => { if (s.alive) fallback(e?.message || String(e)); });
128
+ } else {
129
+ fallback(null);
130
+ }
131
+
132
+ const resize = () => {
133
+ s.w = box.clientWidth || s.w;
134
+ if (fill) s.h = box.clientHeight || s.h;
135
+ s.dpr = window.devicePixelRatio || 1;
136
+ el.width = Math.round(s.w * s.dpr);
137
+ el.height = Math.round(s.h * s.dpr);
138
+ el.style.width = `${s.w}px`;
139
+ el.style.height = `${s.h}px`;
140
+ s.base?.resize(s.w, s.h, s.dpr);
141
+ if (!s.userMoved) placeView(s);
142
+ s.baseDirty = s.overlayDirty = true;
143
+ };
144
+ resize();
145
+ const ro = typeof ResizeObserver === "function" ? new ResizeObserver(resize) : null;
146
+ ro?.observe(box);
147
+ sync(s, latest.current);
148
+
149
+ const frame = () => {
150
+ s.raf = requestAnimationFrame(frame);
151
+ if (s.baseDirty && s.base && s.sc) {
152
+ s.baseDirty = false;
153
+ if (s.base.render() && !s.ready) { s.ready = true; box.dataset.graphReady = "1"; perfSpan("wiki-graph:first-frame", s.t0); }
154
+ }
155
+ if (s.overlayDirty && octx) {
156
+ s.overlayDirty = false;
157
+ drawOverlay(octx, s.sc, {
158
+ lit: s.lit, litLinks: s.litLinks, focus: s.focus, hover: s.hover, titles: s.titles, labelW: s.labelW, dpr: s.dpr, w: s.w, h: s.h, heads: s.hotHeads, litOnly: s.dim,
159
+ under: s.cpairs.length ? (c, sx, sy) => drawContainment(c, drawnMemo(s.cdraw, s.cpairs, [s.hover, s.focus]), sx, sy, (i) => s.sc.mask[i], s.dim ? s.dimLevel : false) : undefined,
160
+ });
161
+ s.badges = drawBadges(octx, badgeItems(s), s.dpr);
162
+ }
163
+ };
164
+ if (typeof requestAnimationFrame === "function") s.raf = requestAnimationFrame(frame);
165
+
166
+ const local = (e) => { const r = el.getBoundingClientRect(); return [e.clientX - r.left, e.clientY - r.top]; };
167
+ const moved = (v) => { s.userMoved = true; s.fitAll = false; s.drill = null; s.aim = -1; setView(s, v); };
168
+ const onWheel = (e) => {
169
+ e.preventDefault();
170
+ const [x, y] = local(e);
171
+ moved(zoomAt(s.view, x, y, Math.exp(-e.deltaY * 0.0015)));
172
+ };
173
+ const onDown = (e) => {
174
+ const [x, y] = local(e);
175
+ s.drag = { x, y, vx: s.view.x, vy: s.view.y, moved: false };
176
+ el.setPointerCapture?.(e.pointerId);
177
+ };
178
+ const onMove = (e) => {
179
+ const [x, y] = local(e);
180
+ if (s.drag) {
181
+ const dx = x - s.drag.x, dy = y - s.drag.y;
182
+ if (!s.drag.moved && Math.hypot(dx, dy) > 3) s.drag.moved = true;
183
+ if (s.drag.moved) moved({ ...s.view, x: s.drag.vx + dx, y: s.drag.vy + dy });
184
+ return;
185
+ }
186
+ const i = pickAt(s, x, y);
187
+ if (i !== s.hover) {
188
+ s.hover = i;
189
+ el.style.cursor = i >= 0 ? "pointer" : "grab";
190
+ el.title = i >= 0 ? s.titles[i] : "";
191
+ relight(s);
192
+ }
193
+ };
194
+ const onUp = (e) => {
195
+ const d = s.drag;
196
+ s.drag = null;
197
+ if (d && !d.moved) {
198
+ const [x, y] = local(e);
199
+ const b = badgeAt(s.badges, x, y);
200
+ const i = b >= 0 ? -1 : pickAt(s, x, y);
201
+ if (b >= 0) latest.current.onBadge?.(s.graph.nodes[b]); // a "+N" badge expands that hub alone
202
+ else if (i >= 0) latest.current.onOpen?.(s.graph.nodes[i]);
203
+ else latest.current.onBackground?.(); // empty space: the page clears the focus
204
+ }
205
+ };
206
+ const onDouble = (e) => {
207
+ const [x, y] = local(e);
208
+ const i = pickAt(s, x, y);
209
+ if (i >= 0) latest.current.onExpand?.(s.graph.nodes[i]);
210
+ else { s.fitAll = true; s.drill = null; s.aim = -1; s.userMoved = false; placeView(s); } // empty space: fit the whole graph, never a node's zoom
211
+ };
212
+ const onLeave = () => {
213
+ if (s.hover >= 0) { s.hover = -1; el.title = ""; relight(s); }
214
+ };
215
+ el.addEventListener("wheel", onWheel, { passive: false });
216
+ el.addEventListener("pointerdown", onDown);
217
+ el.addEventListener("pointermove", onMove);
218
+ el.addEventListener("pointerup", onUp);
219
+ el.addEventListener("pointerleave", onLeave);
220
+ el.addEventListener("dblclick", onDouble);
221
+ return () => {
222
+ s.alive = false;
223
+ if (typeof cancelAnimationFrame === "function") cancelAnimationFrame(s.raf);
224
+ ro?.disconnect();
225
+ s.layout?.stop();
226
+ s.base?.destroy();
227
+ s.baseEl.remove();
228
+ el.removeEventListener("wheel", onWheel);
229
+ el.removeEventListener("pointerdown", onDown);
230
+ el.removeEventListener("pointermove", onMove);
231
+ el.removeEventListener("pointerup", onUp);
232
+ el.removeEventListener("pointerleave", onLeave);
233
+ el.removeEventListener("dblclick", onDouble);
234
+ if (st.current === s) st.current = null;
235
+ };
236
+ }, [forced, height]);
237
+
238
+ // Data, legend, sizing and focus: `sync` diffs against what is drawn and does the least work (relayout, recolour,
239
+ // re-mask or relight).
240
+ useEffect(() => {
241
+ if (st.current) sync(st.current, latest.current);
242
+ }, [graph, layoutKey, hidden, sizeByDegree, focusId, cluster, drill, hops, dim, open]);
243
+
244
+ // A find term (WIKI-GRAPH-FIND-FILTER-LIVE-1): relight only. Nothing here touches the layout, the positions or the view.
245
+ useEffect(() => {
246
+ const s = st.current;
247
+ if (!s) return;
248
+ s.matchIds = match;
249
+ rematch(s);
250
+ relight(s);
251
+ }, [match]);
252
+
253
+ // Enter on a match: frame it (after sync, so a focus set in the same render is already in place).
254
+ useEffect(() => {
255
+ const s = st.current;
256
+ const key = aim ? `${aim.id}#${aim.n}` : "";
257
+ if (!s || key === s.aimKey) return;
258
+ s.aimKey = key;
259
+ s.aim = aim && s.index.has(aim.id) ? s.index.get(aim.id) : -1;
260
+ if (s.aim < 0) return;
261
+ s.userMoved = false;
262
+ s.fitAll = false;
263
+ s.drill = null;
264
+ placeView(s);
265
+ }, [aim]);
266
+
267
+ // Sliders: the running layout gets the new parameters (and reheats); nothing restarts. With cluster on the values are only
268
+ // kept (a params message reheats), and the next layout uses them.
269
+ useEffect(() => {
270
+ const s = st.current;
271
+ if (!s?.physics) return;
272
+ const p = latest.current.physics;
273
+ if (p.repel === s.physics.repel && p.link === s.physics.link && p.centre === s.physics.centre) return;
274
+ s.physics = { ...p, cluster: false };
275
+ if (s.layout && !s.cluster) s.layout.params(s.physics);
276
+ }, [physics.repel, physics.link, physics.centre]);
277
+
278
+ return (
279
+ <div
280
+ ref={wrap}
281
+ data-graph-surface=""
282
+ className={`relative w-full overflow-hidden ${fill ? "h-full" : "rounded-lg"}`}
283
+ style={{ height: fill ? undefined : height, background: graphTheme().field }}
284
+ >
285
+ <canvas
286
+ ref={over}
287
+ role="img"
288
+ aria-label={`Graph of ${graph.nodes.length} docs and ${graph.links.length} links`}
289
+ className="absolute left-0 top-0 block touch-none"
290
+ style={{ cursor: "grab" }}
291
+ />
292
+ </div>
293
+ );
294
+ });
295
+
296
+ export default GraphCanvas;
297
+
298
+ const endOf = (end) => (typeof end === "object" && end ? end.id : end);
299
+
300
+ /** Does `p.graph` hold the same nodes (ids, in order) and the same links (the index pairs the scene draws, in order) as the
301
+ * scene? Only then may a new graph object take the recolour path; any change to the node set OR the links rebuilds
302
+ * topology (WIKI-GRAPH-REFRESH-FU-1). */
303
+ function sameTopology(s, graph) {
304
+ const { nodes, links } = graph;
305
+ if (nodes.length !== s.sc.n || !nodes.every((n, i) => n.id === s.ids[i])) return false;
306
+ let k = 0;
307
+ for (const l of links) {
308
+ const a = s.index.get(endOf(l.source)), b = s.index.get(endOf(l.target));
309
+ if (a === undefined || b === undefined) continue; // build skips these too
310
+ if (s.pairs[k++] !== a || s.pairs[k++] !== b) return false;
311
+ }
312
+ return k === s.pairs.length;
313
+ }
314
+
315
+ /** Bring what is drawn in line with the props `p`. */
316
+ function sync(s, p) {
317
+ const key = p.layoutKey ?? p.graph;
318
+ const was = s.cluster;
319
+ s.cluster = !!p.cluster;
320
+ if (was !== s.cluster) s.clustered = false; // a toggle: the clustered layout has to run (again) before anything stays put
321
+ let built = false;
322
+ if (!s.sc || key !== s.key) {
323
+ build(s, p);
324
+ built = true;
325
+ } else if (p.graph !== s.graph) {
326
+ if (sameTopology(s, p.graph)) recolour(s, p);
327
+ else { build(s, p); built = true; }
328
+ }
329
+ if (!built && p.hidden !== s.hidden) { remask(s, p.hidden); s.base?.setVisible(s.sc); }
330
+ if (!built && p.sizeByDegree !== s.sizeByDegree) { resize(s, p); s.base?.setStyle(s.sc); }
331
+ const drillKey = p.drill ? `${p.drill.id}#${p.drill.n}` : ""; // a repeat drill into the same folder re-aims (the nonce differs)
332
+ const drilled = drillKey !== s.drillKey;
333
+ if (drilled) { s.drillKey = drillKey; s.drill = p.drill ? p.drill.id : null; }
334
+ const hops = p.hops ?? 2, open = p.open ?? null;
335
+ const hopsChanged = hops !== s.hops || open !== s.open, focusChanged = p.focusId !== s.focusId;
336
+ s.hops = hops;
337
+ s.open = open;
338
+ const dimLevel = p.dim ?? DIM_DEFAULT;
339
+ if (dimLevel !== s.dimLevel) { s.dimLevel = dimLevel; if (s.baseEl) dimBase(s.baseEl, !!s.dim, dimLevel); s.overlayDirty = true; } // one dim level, 2D and 3D
340
+ if (built || drilled || focusChanged) {
341
+ s.focusId = p.focusId;
342
+ s.focus = p.focusId != null && s.index.has(p.focusId) ? s.index.get(p.focusId) : -1;
343
+ s.userMoved = false;
344
+ s.fitAll = false;
345
+ }
346
+ // WIKI-GRAPH-LOCAL-HOPS-RECULL-1: a focus draws only its rings, so anything that moves the rings (a new node
347
+ // set, the focus, hops, an opened hub) re-culls here, before the relight and the framing read the mask.
348
+ const cull = localKeep(s.focus, ringsOf(s)), culled = cull !== s.cull;
349
+ if (culled) { s.cull = cull; remask(s, p.hidden); s.base?.setVisible(s.sc); }
350
+ if (built || drilled || focusChanged || hopsChanged || culled) { // 2D relights and reframes only: no node moves (owner ruling 2026-10-21)
351
+ relight(s);
352
+ placeView(s);
353
+ }
354
+ if (!built && was !== undefined && was !== s.cluster) { if (s.cluster) clusterOn(s); else clusterOff(s); }
355
+ s.baseDirty = s.overlayDirty = true;
356
+ }
357
+
358
+ /** A new node set: positions (cached by id, else seeded), the arrays both bases read, and a fresh layout, unless cluster is
359
+ * on, its clustered layout already settled here and some node already has a position: then nothing is laid out and only the
360
+ * new nodes are placed, beside their group (layout2d). */
361
+ function build(s, p) {
362
+ if (s.sc) for (let i = 0; i < s.sc.n; i++) s.cache.set(s.ids[i], [s.sc.pos[2 * i], s.sc.pos[2 * i + 1]]);
363
+ stopLayout(s);
364
+ const { nodes, links } = p.graph;
365
+ const n = nodes.length;
366
+ if (s.graph) { // a collapse: the new folder starts where its children were, not at a fresh seed
367
+ const old = s.graph.nodes;
368
+ for (const [id, c] of collapseCentroids(old, (i) => s.cache.get(old[i].id), nodes)) s.cache.set(id, c);
369
+ }
370
+ seedPositions(nodes, false); // cluster is not a layout input: every target is 0,0
371
+ const pos = new Float32Array(Math.max(2, 2 * n)), known = new Uint8Array(n);
372
+ s.ids = nodes.map((nd) => nd.id);
373
+ s.index = new Map(s.ids.map((id, i) => [id, i]));
374
+ let warm = false, cached = false; // cached: a position from this surface (its cache or a cached parent), not the host's fit
375
+ const served = serverFits(nodes, 2); // the host's settled layout of exactly this graph (WIKI-GRAPH-WARMSTART-3D-1)
376
+ const born = new Map(); // parent id -> children placed under it so far
377
+ for (let i = 0; i < n; i++) {
378
+ const nd = nodes[i];
379
+ let c = s.cache.get(nd.id);
380
+ if (!c && nd.parent && s.cache.has(nd.parent)) { // a folder was just opened: its children start where it was, fanned out a little
381
+ const [px, py] = s.cache.get(nd.parent), j = (born.get(nd.parent) || 0) + 1, r = 6 * Math.sqrt(j);
382
+ born.set(nd.parent, j);
383
+ c = [px + Math.cos(j * GOLDEN) * r, py + Math.sin(j * GOLDEN) * r];
384
+ }
385
+ if (c) cached = true;
386
+ if (!c && served) c = nd.warm2;
387
+ if (c) { pos[2 * i] = c[0]; pos[2 * i + 1] = c[1]; warm = true; known[i] = 1; } else { pos[2 * i] = nd.x; pos[2 * i + 1] = nd.y; }
388
+ }
389
+ // links as index pairs, direction flags and incidence lists (CSR, so a node's neighbours are a slice): the shared linkArrays
390
+ const { pairs: used, dirs, weights, off, adj } = linkArrays(links, s.index, n, dirOf);
391
+ const k = used.length;
392
+ s.pairs = used; s.dirs = dirs; s.weights = weights; s.off = off; s.adj = adj;
393
+ s.cpairs = containPairs(p.graph.contains, s.index); // hub -> contained node: layout springs and dashed lines (a giant hub: neither by default, hubLines), never links
394
+ const run = layout2d({ event: "build", cluster: !!p.cluster, clustered: s.clustered, warm: cached });
395
+ if (!run) placeAppearing(pos, 2, known, used, (i) => bubbleGroupOf(nodes[i]), { groupFirst: true }); // the clusters stay put: new docs join their group
396
+ else if (run === "cluster") s.clustered = false;
397
+ const degree = Uint32Array.from(nodes, (nd) => nd.degree || 0);
398
+ const order = Uint32Array.from({ length: n }, (_, i) => i).sort((a, b) => degree[b] - degree[a]);
399
+ s.titles = nodes.map((nd) => nd.title);
400
+ s.labelW = new Float32Array(n).fill(NaN);
401
+ s.sc = {
402
+ n, pos, colors: new Uint32Array(n), sizes: new Float32Array(n), mask: null, visPairs: new Uint32Array(k), visDirs: new Uint8Array(k / 2), visWeights: new Float32Array(k / 2), visBuckets: null, visCount: 0,
403
+ visibleN: n, view: s.view, big: n > LOD_N, edgeAlpha: lodFor(n, 1).edgeAlpha, degree, order,
404
+ };
405
+ s.key = p.layoutKey ?? p.graph;
406
+ s.hover = -1;
407
+ s.aim = -1;
408
+ rematch(s);
409
+ colour(s, p);
410
+ resize(s, p);
411
+ s.cull = null; // the old rings are indices into the old node set: sync re-culls once the focus is placed in this one
412
+ remask(s, p.hidden);
413
+ s.box.dataset.graphInfo = JSON.stringify({ nodes: n, links: k / 2, lod: s.sc.big });
414
+ s.base?.setData(s.sc);
415
+ s.base?.setView(s.sc);
416
+ s.physics = { ...p.physics, cluster: false };
417
+ s.t0 = performance.now();
418
+ delete s.box.dataset.graphSettledMs;
419
+ if (!n) return;
420
+ s.follow = followFor(null, s.key); // a new node set: its first layout gets a fresh follow budget; Resume / Reheat later keep it (2D has no other view state that moves nodes)
421
+ if (!run) { s.follow = followDone(s.key); report(s, "settled", null); return; }
422
+ start(s, warm, run);
423
+ }
424
+
425
+ /** The group a node is clustered with: the page's `bubbleGroup` (a folder's area, folderGroups), else its legend group; a
426
+ * folder without one belongs to none. Same rule as 3D. */
427
+ const bubbleGroupOf = (nd) => nd.bubbleGroup ?? (nd.kind === "folder" ? null : nd.group ?? null);
428
+
429
+ /** Start a layout over the current positions. `phase` "global": targets 0,0 and the user's physics; "cluster": each group's
430
+ * docs pulled to its own centre (clusterTargets, clusterParams' strong pull, a short warm run like 3D's), and the layout is
431
+ * STOPPED once it settles, so nothing posts or moves after it (WIKI-GRAPH-2D-CLUSTER-ONCE-1). Ticks and states from any
432
+ * layout but the latest are dropped, so a stopped one can never move a node. */
433
+ function start(s, warm, phase = "global") {
434
+ const sc = s.sc, tok = {}, clustering = phase === "cluster";
435
+ s.layoutTok = tok;
436
+ const targets = clustering ? clusterTargets(sc.pos, 2, (i) => bubbleGroupOf(s.graph.nodes[i])).targets : new Float32Array(2 * sc.n);
437
+ s.layout = startLayout({
438
+ xy: sc.pos, pairs: layoutPairs(s.pairs, s.cpairs), weights: s.weights, targets, physics: s.physics, big: sc.big, warm,
439
+ params: clustering ? clusterParams(s.physics) : null, alpha: clustering ? (warm ? 0.6 : 1) : null, maxTicks: clustering && warm ? 120 : null,
440
+ radii: layoutRadii(s.graph.nodes, (i) => sc.sizes[i] / 2),
441
+ onTick: (xy) => {
442
+ if (s.layoutTok !== tok || s.sc !== sc || xy.length !== sc.pos.length) return; // a frame from a layout we already replaced
443
+ sc.pos.set(xy);
444
+ s.base?.setPositions(sc);
445
+ s.tree = null;
446
+ if (follows(s.follow, "tick", performance.now(), s.userMoved)) placeView(s); // WIKI-GRAPH-DRIFT-LONG-EDGES-1: a bounded follow, never after the first settle
447
+ s.baseDirty = s.overlayDirty = true;
448
+ },
449
+ onState: (state, ms) => {
450
+ if (s.layoutTok !== tok || s.sc !== sc) return;
451
+ if (state === "settled") {
452
+ s.box.dataset.graphSettledMs = String(Math.round(performance.now() - s.t0));
453
+ perfSpan("wiki-graph:settle", s.t0);
454
+ if (follows(s.follow, "settled", performance.now(), s.userMoved)) placeView(s);
455
+ if (clustering) { stopLayout(s); s.clustered = true; } // the one clustered layout is done: from here on nothing moves
456
+ }
457
+ report(s, state, ms);
458
+ },
459
+ });
460
+ if (s.layout) return;
461
+ s.layoutTok = null;
462
+ s.follow = followDone(s.key);
463
+ if (clustering) { // no Worker: each group moves rigidly onto its centre, once
464
+ console.warn("wiki graph: no layout worker, the 2D clustered layout is a direct shift with no simulation (links and repulsion are not applied)");
465
+ sc.pos.set(clusterShift(sc.pos, 2, (i) => bubbleGroupOf(s.graph.nodes[i]), targets));
466
+ s.base?.setPositions(sc);
467
+ s.tree = null;
468
+ s.clustered = true;
469
+ placeView(s);
470
+ s.baseDirty = s.overlayDirty = true;
471
+ }
472
+ report(s, "settled", null); // no Worker: the seeded (or shifted) layout is the layout
473
+ }
474
+
475
+ function stopLayout(s) {
476
+ s.layout?.stop();
477
+ s.layout = null;
478
+ s.layoutTok = null;
479
+ }
480
+
481
+ function report(s, state, ms) {
482
+ s.layoutState = state;
483
+ s.latest.current.onLayoutState?.(state, ms);
484
+ }
485
+
486
+ /** Cluster turned on: stop the plain layout where it is and run the clustered one once (a new view: the camera follows it
487
+ * once). What Resume / Reheat offer once it is off again: a plain layout that had not settled can be resumed. */
488
+ function clusterOn(s) {
489
+ s.resumeState = s.layout && s.layoutState !== "settled" ? "paused" : "settled";
490
+ stopLayout(s);
491
+ if (!s.sc?.n || layout2d({ event: "toggle", cluster: true }) !== "cluster") return;
492
+ s.follow = followFor(null, `${String(s.key)}#cluster`);
493
+ start(s, true, "cluster");
494
+ }
495
+
496
+ /** Cluster turned off: nothing moves (a clustered layout still running stops where it is); the controls get back the state the
497
+ * plain layout had when cluster went on. */
498
+ function clusterOff(s) {
499
+ stopLayout(s);
500
+ report(s, s.resumeState || "settled", null);
501
+ }
502
+
503
+ /** Resume / Reheat. Cluster on: Resume continues a paused clustered layout, Reheat (the relayout button) re-runs it once from
504
+ * where the docs are. Cluster off: the plain layout, a fresh one when there is none. */
505
+ function restartOr(s, verb) {
506
+ if (!s || !s.sc?.n) return;
507
+ if (layout2d({ event: "relayout", cluster: s.cluster }) === "cluster") {
508
+ if (verb === "resume" && s.layout) { s.layout.resume(); return; }
509
+ stopLayout(s);
510
+ s.clustered = false;
511
+ start(s, true, "cluster");
512
+ return;
513
+ }
514
+ if (s.layout) s.layout[verb]();
515
+ else start(s, true);
516
+ }
517
+
518
+ /** Colours (and the legend groups they come from) for the current node set. */
519
+ function colour(s, p) {
520
+ const { nodes } = p.graph;
521
+ for (let i = 0; i < nodes.length; i++) s.sc.colors[i] = parseInt(hueFor(nodes[i].color || OTHER).slice(1), 16);
522
+ s.graph = p.graph;
523
+ // WIKI-GRAPH-3D-COLOR-SPHERES-1: doc / folder / hub looks (graphView nodeLooks); true when a hub or folder changed, which the base must rebuild for.
524
+ const looks = nodeLooks(nodes), old = s.sc.looks;
525
+ s.sc.looks = looks;
526
+ return !old || old.some((v, i) => v !== looks[i]);
527
+ }
528
+
529
+ /** Same nodes and links, new graph object (colour by area or folder, a folder's size): recolour, resize and re-mask, no relayout. */
530
+ function recolour(s, p) {
531
+ const relooked = colour(s, p);
532
+ refreshSameTopology(s, p.graph, dirOf);
533
+ s.layout?.weights(s.weights); // new link strengths for the next ticks; positions stay where they are (refreshSameTopology also sent any new containment pairs)
534
+ const { nodes } = p.graph, degree = Uint32Array.from(nodes, (nd) => nd.degree || 0);
535
+ s.sc.degree = degree;
536
+ s.sc.order = Uint32Array.from({ length: nodes.length }, (_, i) => i).sort((a, b) => degree[b] - degree[a]);
537
+ resize(s, p);
538
+ if (relooked) s.base?.setData(s.sc);
539
+ s.base?.setStyle(s.sc);
540
+ remask(s, p.hidden);
541
+ s.base?.setVisible(s.sc);
542
+ }
543
+
544
+ function resize(s, p) {
545
+ const { nodes } = p.graph;
546
+ for (let i = 0; i < nodes.length; i++) s.sc.sizes[i] = levelSize(nodes[i], p.sizeByDegree); // a folder by its docs (3D's rule)
547
+ s.sizeByDegree = p.sizeByDegree;
548
+ }
549
+
550
+ /** The legend mask, the drawn links and the visible index list (fit and hit testing use it). */
551
+ function remask(s, hidden) {
552
+ const sc = s.sc;
553
+ sc.mask = visibleMask(s.graph.nodes, hidden, s.cull); // s.cull: a focus's rings (localKeep), else null
554
+ sc.visCount = visibleLinks(s.pairs, sc.mask, sc.visPairs, s.dirs, sc.visDirs, s.weights, sc.visWeights);
555
+ sc.visBuckets = linkBuckets(sc.visWeights, sc.visCount); // the width passes every base draws (linkWeight.js)
556
+ const vis = [];
557
+ for (let i = 0; i < sc.n; i++) if (sc.mask[i]) vis.push(i);
558
+ s.visIdx = Uint32Array.from(vis);
559
+ sc.visibleN = vis.length;
560
+ s.hidden = hidden;
561
+ s.tree = null;
562
+ s.overlayDirty = s.baseDirty = true;
563
+ }
564
+
565
+ function setView(s, v) {
566
+ s.view = v;
567
+ if (s.sc) { s.sc.view = v; s.base?.setView(s.sc); }
568
+ s.baseDirty = s.overlayDirty = true;
569
+ }
570
+
571
+ /** Fit everything (also after fit() until you move), or the focused doc with its neighbours (centred on it when it has
572
+ * none), as today. Every frame goes through robustBox (WIKI-GRAPH-FIT-HIDDEN-1): a few far-out nodes never shrink or off-centre it. */
573
+ function placeView(s) {
574
+ if (!s.sc) return;
575
+ const { pos, mask } = s.sc;
576
+ const dt = !s.fitAll && s.drill ? drillTargets(s.graph.nodes, s.drill).filter((i) => mask[i]) : []; // a drilled folder and its drawn children
577
+ if (dt.length) { setView(s, robustBox(pos, dt, s.w, s.h, 60, s.sc.sizes)); return; }
578
+ const at = s.aim >= 0 ? s.aim : s.focus; // an Enter on a find match frames it, the focus otherwise
579
+ if (s.fitAll || at < 0 || !mask[at]) { setView(s, robustBox(pos, s.visIdx, s.w, s.h, 40, s.sc.sizes)); return; }
580
+ const fx = pos[2 * at], fy = pos[2 * at + 1];
581
+ const around = (at === s.focus ? s.focusNear : near(s, at)).filter((i) => mask[i]);
582
+ const fit = robustBox(pos, around, s.w, s.h, 60, s.sc.sizes);
583
+ setView(s, around.length > 1 ? (fit.k > 3 ? centreOn(fx, fy, s.w, s.h, 3) : fit) : centreOn(fx, fy, s.w, s.h, 2));
584
+ }
585
+
586
+ /** Node index `i` with its neighbours. */
587
+ function near(s, i) {
588
+ const out = [i];
589
+ for (let e = s.off[i]; e < s.off[i + 1]; e++) out.push(s.adj[e]);
590
+ return out;
591
+ }
592
+
593
+ /** The find matches as node indices of the drawn set (null with no term); ids not drawn are skipped. */
594
+ function rematch(s) {
595
+ s.match = s.matchIds ? s.matchIds.map((id) => s.index.get(id)).filter((i) => i !== undefined) : null;
596
+ }
597
+
598
+ /** The lit set and its links (graphView litOf: the focus's hop rings, hover with its neighbours, or the find matches) and the base dim. */
599
+ function relight(s) {
600
+ if (!s.sc) return;
601
+ const rings = ringsOf(s);
602
+ s.focusNear = rings ? [...rings.lit] : [];
603
+ s.capped = rings ? rings.capped : new Map();
604
+ const { lit, links, dim } = litOf({ focus: s.focus, hover: s.hover, near: (i) => near(s, i), match: s.match, focusLit: rings ? s.focusNear : null });
605
+ s.lit = lit;
606
+ s.litIdx = Uint32Array.from(lit.filter((i) => s.sc.mask[i]));
607
+ s.litLinks = links;
608
+ s.dim = dim;
609
+ s.hotHeads = s.focus >= 0 || s.hover >= 0 ? hotHeads(s.pairs, s.dirs, [s.focus, s.hover]) : null;
610
+ dimBase(s.baseEl, dim, s.dimLevel);
611
+ s.box.dataset.graphLit = s.match ? String(s.lit.length) : "";
612
+ s.overlayDirty = true;
613
+ }
614
+
615
+ /** The visible node under screen point (x, y), or -1: the nearest within its drawn radius (at least 6 screen px). */
616
+ function hitTest(s, x, y) {
617
+ const sc = s.sc;
618
+ if (!sc || !sc.n) return -1;
619
+ const { pos, view: v, sizes, big } = sc;
620
+ if (!s.tree) s.tree = quadtree().x((i) => pos[2 * i]).y((i) => pos[2 * i + 1]).addAll(s.visIdx);
621
+ const wx = (x - v.x) / v.k, wy = (y - v.y) / v.k;
622
+ const i = s.tree.find(wx, wy, Math.max(drawRadius(SIZE_MAX, v.k, big), 6 / v.k));
623
+ if (i === undefined) return -1;
624
+ const r = Math.max(drawRadius(sizes[i], v.k, big), 6 / v.k);
625
+ return (pos[2 * i] - wx) ** 2 + (pos[2 * i + 1] - wy) ** 2 <= r * r ? i : -1;
626
+ }
627
+
628
+ /** The node under (x, y), from the pickable set only (graphView pickable): nothing dimmed, the quadtree over every visible node;
629
+ * something dimmed, the nearest lit node within its drawn radius, so a dimmed node is never picked. */
630
+ function pickAt(s, x, y) {
631
+ if (!s.sc) return -1;
632
+ if (!s.dim) return hitTest(s, x, y);
633
+ const { pos, view: v, sizes, big } = s.sc, wx = (x - v.x) / v.k, wy = (y - v.y) / v.k;
634
+ let best = -1, bd = Infinity;
635
+ for (const i of pickable(s.visIdx, s.litIdx, true)) {
636
+ const r = Math.max(drawRadius(sizes[i], v.k, big), 6 / v.k), d = (pos[2 * i] - wx) ** 2 + (pos[2 * i + 1] - wy) ** 2;
637
+ if (d <= r * r && d < bd) { best = i; bd = d; }
638
+ }
639
+ return best;
640
+ }
641
+
642
+ /** The "+N" badges this frame: each capped hub of the focus ring that is lit and drawn, at its screen position. */
643
+ function badgeItems(s) {
644
+ const out = [];
645
+ if (!s.capped.size || !s.sc) return out;
646
+ const lit = new Set(s.lit), { pos, view: v, sizes, big } = s.sc;
647
+ for (const [i, n] of s.capped) {
648
+ if (lit.has(i) && s.sc.mask[i]) out.push({ i, n, x: pos[2 * i] * v.k + v.x, y: pos[2 * i + 1] * v.k + v.y, r: drawRadius(sizes[i], v.k, big) * v.k });
649
+ }
650
+ return out;
651
+ }
652
+
653
+ /** The focus's hop rings (layoutEngine hopRings, the hub guard capping big hubs), cached on focus, hops, opened hubs and
654
+ * node set. Null with no focus. `s.open` holds the page's badge-opened ids. */
655
+ function ringsOf(s) {
656
+ if (!s.sc || s.focus < 0) { s.rings = null; s.ringsKey = null; return null; }
657
+ const key = [s.focus, s.hops, s.open, s.sc];
658
+ if (s.rings && s.ringsKey && key.every((v, k) => v === s.ringsKey[k])) return s.rings;
659
+ const open = s.open ? new Set([...s.open].map((id) => s.index.get(id)).filter((i) => i !== undefined)) : null;
660
+ s.rings = hopRings({ get: (i) => near(s, i) }, s.focus, s.hops, { open });
661
+ s.ringsKey = key;
662
+ return s.rings;
663
+ }