@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,31 @@
1
+ // WIKI-GRAPH-3D-POLISH-1 round 3: "Turn slowly when idle", the 3D graph's one self-moving option. Owner rule "no idle auto-spin":
2
+ // the camera turns by itself ONLY when the reader switched this on, it is OFF by default, and nothing turns it on for them. Pure of
3
+ // React and three: the rule (`idleRate`), the azimuth step (`turnAzimuth`) and the stored preference (`readIdleTurn`).
4
+ import { readStore, writeStore } from "./graphShell";
5
+
6
+ export const IDLE_DELAY_MS = 10000; // no input for this long before the turn starts
7
+ export const IDLE_DEG_PER_S = 4; // slow: a full turn in 90 s
8
+ export const IDLE_TURN_KEY = "wiki.graph.idleTurn"; // per viewer, in localStorage: never in the URL or a saved view
9
+ export const IDLE_STEP_MAX_MS = 100; // one frame never turns more than this long (a hidden-tab gap or a paused loop must not jump)
10
+
11
+ /** Degrees per second the camera should turn right now, or 0. Turns only when the option is on (`on === true`), reduced motion is
12
+ * off, nothing tweens, nothing is dragged, the tab is visible and the last input is older than IDLE_DELAY_MS. */
13
+ export function idleRate({ on, reduced, now, lastInputAt, tweening, hidden, dragging } = {}) {
14
+ if (on !== true || reduced || tweening || hidden || dragging) return 0;
15
+ if (!Number.isFinite(now) || !Number.isFinite(lastInputAt)) return 0;
16
+ return now - lastInputAt >= IDLE_DELAY_MS ? IDLE_DEG_PER_S : 0;
17
+ }
18
+
19
+ /** The camera position `deg` further round the vertical axis through `target`: azimuth only, distance and elevation unchanged
20
+ * (the same az as graphOrient `orientationOf`: atan2(dx, dz)). Bad input comes back unchanged. */
21
+ export function turnAzimuth(target, cameraPos, deg) {
22
+ const ok = (v) => Array.isArray(v) && v.length === 3 && v.every(Number.isFinite);
23
+ if (!ok(target) || !ok(cameraPos) || !Number.isFinite(deg)) return cameraPos;
24
+ const a = (deg * Math.PI) / 180, c = Math.cos(a), sn = Math.sin(a);
25
+ const dx = cameraPos[0] - target[0], dz = cameraPos[2] - target[2];
26
+ return [target[0] + dx * c + dz * sn, cameraPos[1], target[2] + dz * c - dx * sn];
27
+ }
28
+
29
+ /** The stored choice, shape-checked: only the boolean `true` is on; anything else (missing, "true", 1, {}, null) is off. */
30
+ export const readIdleTurn = () => readStore(IDLE_TURN_KEY, false) === true;
31
+ export const writeIdleTurn = (on) => writeStore(IDLE_TURN_KEY, on === true);
@@ -0,0 +1,59 @@
1
+ // WIKI-GRAPH-3D-POLISH-1 round 4: which labels the 3D graph shows, with depth. Far-side labels painted over near dots were the
2
+ // main 3D clutter (3D-UX-1 REPORT.md "labels in depth"). Each frame the candidates are the always-shown docs (lit, focused,
3
+ // hovered, selected) plus the docs nearer than the pivot (the orbit target); they are packed front to back so near labels win a
4
+ // collision (the same box rule as the 2D overlay: createLabelPacker), and drawn back to front with an opacity that falls with
5
+ // depth. The screen font stays 12 px (overlay.js). Pure: no canvas, no measuring beyond the `width` it is handed.
6
+ import { createLabelPacker } from "./graphView";
7
+ import { HUB_DEGREE, LABEL_ALPHA_MIN, labelFade } from "./wikiGraph";
8
+
9
+ /** The lowest opacity a label gets: an always-shown label at the far edge of the scene. */
10
+ export const DEPTH_FLOOR = 0.35;
11
+ const NEAR_MIN = 1e-6; // a depth at or under this is behind the camera (graph3d.project)
12
+
13
+ /** A label's depth opacity: 1 at or nearer than `pivot`, falling linearly to DEPTH_FLOOR at `far`, never outside
14
+ * [DEPTH_FLOOR, 1]. No usable pivot or far edge (not finite, far not past the pivot) means 1. */
15
+ export function depthOpacity(depth, pivot, far) {
16
+ if (!Number.isFinite(pivot) || !(far > pivot) || !(depth > pivot)) return 1;
17
+ return Math.max(DEPTH_FLOOR, Math.min(1, 1 - ((1 - DEPTH_FLOOR) * (depth - pivot)) / (far - pivot)));
18
+ }
19
+
20
+ let pool = new Uint32Array(256); // the candidate buffer, reused every frame
21
+
22
+ /** The labels of one 3D frame, in DRAW order (back to front): [{i, x, y, a}] (centre x, baseline y, alpha = fade x depth opacity).
23
+ * `scr`: the projection, three floats per doc (x, y, depth); a doc with a NaN or non-positive depth has no label. `always`:
24
+ * doc indices that are shown whatever their depth (lit, selected; `focus` and `hover` are packed before the rest of them, so
25
+ * neither is dropped by a nearer neighbour). `order`: doc indices best-linked first (the hubs are its prefix); ordinary labels
26
+ * follow pickLabels' mode rules (`mode`, `degree`, `k`, `labelK`) but only for docs nearer than `pivot` (a non-finite pivot
27
+ * turns the depth rule off). Ordinary labels stop at min(cap, budget) in all (the nearest kept); always-shown ones are exempt
28
+ * from the depth filter, the fade and the budget, up to `cap`, yet still lose a collision to an always-shown label that is
29
+ * nearer (or is the focus or hover). `at(i)`: [sx, sy, rPx]; `width(i)`: label px width. Cost: one linear pass over `order`,
30
+ * a sort of the on-screen candidates (a reused typed-array buffer), a linear packing pass, a sort of the (at most cap) picks. */
31
+ export function pickDepthLabels({ scr, mask, always, order, pivot, far, focus = -1, hover = -1, mode, cap, budget = cap, at, width, w, h, degree = null, k = 1, labelK = 0 }) {
32
+ if (!scr || !scr.length) return [];
33
+ const depth = (i) => scr[3 * i + 2];
34
+ const usable = (i) => depth(i) > NEAR_MIN && (!mask || !!mask[i]); // false for NaN and for an index off the end
35
+ const onScreen = (i) => scr[3 * i] >= -50 && scr[3 * i] <= w + 50 && scr[3 * i + 1] >= -20 && scr[3 * i + 1] <= h + 20;
36
+ const { out, place } = createLabelPacker({ at, width, w, h, mask });
37
+ const seen = new Set(), lead = [];
38
+ for (const i of always || []) if (!seen.has(i) && usable(i)) { seen.add(i); lead.push(i); }
39
+ const pin = (i) => (i === focus || i === hover ? 1 : 0);
40
+ lead.sort((a, b) => pin(b) - pin(a) || depth(a) - depth(b));
41
+ for (const i of lead) { if (out.length >= cap) break; place(i, depthOpacity(depth(i), pivot, far)); }
42
+
43
+ const limit = Math.min(cap, budget), fade = labelFade(k, labelK), gate = Number.isFinite(pivot);
44
+ let m = 0;
45
+ for (const i of order || []) {
46
+ if (seen.has(i)) continue;
47
+ const hub = !!degree && degree[i] >= HUB_DEGREE;
48
+ if (mode !== "all" && !hub && (mode === "focus" || fade < LABEL_ALPHA_MIN)) break; // the hubs are the order's prefix
49
+ if (!usable(i) || (gate && depth(i) > pivot) || !onScreen(i)) continue;
50
+ if (m === pool.length) { const g = new Uint32Array(2 * m); g.set(pool); pool = g; }
51
+ pool[m++] = i;
52
+ }
53
+ const near = pool.subarray(0, m).sort((a, b) => depth(a) - depth(b));
54
+ for (let t = 0; t < m && out.length < limit; t++) {
55
+ const i = near[t];
56
+ place(i, mode === "all" || (!!degree && degree[i] >= HUB_DEGREE) ? 1 : fade);
57
+ }
58
+ return out.sort((a, b) => depth(b.i) - depth(a.i)); // packed in priority order (pinned first), drawn back to front
59
+ }
@@ -0,0 +1,69 @@
1
+ // WIKI-GRAPH-OBSIDIAN-1: the main thread's side of the layout worker (forceWorker.js). Builds every message the worker
2
+ // gets, so "a slider moves -> the worker gets `params`" is a unit test, with the Worker factory injectable. Without a
3
+ // Worker (jsdom, a browser that blocks them) there is no layout: the seeded positions are drawn as they are.
4
+ // WIKI-GRAPH-3D-1: `dims` 3 talks to forceWorker3d.js instead (positions `xyz`, three floats a node); the messages are shared.
5
+ // WIKI-GRAPH-CLUSTER-REGROUP-1: `init` may carry `pinned` (Uint8Array, nodes fixed where they start), and a caller may pass
6
+ // raw worker `params` (already d3 values, e.g. layoutEngine clusterParams), `alpha` and `maxTicks` in place of the defaults.
7
+ import { forceParams, maxTicksFor, progressMsFor } from "./graphView";
8
+
9
+ export const WARM_ALPHA = 0.3; // a re-layout that starts from cached positions needs far less heat than a cold start
10
+
11
+ export const messages = {
12
+ init: ({ xy, pairs, targets, physics, big, warm, dims = 2, radii = null, pinned = null, weights = null, params = null, alpha = null, maxTicks = null }) => ({
13
+ type: "init", [dims === 3 ? "xyz" : "xy"]: xy, links: pairs, targets, params: params || forceParams(physics), progressMs: progressMsFor(xy.length / dims),
14
+ maxTicks: maxTicks ?? maxTicksFor(big), alpha: alpha ?? (warm ? WARM_ALPHA : 1), big, ...(radii ? { radii } : {}), ...(pinned ? { pinned } : {}), ...(weights ? { weights } : {}),
15
+ }),
16
+ params: (physics, raw = null) => ({ type: "params", params: raw || forceParams(physics) }),
17
+ weights: (weights) => ({ type: "weights", weights }),
18
+ contain: (pairs, lead) => ({ type: "contain", pairs, lead }),
19
+ pause: () => ({ type: "pause" }),
20
+ resume: () => ({ type: "resume" }),
21
+ reheat: () => ({ type: "reheat" }),
22
+ pin: (i, x, y, z) => ({ type: "pin", i, x, y, z }),
23
+ unpin: (idx = null) => ({ type: "unpin", ...(idx ? { idx } : {}) }),
24
+ stop: () => ({ type: "stop" }),
25
+ };
26
+
27
+ export const defaultWorker = () => (typeof Worker === "function"
28
+ ? new Worker(new URL("./forceWorker.js", import.meta.url), { type: "module" })
29
+ : null);
30
+ export const defaultWorker3d = () => (typeof Worker === "function"
31
+ ? new Worker(new URL("./forceWorker3d.js", import.meta.url), { type: "module" })
32
+ : null);
33
+
34
+ /** Start a layout. `onTick(xy, alpha, ticks)` per position frame, `onState(state, ms)` on running|paused|settled.
35
+ * Returns null when no Worker can be made. `xy` is copied, so the caller keeps its own positions array. `dims` 3: `xy`
36
+ * and `targets` hold x, y, z per node and the 3D worker lays them out (onTick gets xyz). `radii` (graphView layoutRadii,
37
+ * copied): the drawn radius per node, which the worker keeps apart. `pinned` (Uint8Array, copied): nodes that never move.
38
+ * `weights` (Float32Array, copied): one aggregated-link weight per leading pair of `pairs` (linkWeight.js sets the link strength; the
39
+ * rest, containment springs, are 1); `layout.weights(w)` replaces them live without reheating,
40
+ * and `layout.contain(pairs, lead)` replaces the containment springs (every pair after the first `lead` links) the same way.
41
+ * `params` / `alpha` / `maxTicks`: raw overrides of forceParams(physics), the warm alpha and maxTicksFor(big). */
42
+ export function startLayout({ xy, pairs, targets, physics, big, warm, dims = 2, radii = null, pinned = null, weights = null, params = null, alpha = null, maxTicks = null, onTick, onState }, makeWorker = dims === 3 ? defaultWorker3d : defaultWorker) {
43
+ let worker = null;
44
+ try { worker = makeWorker(); } catch (e) { console.warn("wiki graph: the layout worker could not start, the graph stays at its seeded layout:", e?.message || e); }
45
+ if (!worker) return null;
46
+ worker.onmessage = (e) => {
47
+ const m = e.data;
48
+ if (m.type === "tick") onTick?.(m.xy ?? m.xyz, m.alpha, m.ticks);
49
+ else if (m.type === "state") onState?.(m.state, m.ms);
50
+ };
51
+ worker.onerror = (e) => { console.warn("wiki graph: the layout worker failed, the graph stays where it is:", e?.message || e); onState?.("settled", null); };
52
+ const send = (m) => worker.postMessage(m);
53
+ const own = new Float32Array(xy), ownTargets = new Float32Array(targets), ownPairs = new Uint32Array(pairs);
54
+ const ownRadii = radii ? new Float32Array(radii) : null, ownPinned = pinned ? new Uint8Array(pinned) : null;
55
+ const ownWeights = weights ? new Float32Array(weights) : null;
56
+ worker.postMessage(messages.init({ xy: own, pairs: ownPairs, targets: ownTargets, physics, big, warm, dims, radii: ownRadii, pinned: ownPinned, weights: ownWeights, params, alpha, maxTicks }),
57
+ [own.buffer, ownTargets.buffer, ownPairs.buffer, ...(ownRadii ? [ownRadii.buffer] : []), ...(ownPinned ? [ownPinned.buffer] : []), ...(ownWeights ? [ownWeights.buffer] : [])]);
58
+ return {
59
+ params: (p, raw = null) => send(messages.params(p, raw)),
60
+ weights: (w) => send(messages.weights(new Float32Array(w))),
61
+ contain: (pairs, lead) => send(messages.contain(new Uint32Array(pairs), lead)),
62
+ pause: () => send(messages.pause()),
63
+ resume: () => send(messages.resume()),
64
+ reheat: () => send(messages.reheat()),
65
+ pin: (i, x, y, z) => send(messages.pin(i, x, y, z)), // 3D only (forceWorker3d): hold doc i at (x, y, z)
66
+ unpin: (idx = null) => send(messages.unpin(idx ? Uint32Array.from(idx) : null)), // release those docs, or every pin
67
+ stop: () => { send(messages.stop()); worker.terminate?.(); },
68
+ };
69
+ }
@@ -0,0 +1,184 @@
1
+ // WIKI-GRAPH-CLUSTER-REGROUP-1 + WIKI-GRAPH-FOCUS-HOPS-1: the graph's layout engine, pure (no DOM, no worker), shared by
2
+ // both canvases. Owner ruling 2026-10-21: "3D regroups, 2D stays put", so only the 3D canvas feeds the position parts
3
+ // (clusterTargets, focusShells, glide) to the worker; the lit-set parts (hopRings, focusFindLit, folderGroups) serve both.
4
+ //
5
+ // The model: a *frame* is the set of positions every other state eases back to. There are two, `global` (the plain force
6
+ // layout) and `clustered` (the same graph re-laid out with every node pulled hard to its group's centre, centres spread
7
+ // apart). A focus re-lays out only its lit neighbourhood inside the active frame, with the dimmed rest pinned; clearing it
8
+ // eases the lit set back to the active frame. Every move goes through `glide`, never a snap.
9
+ import { forceParams } from "./graphView";
10
+ import { fibonacciSphere, seedRadius } from "./graph3d";
11
+
12
+ /** Hub guard: a hop ring stops expanding past a node that would add more than this many unseen nodes ("+N" badge). */
13
+ export const HUB_CAP = 150;
14
+
15
+ const nbrsOf = (adj) => (id) => (adj.get ? adj.get(id) : adj[id]) || [];
16
+
17
+ /** The focus's lit neighbourhood: a ring-by-ring BFS from `root` out to `hops` links. The root always expands. Any other
18
+ * node with more than `cap` unseen neighbours is not expanded; it goes into `capped` with that count (its "+N" badge).
19
+ * Ids in `open` (badge-clicked) always expand. `adj`: a Map id -> neighbour ids, or an array indexed by node.
20
+ * Returns {lit: Set, depth: Map id -> hop, capped: Map id -> hidden count}. An undefined/null root lights nothing. */
21
+ export function hopRings(adj, root, hops, { cap = HUB_CAP, open = null } = {}) {
22
+ const lit = new Set(), depth = new Map(), capped = new Map();
23
+ if (root === null || root === undefined) return { lit, depth, capped };
24
+ const nbrs = nbrsOf(adj);
25
+ lit.add(root); depth.set(root, 0);
26
+ let frontier = [root];
27
+ for (let h = 0; h < hops && frontier.length; h++) {
28
+ const next = [];
29
+ for (const id of frontier) {
30
+ const fresh = new Set();
31
+ for (const n of nbrs(id)) if (!lit.has(n)) fresh.add(n);
32
+ if (id !== root && fresh.size > cap && !(open && open.has(id))) { capped.set(id, fresh.size); continue; }
33
+ for (const n of fresh) if (!lit.has(n)) { lit.add(n); depth.set(n, h + 1); next.push(n); }
34
+ }
35
+ frontier = next;
36
+ }
37
+ return { lit, depth, capped };
38
+ }
39
+
40
+ /** Find + focus: with a find term (`matchIds` not null) a node is lit only if it is in both the hop set and the matches;
41
+ * the focus itself is always lit. Without a term, the hop set as it is. */
42
+ export function focusFindLit(hopLit, matchIds, focusId) {
43
+ if (!matchIds) return new Set(hopLit);
44
+ const out = new Set();
45
+ for (const id of hopLit) if (matchIds.has(id)) out.add(id);
46
+ if (focusId !== null && focusId !== undefined) out.add(focusId);
47
+ return out;
48
+ }
49
+
50
+ /** A level-view folder's area for the cluster bubbles: the most common `group` among the docs under its `path` (the
51
+ * folder itself and every subfolder), ties broken by group name; a folder with no docs gets null. `levelNodes`: the
52
+ * level view's nodes (only `kind: "folder"` ones are looked at); `flatGraph`: the doc graph ({nodes: [{folder, group}]}).
53
+ * Returns a Map folder id -> group (or null). Bubble membership only: legend rows and colours are unchanged. */
54
+ export function folderGroups(levelNodes, flatGraph) {
55
+ const tally = new Map(); // folder path -> Map group -> count
56
+ const bump = (path, g) => {
57
+ let t = tally.get(path);
58
+ if (!t) tally.set(path, (t = new Map()));
59
+ t.set(g, (t.get(g) || 0) + 1);
60
+ };
61
+ for (const d of flatGraph?.nodes || []) {
62
+ if (d.kind === "folder" || d.group === null || d.group === undefined) continue;
63
+ const segs = String(d.folder || "").split("/").filter(Boolean);
64
+ bump("", d.group);
65
+ for (let k = 1; k <= segs.length; k++) bump(segs.slice(0, k).join("/"), d.group);
66
+ }
67
+ const out = new Map();
68
+ for (const f of levelNodes || []) {
69
+ if (f.kind !== "folder") continue;
70
+ const path = String(f.path || "").split("/").filter(Boolean).join("/");
71
+ let best = null, bestK = 0;
72
+ for (const [g, k] of tally.get(path) || []) if (k > bestK || (k === bestK && String(g) < String(best))) { best = g; bestK = k; }
73
+ out.set(f.id, best);
74
+ }
75
+ return out;
76
+ }
77
+
78
+ /** The clustered frame's forceX/Y/Z strength (the default slider gives 0.05): strong, so each group packs round its own
79
+ * centre while links still pull across. */
80
+ export const CLUSTER_CENTRE = 0.25;
81
+ /** forceParams with the strong cluster pull: the worker params of the clustered frame. */
82
+ export const clusterParams = (physics) => ({ ...forceParams(physics), centre: CLUSTER_CENTRE });
83
+
84
+ /** The clustered frame's per-node targets (Float32Array, `dims` floats a node). Group centres sit on a Fibonacci sphere
85
+ * (3D) or a circle (2D) round the current centroid, largest group first (ties by name), far enough apart that the
86
+ * largest group's ball (radius rMax = 14 * cbrt(size)) clears its neighbour: R = max(seedRadius(n), 1.8 * rMax / spacing),
87
+ * spacing sqrt(4π/G) on the sphere, 2π/G on the circle. A node whose `groupOf(i)` is null targets the centroid.
88
+ * Returns {targets, centres: Map group -> [x, y(, z)], R}. */
89
+ export function clusterTargets(pos, dims, groupOf) {
90
+ const n = Math.floor(pos.length / dims), c = new Array(dims).fill(0);
91
+ for (let i = 0; i < n; i++) for (let k = 0; k < dims; k++) c[k] += pos[dims * i + k] / Math.max(1, n);
92
+ const size = new Map();
93
+ for (let i = 0; i < n; i++) {
94
+ const g = groupOf(i);
95
+ if (g !== null && g !== undefined) size.set(g, (size.get(g) || 0) + 1);
96
+ }
97
+ const groups = [...size.entries()].sort((a, b) => b[1] - a[1] || (String(a[0]) < String(b[0]) ? -1 : 1));
98
+ const G = groups.length, rMax = G ? 14 * Math.cbrt(groups[0][1]) : 0;
99
+ const spacing = dims === 3 ? Math.sqrt((4 * Math.PI) / Math.max(1, G)) : (2 * Math.PI) / Math.max(1, G);
100
+ const R = G > 1 ? Math.max(seedRadius(n), (1.8 * rMax) / spacing) : 0;
101
+ const centres = new Map();
102
+ groups.forEach(([g], j) => {
103
+ if (dims === 3) {
104
+ const [x, y, z] = fibonacciSphere(j, G);
105
+ centres.set(g, [c[0] + R * x, c[1] + R * y, c[2] + R * z]);
106
+ } else {
107
+ const a = (2 * Math.PI * j) / Math.max(1, G);
108
+ centres.set(g, [c[0] + R * Math.cos(a), c[1] + R * Math.sin(a)]);
109
+ }
110
+ });
111
+ const targets = new Float32Array(n * dims);
112
+ for (let i = 0; i < n; i++) {
113
+ const t = centres.get(groupOf(i)) || c;
114
+ for (let k = 0; k < dims; k++) targets[dims * i + k] = t[k];
115
+ }
116
+ return { targets, centres, R };
117
+ }
118
+
119
+ /** The no-Worker stand-in for the clustered frame: each group moves rigidly so its centroid lands on its target (every
120
+ * member shares one target, clusterTargets). Nodes with no group stay where they are. Returns a new Float32Array. */
121
+ export function clusterShift(pos, dims, groupOf, targets) {
122
+ const n = Math.floor(pos.length / dims), out = Float32Array.from(pos), sum = new Map();
123
+ for (let i = 0; i < n; i++) {
124
+ const g = groupOf(i);
125
+ if (g === null || g === undefined) continue;
126
+ let a = sum.get(g);
127
+ if (!a) sum.set(g, (a = { i, c: new Float64Array(dims), k: 0 }));
128
+ for (let d = 0; d < dims; d++) a.c[d] += pos[dims * i + d];
129
+ a.k++;
130
+ }
131
+ for (let i = 0; i < n; i++) {
132
+ const a = sum.get(groupOf(i));
133
+ if (!a) continue;
134
+ for (let d = 0; d < dims; d++) out[dims * i + d] += targets[dims * a.i + d] - a.c[d] / a.k;
135
+ }
136
+ return out;
137
+ }
138
+
139
+ /** The focus re-layout's targets (Float32Array, `dims` floats a node): the focus stays where it is; a lit node at hop d
140
+ * targets focus + unit(node - focus) * d * shellR (a node on top of the focus takes a Fibonacci direction); every other
141
+ * node targets its own position (the worker pins it anyway). `depth`: Map node index -> hop. shellR: 0.9 * link distance. */
142
+ export function focusShells(pos, dims, depth, focusIdx, shellR) {
143
+ const t = Float32Array.from(pos), n = Math.floor(pos.length / dims), f = [];
144
+ for (let k = 0; k < dims; k++) f.push(pos[dims * focusIdx + k]);
145
+ let j = 0;
146
+ for (const [i, d] of depth) {
147
+ if (i === focusIdx || i < 0 || i >= n || !d) continue;
148
+ const v = [];
149
+ let len = 0;
150
+ for (let k = 0; k < dims; k++) { v.push(pos[dims * i + k] - f[k]); len += v[k] * v[k]; }
151
+ len = Math.sqrt(len);
152
+ if (len < 1e-6) {
153
+ const u = fibonacciSphere(j++, Math.max(2, depth.size));
154
+ for (let k = 0; k < dims; k++) v[k] = u[k];
155
+ len = Math.hypot(...v) || 1;
156
+ }
157
+ for (let k = 0; k < dims; k++) t[dims * i + k] = f[k] + (v[k] / len) * d * shellR;
158
+ }
159
+ return t;
160
+ }
161
+
162
+ /** The time constant (ms) of every eased move: about 600 ms to look settled (5% left after 3 tau). */
163
+ export const GLIDE_TAU = 180;
164
+ /** Below this distance (world units) a glide snaps to its goal, so it ends exactly there. */
165
+ export const GLIDE_SNAP = 0.05;
166
+
167
+ /** One eased step of `cur` toward `goal` (same length, in place): an exponential approach over `dt` ms. Values within
168
+ * GLIDE_SNAP of their goal snap onto it. `idx` (optional): only those node indices (with `dims`) move. Returns the
169
+ * largest distance left on any coordinate; 0 means `cur` equals `goal` exactly. `tau` 0 (reduced motion) jumps: every value
170
+ * lands on its goal in this one step. */
171
+ export function glide(cur, goal, dt, tau = GLIDE_TAU, idx = null, dims = 1) {
172
+ const jump = !(tau > 0);
173
+ const k = jump ? 1 : 1 - Math.exp(-Math.max(0, dt) / tau);
174
+ let left = 0;
175
+ const step = (j) => {
176
+ const d = goal[j] - cur[j];
177
+ if (jump || Math.abs(d) <= GLIDE_SNAP) { cur[j] = goal[j]; return; }
178
+ cur[j] += d * k;
179
+ left = Math.max(left, Math.abs(goal[j] - cur[j]));
180
+ };
181
+ if (idx) { for (const i of idx) for (let c = 0; c < dims; c++) step(dims * i + c); }
182
+ else for (let j = 0; j < cur.length; j++) step(j);
183
+ return left;
184
+ }
@@ -0,0 +1,67 @@
1
+ // WIKI-GRAPH-LEVEL-WEIGHTS-FU-1: the ONE mapping from an aggregated link's `weight` (graphLevels: how many doc links a folder
2
+ // link stands for) to what is drawn and laid out, shared by the layout workers, the Canvas2D, Pixi and three.js bases.
3
+ //
4
+ // One level t in [0, 1] on a log scale (weight 1 -> 0, WEIGHT_SAT or more -> 1), then two bounded outputs:
5
+ // width WIDTH_MIN..WIDTH_MAX screen px (1 px at weight 1, 2.66 at 10, 4 at 64+)
6
+ // strength STRENGTH_BASE..STRENGTH_MAX (d3 link strength: 0.15 at weight 1, 0.32 at 10, 0.45 at 64+)
7
+ // Flat (doc-level, weight 1) links map to exactly today's 1 px and 0.15. A missing, non-finite or < 1 weight counts as 1.
8
+ //
9
+ // Drawn widths are whole px buckets (widthBucket: 1..LINK_BUCKETS), so a renderer draws a few passes, not one width a link.
10
+ export const WEIGHT_SAT = 64;
11
+ export const WIDTH_MIN = 1, WIDTH_MAX = 4;
12
+ export const STRENGTH_BASE = 0.15, STRENGTH_MAX = 0.45;
13
+ export const LINK_BUCKETS = WIDTH_MAX;
14
+
15
+ const LOG_SAT = Math.log2(WEIGHT_SAT);
16
+ const level = (w) => { const x = Number(w); return x > 1 ? Math.min(1, Math.log2(x) / LOG_SAT) : 0; };
17
+
18
+ export const linkWidth = (w) => WIDTH_MIN + (WIDTH_MAX - WIDTH_MIN) * level(w);
19
+ export const linkStrength = (w) => STRENGTH_BASE + (STRENGTH_MAX - STRENGTH_BASE) * level(w);
20
+ export const widthBucket = (w) => Math.round(linkWidth(w));
21
+
22
+ /** The drawn links (`count` of them, `weights` one per drawn link, null = all 1) grouped by width bucket: `idx` holds link
23
+ * numbers, bucket b (1..LINK_BUCKETS) is `idx[start[b] .. start[b + 1])`, in link order within a bucket. */
24
+ export function linkBuckets(weights, count) {
25
+ const start = new Uint32Array(LINK_BUCKETS + 2), idx = new Uint32Array(count);
26
+ const bucket = new Uint8Array(count);
27
+ for (let e = 0; e < count; e++) { const b = weights ? widthBucket(weights[e]) : 1; bucket[e] = b; start[b + 1]++; }
28
+ start[1] = 0;
29
+ for (let b = 1; b <= LINK_BUCKETS; b++) start[b + 1] += start[b];
30
+ const fill = start.slice();
31
+ for (let e = 0; e < count; e++) idx[fill[bucket[e]]++] = e;
32
+ return { idx, start };
33
+ }
34
+
35
+ /** How many quads the links of buckets 2.. of `buckets` need (one a link). */
36
+ export const thickCount = (buckets) => buckets.start[LINK_BUCKETS + 1] - buckets.start[2];
37
+
38
+ /** Quad and index buffers with room for `need` quads (12 floats and 6 identity indices each). `cur` ({ quads, index, cap }) is kept
39
+ * while it fits, else the capacity grows to `need` or twice itself, so a renderer draws every thick link and never drops the overflow. */
40
+ export function thickBuffers(cur, need) {
41
+ if (cur && cur.cap >= need) return cur;
42
+ const cap = Math.max(1, need, cur ? 2 * cur.cap : 0);
43
+ return { cap, quads: new Float32Array(12 * cap), index: Uint32Array.from({ length: 6 * cap }, (_, i) => i) };
44
+ }
45
+
46
+ /** Triangle-list quads (12 floats = 2 triangles a link, world units) for the links of buckets 2.. at zoom `k`, so each is
47
+ * `bucket` screen px wide: the Pixi base's thick links (its line-list mesh draws hairlines only). `pos`: x, y per node,
48
+ * `pairs`: the drawn index pairs, `buckets`: linkBuckets of them. At most `cap` quads (a caller sizes `out` with thickBuffers
49
+ * so the cap is never reached); links past it are not drawn, and that is one console.warn naming how many. Returns how many were written. */
50
+ export function fillThickLinks(pos, pairs, buckets, k, out, cap) {
51
+ let q = 0, lost = 0;
52
+ for (let b = 2; b <= LINK_BUCKETS; b++) {
53
+ const half = b / (2 * k);
54
+ for (let j = buckets.start[b]; j < buckets.start[b + 1]; j++) {
55
+ const e = 2 * buckets.idx[j], a = pairs[e], c = pairs[e + 1];
56
+ const ax = pos[2 * a], ay = pos[2 * a + 1], bx = pos[2 * c], by = pos[2 * c + 1];
57
+ const dx = bx - ax, dy = by - ay, len = Math.hypot(dx, dy);
58
+ if (!(len > 0)) continue;
59
+ if (q >= cap) { lost++; continue; }
60
+ const nx = (-dy / len) * half, ny = (dx / len) * half, o = 12 * q++;
61
+ out[o] = ax + nx; out[o + 1] = ay + ny; out[o + 2] = ax - nx; out[o + 3] = ay - ny; out[o + 4] = bx + nx; out[o + 5] = by + ny;
62
+ out[o + 6] = ax - nx; out[o + 7] = ay - ny; out[o + 8] = bx - nx; out[o + 9] = by - ny; out[o + 10] = bx + nx; out[o + 11] = by + ny;
63
+ }
64
+ }
65
+ if (lost) console.warn(`wiki graph: ${lost} weighted link${lost === 1 ? "" : "s"} not drawn, the thick-link buffer holds ${cap}`);
66
+ return q;
67
+ }
@@ -0,0 +1,10 @@
1
+ // WIKI-GRAPH-FIT-HIDDEN-1 r4: the orphan pull both layout workers (forceWorker.js 2D, forceWorker3d.js 3D) share. One definition, no copy.
2
+ // WIKI-GRAPH-FIT-HIDDEN-1 r3: an unlinked node feels only the repulsion of the whole graph (legend-hidden areas included) and the
3
+ // weak centre pull, so it settled in a ring outside everything and a fit of the visible docs shrank their linked core to a dot.
4
+ // It is pulled to its centre this many times harder, so it settles inside the mass. Linked nodes keep the centre pull they had.
5
+ export const ORPHAN_PULL = 12;
6
+ /** Mark the nodes no link or containment spring touches (`free`), the ones the centre pulls ORPHAN_PULL times harder. */
7
+ export function markFree(nodes, links) {
8
+ for (const nd of nodes) nd.free = true;
9
+ for (const l of links) { nodes[l.source.index ?? l.source].free = false; nodes[l.target.index ?? l.target].free = false; }
10
+ }
@@ -0,0 +1,14 @@
1
+ // Timing marks for the graph page (WIKI-GRAPH-LOAD-1): `performance.measure` entries named `wiki-graph:<phase>`, read in the
2
+ // console with `performance.getEntriesByType("measure")`. Nothing is sent anywhere. A runtime without the User Timing
3
+ // API (old jsdom) records nothing.
4
+
5
+ export const perfNow = () => globalThis.performance?.now?.() ?? Date.now();
6
+
7
+ /** Record `name` as a measure from `start` (a perfNow() reading) to now. */
8
+ export function perfSpan(name, start) {
9
+ try {
10
+ globalThis.performance?.measure?.(name, { start, end: perfNow() });
11
+ } catch {
12
+ // a runtime whose measure() takes no options object: no entry, no error
13
+ }
14
+ }
@@ -0,0 +1,36 @@
1
+ // WIKI-GRAPH-3D-POLISH-1 round 2: the one source of truth for `prefers-reduced-motion: reduce`. Pure of React and three, so the
2
+ // 3D canvas (and a later 2D fix, or the idle-turn option) reads the same answer. A missing `matchMedia` (jsdom, old browsers)
3
+ // or a throwing one means motion stays ON: this never throws.
4
+
5
+ export const REDUCED_MOTION_QUERY = "(prefers-reduced-motion: reduce)";
6
+
7
+ function queryOf(win) {
8
+ try {
9
+ const w = win === undefined ? (typeof window === "undefined" ? undefined : window) : win;
10
+ return typeof w?.matchMedia === "function" ? w.matchMedia(REDUCED_MOTION_QUERY) || null : null;
11
+ } catch {
12
+ return null;
13
+ }
14
+ }
15
+
16
+ /** True when the user asked the system for reduced motion. False when it cannot be read. */
17
+ export function prefersReducedMotion(win) {
18
+ return queryOf(win)?.matches === true;
19
+ }
20
+
21
+ /** Calls `cb(reduced)` whenever the setting changes (not once at the start). Returns the unsubscribe function, a no-op when
22
+ * there is nothing to listen to. */
23
+ export function watchReducedMotion(cb, win) {
24
+ const mql = queryOf(win);
25
+ if (!mql) return () => {};
26
+ const on = (e) => cb(e && typeof e.matches === "boolean" ? e.matches : mql.matches === true);
27
+ if (typeof mql.addEventListener === "function") {
28
+ mql.addEventListener("change", on);
29
+ return () => mql.removeEventListener?.("change", on);
30
+ }
31
+ if (typeof mql.addListener === "function") { // Safari before 14
32
+ mql.addListener(on);
33
+ return () => mql.removeListener?.(on);
34
+ }
35
+ return () => {};
36
+ }
@@ -0,0 +1,62 @@
1
+ // WIKI-GRAPH-FIT-HIDDEN-1: an outlier-proof fit, the one helper per dimension every automatic framing path uses (2D fit(), focus,
2
+ // drill, find aim and the fit-until-you-move re-fits; the same in 3D). A few far-out nodes no longer shrink the view to a tiny
3
+ // cluster or pull its centre off the mass: per axis the 2nd-98th percentile of the node centres is taken (one sort per axis), widened
4
+ // by a fence of FIT_FENCE x that span, and a node outside it on any axis is left out (at least one node per side is cut from
5
+ // FIT_TRIM_MIN nodes up, so the fence alone decides whether that node is far enough out to drop). The raw fit (fitBox / fitSphere) then runs over
6
+ // the nodes kept, so radii are added after trimming and a big folder that is kept is shown whole.
7
+ import { fitSphere } from "./graph3d";
8
+ import { fitBox } from "./wikiGraph";
9
+
10
+ /** Under this many (finite) nodes nothing is trimmed: a small set is framed whole, so a 3-node fit still shows all 3. From 20 up
11
+ * the percentile cut is at least one node per side (2% of n is under one node until n = 51), so one far-out neighbour in a focus
12
+ * or drill set of 20-50 is still left out; under 20 the one-node cut would decide too much of the view. */
13
+ export const FIT_TRIM_MIN = 20;
14
+ /** The tail cut per side and axis: 2%, so up to 2% of the visible set on each side can sit anywhere without moving the view. */
15
+ export const FIT_TRIM_Q = 0.02;
16
+ /** The fence past the 2nd-98th percentile, as a share of that span. A clean cluster's outer 2% stays in (a force-layout disc reaches
17
+ * about 1.05 x its p98, WIKI-GRAPH-FIT-HIDDEN-1 handoff-r1), and a clean 20-node set loses its edge node on a side only when that node
18
+ * is a quarter of the span past the next one (about 0.75^19 = 0.4% per side and axis; at 0.1 it was 13%). The cost: a halo up to a
19
+ * quarter span out still widens the view, at most 1.5 x for a round cluster. The owner's far-out nodes sit many spans out. */
20
+ export const FIT_FENCE = 0.25;
21
+
22
+ let lastWarn = "";
23
+
24
+ /** The indices of `idx` to frame: non-finite positions (any of `stride` coords per node in `xs`) or sizes are skipped with one
25
+ * console.warn naming the count (repeated only when the count changes, so the re-fits on every layout tick do not flood the
26
+ * console); then, from FIT_TRIM_MIN nodes up, the nodes outside the fenced 2nd-98th percentile on any axis are left out. */
27
+ export function robustIdx(xs, stride, idx, sizes = null, what = "fit") {
28
+ const ok = [];
29
+ let bad = 0;
30
+ for (const i of idx) {
31
+ let fine = !sizes || Number.isFinite(sizes[i]);
32
+ for (let a = 0; fine && a < stride; a++) fine = Number.isFinite(xs[stride * i + a]);
33
+ if (fine) ok.push(i); else bad++;
34
+ }
35
+ const msg = bad ? `${what}: skipped ${bad} node(s) with a non-finite position or size` : "";
36
+ if (msg && msg !== lastWarn) console.warn(msg);
37
+ lastWarn = msg;
38
+ const n = ok.length;
39
+ if (n < FIT_TRIM_MIN) return ok;
40
+ const lo = new Float64Array(stride), hi = new Float64Array(stride), v = new Float64Array(n);
41
+ const a0 = Math.max(1, Math.floor(FIT_TRIM_Q * (n - 1))), a1 = Math.min(n - 2, Math.ceil((1 - FIT_TRIM_Q) * (n - 1)));
42
+ for (let a = 0; a < stride; a++) {
43
+ for (let j = 0; j < n; j++) v[j] = xs[stride * ok[j] + a];
44
+ v.sort();
45
+ const f = (v[a1] - v[a0]) * FIT_FENCE;
46
+ lo[a] = v[a0] - f; hi[a] = v[a1] + f;
47
+ }
48
+ return ok.filter((i) => {
49
+ for (let a = 0; a < stride; a++) { const x = xs[stride * i + a]; if (x < lo[a] || x > hi[a]) return false; }
50
+ return true;
51
+ });
52
+ }
53
+
54
+ /** fitBox over the nodes robustIdx keeps: `pos` x, y per node index, `sizes` diameters per node index (or null). */
55
+ export function robustBox(pos, idx, w, h, pad = 40, sizes = null) {
56
+ return fitBox(pos, robustIdx(pos, 2, idx, sizes, "robustBox"), w, h, pad, sizes);
57
+ }
58
+
59
+ /** fitSphere over the nodes robustIdx keeps: `xyz` x, y, z per node index, `radius` per node index (or null). Null when none is kept. */
60
+ export function robustSphere(xyz, idx, radius = null) {
61
+ return fitSphere(xyz, robustIdx(xyz, 3, idx, radius, "robustSphere"), radius);
62
+ }