@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,1366 @@
1
+ import React, { forwardRef, useEffect, useImperativeHandle, useRef } from "react";
2
+ import { CARD_KEYS_HELP } from "../../lib/cardVisibility";
3
+ import { DIM_DEFAULT, PHYSICS_DEFAULTS, dimBase, forceParams, layoutRadii, litOf, localKeep, nodeLooks, pickable, visibleLinks, visibleMask } from "../../lib/graphView";
4
+ import { startLayout } from "../../lib/layoutClient";
5
+ import { linkBuckets } from "../../lib/linkWeight";
6
+ import { perfSpan } from "../../lib/perf";
7
+ import { dirOf, hotHeads } from "../../lib/graphArrows";
8
+ import { LOD_N, OTHER, lodFor } from "../../lib/wikiGraph";
9
+ import { cameraOf, dragStarted, holdPins, pinArmed, pinOffset, pinTarget } from "../../lib/graphPin";
10
+ import { FOV, fitDistance, fitSphere, linkArrays, nodeRadius, pickProjected, pickStack, project, refreshSameTopology, sameTopology, seed3d } from "../../lib/graph3d";
11
+ import { robustSphere } from "../../lib/robustFit";
12
+ import { depthCycle, dollyBy, flashRing, orbitAbout, screenList } from "../../lib/canvasPort";
13
+ import { lassoSample } from "../../lib/graphLasso";
14
+ import { arcPoint, cameraFor, gizmoAxes, orientationOf, snapOrient } from "../../lib/graphOrient";
15
+ import { buttonActions, MOUSE_HELP } from "../../lib/graphPress";
16
+ import { BUBBLE_HZ, groupBubbles, outlinesShown, placesStatically } from "../../lib/groupBubbles";
17
+ import { prefersReducedMotion, watchReducedMotion } from "../../lib/reducedMotion";
18
+ import { IDLE_STEP_MAX_MS, idleRate, turnAzimuth } from "../../lib/idleTurn";
19
+ import { clusterParams, clusterShift, clusterTargets, focusShells, glide, hopRings } from "../../lib/layoutEngine";
20
+ import { createThreeBase } from "./graph/threeBase";
21
+ import { bubbleLabels, createBubbles3d, drawBubbleLabels } from "./graph/bubbles3d";
22
+ import { drawOverlay } from "./graph/overlay";
23
+ import { badgeAt, drawBadges } from "./graph/badges";
24
+ import { containPairs, drawContainment } from "./graph/containment";
25
+ import { drawnMemo, layoutPairs } from "../../lib/hubLines";
26
+ import { followDone, followFor, follows } from "../../lib/camFollow";
27
+ import { collapseCentroids, drillTargets } from "../../lib/graphLevels";
28
+ import { graphTheme, hueFor } from "../../lib/graphTheme";
29
+
30
+ // WIKI-GRAPH-3D-1: the 3D graph surface, GraphCanvas.jsx's twin. Two stacked canvases: the three.js base (threeBase.js:
31
+ // every node, edge and arrowhead) and the same 2D overlay as 2D (overlay.js: labels, hover/focus highlight), which takes the
32
+ // pointer (OrbitControls orbit/zoom/pan, our click/double-click/hover). The overlay is fed a projected stand-in scene: each
33
+ // node's screen position and drawn radius from this frame's camera, divided by the effective zoom k (focal / camera-to-target
34
+ // distance) with view {k, 0, 0}, so drawOverlay's sx/sy/rPx are the 3D projection and lodFor(visibleN, k) keeps the 2D label
35
+ // rule. The d3-force-3d layout runs in its own worker (forceWorker3d.js through layoutClient dims 3). One rAF loop renders
36
+ // only when the camera, the positions or the style changed. Same props, ref and bench contract as GraphCanvas
37
+ // (`data-graph-renderer="three"`), plus `onUnavailable(reason)` when no WebGL renderer can be made: the page drops to 2D.
38
+ const NONE = new Set();
39
+ const TWEEN_MS = 450;
40
+ const CLICK_PX = 3;
41
+ const ease = (t) => 1 - (1 - t) ** 3;
42
+ const FIT_FLOOR = 4; // fit-all never closer than focal / 4 (same cap as 2D fitBox)
43
+ const focalFor = (h) => h / (2 * Math.tan((FOV * Math.PI) / 360)); // px per world unit at distance 1
44
+
45
+ /** Frames, glide and focus (WIKI-GRAPH-CLUSTER-REGROUP-1 + WIKI-GRAPH-FOCUS-HOPS-1, owner ruling 2026-10-21: "3D regroups,
46
+ * 2D stays put"). Two base frames: the global layout and, with "Cluster by group" on, a clustered one laid out toward per-group
47
+ * centres (layoutEngine clusterTargets, clusterParams). Cluster on saves the global frame and glides into the clustered one;
48
+ * off glides straight back to the saved global frame with no simulation. A focus lights its hop rings (hopRings, `hops`, the
49
+ * hub guard's "+N" badges, `open` hubs) and lays only the lit ring out onto shells round the focus (focusShells) with every
50
+ * unlit node pinned; clearing it glides the ring back to the base frame. Every move is a glide (layoutEngine glide): layout
51
+ * ticks write `s.goal` and the frame loop eases the drawn positions toward it. A new node set is laid out directly in the
52
+ * active base frame. Typing (`match`) only relights. Group outlines are off by default in 3D (`outlines` turns on the subtle
53
+ * OUTLINE_STYLE); the group names are drawn while cluster is on.
54
+ * Props: GraphCanvas's (`graph, layoutKey, hidden, focusId, sizeByDegree, physics, cluster, forced, onOpen, onExpand,
55
+ * onLayoutState, height`; `forced` is accepted and ignored, the page picks the surface) plus `onUnavailable(reason)`, called
56
+ * once when the WebGL renderer cannot be created or its context is lost, `hops` (default 2), `open` (a Set of badge-opened
57
+ * hub ids), `outlines`, `onBadge(node)` (a "+N" badge clicked) and `onBackground()` (a click on empty space). `match` and
58
+ * `aim` (WIKI-GRAPH-FIND-FILTER-LIVE-1) are GraphCanvas's: the find matches lit over the dimmed base, and a camera aim at a
59
+ * node. The ref exposes fit(), pause(), resume(), reheat() and focus(id).
60
+ *
61
+ * The canvas port (WIKI-GRAPH-3D-PORT-1, EXPLORE-1b REPORT handoff item 4; the 2D canvas has none of these names yet, so they
62
+ * are the contract's): `onEvent({type, id, x, y, shift, meta, alt})` with type hover | click | dblclick | context | press (x, y
63
+ * relative to the surface, id null on empty space, meta = Cmd or Ctrl, press = a touch pointerdown); when it is given it
64
+ * REPLACES onOpen / onExpand / onBackground (onBadge stays), and right clicks are left to the page's menu (not
65
+ * preventDefaulted). An Alt-click is a click on the next node back under the pointer (canvasPort depthCycle). `marks = {lit:
66
+ * Set|null, links: [a, b][], selected: Set, hoverId}` (ids) is merged into the lit set: `lit` lights those docs over the
67
+ * dimmed base (null = no lens), `links` lights those links, `selected` gets its own ring, `hoverId` lights a doc as a pointer
68
+ * hover would (a list row). `onHoverCard(card | null)`: the anchored hover-card feed, card = {id, x, y, r, behind, w, h} (the
69
+ * hovered dot's projection and drawn radius in surface px, how many more docs are under the pointer behind it, the surface
70
+ * size), called when any of it changes (every frame the dot moves) and null while dragging, on touch, or with the dot behind
71
+ * the camera; place the card with canvasPort placeCard. With it the native `title` tooltip is off. Imperative: fitTo(ids)
72
+ * flies the camera (~450 ms, the orbit angle kept) to frame the docs (one id: the doc and its neighbours), false when none is
73
+ * drawn; flash(id) an expanding ring for ~900 ms; screenPositions(ids?) -> [{id, x, y, visible, degree}] (canvasPort
74
+ * screenList); idAt(clientX, clientY) -> id | null; zoomBy(f) (f > 1 closer, tweened); keepView(id): the next focus change to
75
+ * `id` (null: to none) neither fits nor frames, it only pans the doc on screen when it is off it. Pointer (3D-UX-1 D1): drag
76
+ * turns round the pivot (the focused doc, else the centre of the visible docs; canvasPort orbitAbout) with a cross drawn on it
77
+ * while turning; middle-drag, Cmd-drag or Option/Alt-drag pans (graphPress.js); right button never pans.
78
+ *
79
+ * Lasso (WIKI-GRAPH-EXPLORE-1c-UI, 3D-UX-1 D2): with `onLasso({phase, pts})` given, a Shift-drag with the mouse's left button draws a
80
+ * polygon instead of turning (OrbitControls is off for that press; no lasso on touch). phase = start | move | end | cancel, `pts` =
81
+ * [[x, y], ...] in surface px, sampled (lib/graphLasso lassoSample) and drawn on the overlay while it lasts. `end` comes on release
82
+ * after a drag; a Shift-click that never moved is `cancel` and stays a plain click. The page picks the docs: screenPositions() rows
83
+ * carry `depth`, and pivotDepth() is the turn pivot's view depth (null with nothing drawn), the line between front and behind.
84
+ * Drag-to-pin (round 2): with `onPin({phase: "pins", ids})` given, a plain left-drag that starts ON a dot moves it in the camera-facing
85
+ * plane at its depth (lib/graphPin) and pins it (the worker holds fx/fy/fz); a press that never moves is the usual click, a drag on empty
86
+ * space still turns, Shift is the lasso, Alt/middle pan, touch turns. `onPin` fires when the pinned set changes (a drop, releasePins(),
87
+ * a new surface clears it). `marks.pinned` (a Set of ids) draws a pin head on each; releasePins() lets them all go; pinnedIds() lists them. */
88
+ const GraphCanvas3D = forwardRef(function GraphCanvas3D(
89
+ { graph, layoutKey, hidden = NONE, focusId = null, sizeByDegree = true, physics = PHYSICS_DEFAULTS, cluster = false, drill = null, match = null, aim = null, hops = 2, dim = DIM_DEFAULT, open = null, outlines = false, idleTurn = false, marks = null, onEvent, onLasso, onPin, onHoverCard, onOpen, onExpand, onBadge, onBackground, onLayoutState, onUnavailable, height = 680 },
90
+ ref,
91
+ ) {
92
+ const fill = height === "fill";
93
+ const wrap = useRef(null);
94
+ const over = useRef(null);
95
+ const st = useRef(null);
96
+ const latest = useRef(null);
97
+ latest.current = { graph, layoutKey, hidden, focusId, sizeByDegree, physics, cluster, drill, match, aim, hops, dim, open, outlines, idleTurn, marks, onEvent, onLasso, onPin, onHoverCard, onOpen, onExpand, onBadge, onBackground, onLayoutState, onUnavailable };
98
+
99
+ useImperativeHandle(ref, () => ({
100
+ fit() {
101
+ const s = st.current;
102
+ if (!s) return;
103
+ s.fitAll = true;
104
+ s.fitIdx = null;
105
+ s.drill = null;
106
+ s.aim = -1;
107
+ s.userMoved = false;
108
+ placeView(s);
109
+ },
110
+ focus(id) {
111
+ const s = st.current;
112
+ if (!s?.sc || !s.index.has(id)) return;
113
+ s.focus = s.index.get(id);
114
+ s.fitAll = false;
115
+ s.fitIdx = null;
116
+ s.drill = null;
117
+ s.aim = -1;
118
+ s.userMoved = false;
119
+ relight(s);
120
+ placeView(s);
121
+ },
122
+ pause: () => st.current?.layout?.pause(),
123
+ resume: () => restartOr(st.current, "resume"),
124
+ reheat: () => restartOr(st.current, "reheat"),
125
+ releasePins() {
126
+ const s = st.current;
127
+ if (!s?.pins.size) return 0;
128
+ const n = s.pins.size;
129
+ s.layout?.unpin();
130
+ s.pins.clear();
131
+ s.dirty = true;
132
+ s.latest.current.onPin?.({ phase: "pins", ids: [] });
133
+ return n;
134
+ },
135
+ pinnedIds: () => [...(st.current?.pins.keys() || [])],
136
+ /** Saved views (round 5). `orientation(pending)`: the camera's `{az, el, k}` about its orbit target (graphOrient), or null before the camera exists; with `pending` true and a tween running, the tween's end instead (keyboard steps).
137
+ * `pinList()`: each pin as `{id, x, y, z}`. `applyOrient(o)`: fly to orientation `o` round the target the camera is going to (a pending
138
+ * fly-to's end, else where it is), at the current fit distance: relative, never absolute. `setPins(list)`: replace the pins with `list`
139
+ * (`{id, x, y, z}`), holding each drawn one at its place; returns how many were drawn, -1 when nothing is drawn yet. */
140
+ orientation(pending = false) {
141
+ const s = st.current;
142
+ if (!s?.base || !s.controls) return null;
143
+ if (pending && s.tween) return orientationOf(s.tween.to.t, s.tween.to.p, fitDistOf(s)); // keyboard steps start from where the camera is going
144
+ const t = s.controls.target, c = s.base.camera.position;
145
+ return orientationOf([t.x, t.y, t.z], [c.x, c.y, c.z], fitDistOf(s));
146
+ },
147
+ pinList: () => [...(st.current?.pins || [])].map(([id, p]) => ({ id, x: p[0], y: p[1], z: p[2] })),
148
+ applyOrient(o) {
149
+ const s = st.current;
150
+ if (!s?.base || !s.controls) return false;
151
+ const t = s.tween?.to.t || (({ x, y, z }) => [x, y, z])(s.controls.target);
152
+ markUserMove(s);
153
+ fly(s, t, cameraFor(t, o, fitDistOf(s)), false, false);
154
+ return true;
155
+ },
156
+ setPins(list) {
157
+ const s = st.current;
158
+ if (!s?.sc) return -1;
159
+ if (s.pins.size) { s.layout?.unpin(); s.pins.clear(); }
160
+ let n = 0;
161
+ for (const p of Array.isArray(list) ? list : []) {
162
+ const i = s.index.get(p.id);
163
+ if (i === undefined || !s.sc.mask[i]) continue;
164
+ const at = [p.x, p.y, p.z];
165
+ s.pins.set(p.id, at);
166
+ s.sc.xyz.set(at, 3 * i);
167
+ s.goal?.set(at, 3 * i);
168
+ s.layout?.pin(i, at[0], at[1], at[2]);
169
+ n++;
170
+ }
171
+ s.base?.setPositions(s.sc); // a paused worker posts no tick: upload the restored spheres and links now (as dragPin does)
172
+ s.dirty = true;
173
+ s.overlayDirty = true;
174
+ s.latest.current.onPin?.({ phase: "pins", ids: [...s.pins.keys()] });
175
+ return n;
176
+ },
177
+ fitTo(ids) {
178
+ const s = st.current;
179
+ if (!s?.sc) return false;
180
+ const want = typeof ids === "string" ? [ids] : [...(ids || [])];
181
+ const idx = want.map((id) => s.index.get(id)).filter((i) => i !== undefined && s.sc.mask[i]);
182
+ if (!idx.length) return false;
183
+ s.fitIdx = idx.length === 1 ? near(s, idx[0]) : idx; // one doc: it and its neighbours (3D-UX-1 §2 "focus fly-to")
184
+ s.fitAll = false;
185
+ s.drill = null;
186
+ s.aim = -1;
187
+ s.userMoved = false;
188
+ placeView(s);
189
+ return true;
190
+ },
191
+ flash(id) {
192
+ const s = st.current;
193
+ const i = s?.index.get(id);
194
+ if (i === undefined) return false;
195
+ s.flash = { i, t0: null, ring: null };
196
+ s.box.dataset.graphFlash = String(id);
197
+ s.overlayDirty = true;
198
+ return true;
199
+ },
200
+ screenPositions(ids = null) {
201
+ const s = st.current;
202
+ if (!s?.sc) return [];
203
+ reproject(s);
204
+ return screenList(ids, s.index, s.scr, s.sc.mask, s.sc.degree, s.w, s.h);
205
+ },
206
+ idAt(clientX, clientY) {
207
+ const s = st.current;
208
+ if (!s?.sc) return null;
209
+ reproject(s);
210
+ const r = s.el.getBoundingClientRect(), i = pickAt(s, clientX - r.left, clientY - r.top);
211
+ return i >= 0 ? s.ids[i] : null;
212
+ },
213
+ pivotDepth() {
214
+ const s = st.current, p = s?.sc && s.base ? pivotOf(s) : null;
215
+ if (!p) return null;
216
+ const f = s.base.frame(), at = new Float32Array(3), r = new Float32Array(1);
217
+ project(Float32Array.from(p), [0], 1, f.vp, f.focal, s.w, s.h, at, r);
218
+ return at[2] > 1e-6 ? at[2] : null;
219
+ },
220
+ zoomBy(f) {
221
+ const s = st.current;
222
+ if (!s?.base || !s.controls) return;
223
+ const t = s.controls.target, c = s.base.camera.position;
224
+ const to = dollyBy([c.x, c.y, c.z], [t.x, t.y, t.z], f, s.controls.minDistance, s.controls.maxDistance);
225
+ s.userMoved = true; // the layout's ticks leave a zoomed camera alone, as after a wheel
226
+ s.fitAll = false;
227
+ s.fitIdx = null;
228
+ s.drill = null;
229
+ s.aim = -1;
230
+ fly(s, [t.x, t.y, t.z], to);
231
+ },
232
+ keepView(id) {
233
+ const s = st.current;
234
+ if (!s) return;
235
+ s.keep = (id ?? null) === (s.focusId ?? null) ? null : { id: id ?? null };
236
+ },
237
+ }), []);
238
+
239
+ useEffect(() => {
240
+ const box = wrap.current, el = over.current;
241
+ let octx = null;
242
+ try { octx = el.getContext("2d"); } catch { octx = null; }
243
+ const baseEl = document.createElement("canvas");
244
+ baseEl.style.cssText = "position:absolute;left:0;top:0;pointer-events:none";
245
+ box.insertBefore(baseEl, el);
246
+ const s = {
247
+ box, el, baseEl, octx, latest, w: box.clientWidth || 800, h: fill ? box.clientHeight || 680 : height, dpr: window.devicePixelRatio || 1,
248
+ base: null, controls: null, sc: null, ov: null, key: undefined, graph: null, hidden: null, sizeByDegree: null,
249
+ focusId: undefined, ids: [], index: new Map(), titles: [], labelW: null, off: null, adj: null, pairs: null, dirs: null,
250
+ visIdx: new Uint32Array(0), scr: new Float32Array(0), rpx: new Float32Array(0), cache: new Map(), layout: null,
251
+ physics: null, t0: 0, focus: -1, hover: -1, focusNear: [], lit: [], litLinks: [], dirty: false, overlayDirty: false,
252
+ userMoved: false, lastInputAt: null, idleAt: null, fitAll: false, drill: null, drillKey: "", cpairs: new Uint32Array(0), cdraw: {}, follow: null, tween: null, down: null, pin: null, pinArm: false, pins: new Map(), pinIdx: [], pointer: null, pointerMoved: false, raf: 0, alive: true, ready: false,
253
+ cluster: undefined, layoutTok: null, layoutState: null, resumeState: null, bubbles: null, bubbleList: [], bubbleLabels: [], bubbleAt: 0,
254
+ matchIds: null, match: null, dim: false, aim: -1, aimKey: "", outlines: false,
255
+ fitIdx: null, keep: null, flash: null, cycle: null, pivot: null, camPrev: null, pointerType: "mouse", mods: null, hoverBehind: 0, cardKey: "", selIdx: [], frameAt: null,
256
+ reduced: prefersReducedMotion(), frames: { global: null, clustered: null }, goal: null, glideAt: 0, focusFrame: null, phase: "global", afterGlide: null,
257
+ hops: 2, dimLevel: DIM_DEFAULT, open: null, openIdx: null, rings: null, ringsKey: null, capped: new Map(), badges: [], litIdx: new Uint32Array(0),
258
+ };
259
+ st.current = s;
260
+ let base = null;
261
+ try {
262
+ base = createThreeBase(baseEl, { w: s.w, h: s.h, dpr: s.dpr, background: graphTheme().field });
263
+ } catch (e) {
264
+ s.alive = false;
265
+ baseEl.remove();
266
+ if (st.current === s) st.current = null;
267
+ latest.current.onUnavailable?.(e?.message || String(e));
268
+ return undefined;
269
+ }
270
+ s.base = base;
271
+ s.bubbles = createBubbles3d(base.scene);
272
+ box.dataset.graphRenderer = base.kind;
273
+ const controls = base.controls(el);
274
+ s.controls = controls;
275
+ // Reduced motion (round 2): one flag, read at the choke points (fly, controls damping, flash ring, glide). Live: a change
276
+ // mid-session takes effect on the next frame. The idle turn (round 3) reads `s.reduced` too and stays off while it is true.
277
+ controls.enableDamping = !s.reduced;
278
+ const unwatchMotion = watchReducedMotion((r) => {
279
+ s.reduced = r;
280
+ controls.enableDamping = !r;
281
+ if (r && s.tween) { const to = s.tween.to; s.tween = null; applyCamera(s, to.t, to.p); } // a tween under way ends where it was going
282
+ });
283
+ s.gizmo = gizmoParts(box.querySelector("[data-graph-gizmo]"));
284
+ // The pivot (3D-UX-1 §2): OrbitControls turns round its target; while a turn drag (and its damping) runs, each of its
285
+ // updates is redone round s.pivot. camPrev is the camera before the update (null after a programmatic move: applyCamera).
286
+ const onChange = () => {
287
+ s.dirty = true;
288
+ const t = controls.target, c = base.camera.position;
289
+ if (s.pivot && s.camPrev) {
290
+ const r = orbitAbout(s.pivot, s.camPrev.c, s.camPrev.t, [c.x, c.y, c.z], [t.x, t.y, t.z]);
291
+ if (r) { t.set(r.t[0], r.t[1], r.t[2]); c.set(r.c[0], r.c[1], r.c[2]); base.camera.lookAt(t); }
292
+ }
293
+ s.camPrev = { c: [c.x, c.y, c.z], t: [t.x, t.y, t.z] };
294
+ };
295
+ controls.addEventListener("change", onChange);
296
+ const onLost = (e) => {
297
+ if (!s.alive) return;
298
+ e.preventDefault?.();
299
+ latest.current.onUnavailable?.("WebGL context lost");
300
+ };
301
+ baseEl.addEventListener("webglcontextlost", onLost);
302
+
303
+ const resize = () => {
304
+ s.w = box.clientWidth || s.w;
305
+ if (fill) s.h = box.clientHeight || s.h;
306
+ s.dpr = window.devicePixelRatio || 1;
307
+ el.width = Math.round(s.w * s.dpr);
308
+ el.height = Math.round(s.h * s.dpr);
309
+ el.style.width = `${s.w}px`;
310
+ el.style.height = `${s.h}px`;
311
+ base.resize(s.w, s.h, s.dpr);
312
+ if (!s.userMoved) placeView(s, true);
313
+ s.dirty = s.overlayDirty = true;
314
+ };
315
+ resize();
316
+ const ro = typeof ResizeObserver === "function" ? new ResizeObserver(resize) : null;
317
+ ro?.observe(box);
318
+ sync(s, latest.current);
319
+
320
+ const frame = (now) => {
321
+ s.raf = requestAnimationFrame(frame);
322
+ if (s.tween) stepTween(s, now);
323
+ if (controls.update()) s.dirty = true;
324
+ idleStep(s, now, !!latest.current.idleTurn);
325
+ if (!s.sc) return;
326
+ if (s.goal) stepGlide(s, now);
327
+ if (s.dirty) {
328
+ s.dirty = false;
329
+ drawFrame(s);
330
+ drawGizmo(s);
331
+ feedCard(s);
332
+ if (!s.ready) { s.ready = true; box.dataset.graphReady = "1"; perfSpan("wiki-graph:first-frame", s.t0); }
333
+ }
334
+ if (s.pointerMoved && s.pointer && !s.down) {
335
+ s.pointerMoved = false;
336
+ hoverAt(s, s.pointer[0], s.pointer[1]);
337
+ }
338
+ if (s.flash) {
339
+ if (s.flash.t0 === null) s.flash.t0 = now;
340
+ s.flash.ring = flashRing(now - s.flash.t0, s.rpx[s.flash.i] || 0, s.reduced);
341
+ if (!s.flash.ring) { s.flash = null; delete box.dataset.graphFlash; }
342
+ s.overlayDirty = true; // the ring grows every frame, and its last frame clears it
343
+ }
344
+ if (s.overlayDirty && octx) {
345
+ s.overlayDirty = false;
346
+ drawOverlay(octx, s.ov, {
347
+ lit: s.lit, litLinks: frontLinks(s), focus: s.focus, hover: s.hover, selected: s.selIdx, titles: s.titles, labelW: s.labelW, dpr: s.dpr, w: s.w, h: s.h, heads: s.hotHeads, litOnly: s.dim,
348
+ under: s.cpairs.length ? (c, sx, sy) => drawContainment(c, drawnMemo(s.cdraw, s.cpairs, [s.hover, s.focus]), sx, sy, (i) => s.ov.mask[i] && s.scr[3 * i + 2] > 1e-6, s.dim ? s.dimLevel : false) : undefined,
349
+ });
350
+ drawBubbleLabels(octx, s.bubbleLabels, s.dpr, { outlines: outlinesShown(3, s.cluster, s.outlines) });
351
+ s.badges = drawBadges(octx, badgeItems(s), s.dpr);
352
+ drawMarksOver(octx, s);
353
+ }
354
+ };
355
+ if (typeof requestAnimationFrame === "function") s.raf = requestAnimationFrame(frame);
356
+
357
+ const local = (e) => { const r = el.getBoundingClientRect(); return [e.clientX - r.left, e.clientY - r.top]; };
358
+ const mods = (e) => ({ shift: !!e.shiftKey, meta: !!(e.metaKey || e.ctrlKey), alt: !!e.altKey });
359
+ const emit = (type, i, x, y, e) => latest.current.onEvent?.({ type, id: i >= 0 ? s.ids[i] : null, x, y, ...mods(e) });
360
+ const userMove = () => { markUserMove(s); s.tween = null; };
361
+ const onWheel = () => userMove();
362
+ // Capture on the box, so it runs before OrbitControls' own pointerdown on the overlay: an Option/Alt-drag pans (D1), and a
363
+ // turn (plain left drag, one finger) goes round the pivot. Any other press (a pan, a modified drag) drops the pivot.
364
+ const onDownCapture = (e) => {
365
+ if (e.target?.closest?.("[data-graph-gizmo]")) return; // the orientation gizmo is not the surface: no pivot, pin or lasso arms
366
+ Object.assign(controls.mouseButtons, buttonActions(e)); // the mode is fixed by this press's own flags and held for the drag
367
+ const t = controls.target, c = base.camera.position;
368
+ s.camPrev = { c: [c.x, c.y, c.z], t: [t.x, t.y, t.z] };
369
+ const turn = e.pointerType === "touch" || ((e.button ?? 0) === 0 && !e.altKey && !e.shiftKey && !e.ctrlKey && !e.metaKey);
370
+ // A plain left press ON a dot drags that dot (pin): the controls sit it out, like the lasso.
371
+ const [px, py] = local(e);
372
+ s.pinArm = pinArmed(e, !!latest.current.onPin && pickAt(s, px, py) >= 0);
373
+ s.pivot = turn && !s.pinArm ? pivotOf(s) : null;
374
+ // Shift + left mouse is the lasso (never OrbitControls' shift-pan): the controls sit this press out, onUp turns them back on.
375
+ s.lassoArm = !!latest.current.onLasso && e.pointerType !== "touch" && (e.button ?? 0) === 0 && e.shiftKey && !e.altKey && !e.ctrlKey && !e.metaKey;
376
+ controls.enabled = !s.lassoArm && !s.pinArm;
377
+ };
378
+ const onDown = (e) => {
379
+ const [x, y] = local(e);
380
+ s.down = { x, y, moved: false, button: e.button ?? 0 };
381
+ s.pointerType = e.pointerType || "mouse";
382
+ s.tween = null;
383
+ if (s.lassoArm) {
384
+ s.lassoArm = false;
385
+ s.lasso = { pts: [[x, y]] };
386
+ try { el.setPointerCapture?.(e.pointerId); } catch { /* a synthetic pointer has no capture */ }
387
+ latest.current.onLasso({ phase: "start", pts: s.lasso.pts });
388
+ }
389
+ if (s.pinArm) { // the dot under the press, its depth and the gap to the plane point under the pointer: the drag keeps that gap
390
+ s.pinArm = false;
391
+ const i = pickAt(s, x, y), f = s.base.frame(), cam = cameraOf(s.base.camera.matrixWorld.elements, f.eye, f.focal), depth = s.scr[3 * i + 2];
392
+ const p = [s.sc.xyz[3 * i], s.sc.xyz[3 * i + 1], s.sc.xyz[3 * i + 2]];
393
+ s.pin = { i, cam, depth, offset: pinOffset(cam, s.w, s.h, x, y, depth, p), moved: false };
394
+ try { el.setPointerCapture?.(e.pointerId); } catch { /* a synthetic pointer has no capture */ }
395
+ }
396
+ if (e.pointerType === "touch") emit("press", pickAt(s, x, y), x, y, e); // sets the page's menu target for a long-press
397
+ };
398
+ const onMove = (e) => {
399
+ const [x, y] = local(e);
400
+ s.pointer = [x, y];
401
+ s.mods = mods(e);
402
+ if (e.pointerType) s.pointerType = e.pointerType;
403
+ if (s.lasso) {
404
+ const pts = lassoSample(s.lasso.pts, x, y);
405
+ if (pts !== s.lasso.pts) { s.lasso.pts = pts; s.overlayDirty = true; latest.current.onLasso?.({ phase: "move", pts }); }
406
+ }
407
+ if (s.down) {
408
+ if (!s.down.moved && dragStarted(s.down.x, s.down.y, x, y, CLICK_PX)) { s.down.moved = true; userMove(); feedCard(s); }
409
+ if (s.pin && s.down.moved) dragPin(s, x, y);
410
+ return;
411
+ }
412
+ s.pointerMoved = true;
413
+ };
414
+ const click = (i, x, y, e) => {
415
+ const L = latest.current;
416
+ if (L.onEvent) emit("click", i, x, y, e);
417
+ else if (i >= 0) L.onOpen?.(s.graph.nodes[i]);
418
+ else L.onBackground?.(); // empty space: the page clears the focus
419
+ };
420
+ const endLasso = (e, cancel) => { // release (or a cancelled pointer): a dragged polygon ends, a Shift-click that never moved cancels
421
+ const l = s.lasso;
422
+ s.lasso = null;
423
+ controls.enabled = true;
424
+ if (!l) return;
425
+ const [x, y] = e ? local(e) : [NaN, NaN];
426
+ const pts = lassoSample(l.pts, x, y, 0.01);
427
+ s.overlayDirty = true;
428
+ latest.current.onLasso?.({ phase: cancel || !s.down?.moved ? "cancel" : "end", pts });
429
+ };
430
+ const endPin = () => { // release or cancel: the dot stays where it was dropped, pinned; the controls come back on every exit
431
+ const d = s.pin;
432
+ s.pin = null;
433
+ s.pinArm = false;
434
+ controls.enabled = true;
435
+ if (d?.moved) { s.overlayDirty = true; latest.current.onPin?.({ phase: "pins", ids: [...s.pins.keys()] }); }
436
+ };
437
+ const onCancel = () => { endLasso(null, true); endPin(); s.down = null; s.lassoArm = false; };
438
+ const onUp = (e) => {
439
+ const d = s.down;
440
+ endLasso(e, false);
441
+ endPin();
442
+ s.down = null;
443
+ if (d && !d.moved && d.button === 0) { // a right or middle press is never a click (the menu, a pan)
444
+ const [x, y] = local(e);
445
+ const b = badgeAt(s.badges, x, y);
446
+ if (b >= 0) latest.current.onBadge?.(s.graph.nodes[b]); // a "+N" badge expands that hub alone
447
+ else if (e.altKey) { // one deeper per Alt-click (3D-UX-1 §2 "occlusion")
448
+ const { pick, state } = depthCycle(s.cycle, stackAt(s, x, y), x, y, CLICK_PX);
449
+ s.cycle = state;
450
+ click(pick, x, y, e);
451
+ } else {
452
+ s.cycle = null;
453
+ click(pickAt(s, x, y), x, y, e);
454
+ }
455
+ }
456
+ if (d?.moved) s.overlayDirty = true; // the pivot cross goes
457
+ s.pointerMoved = true;
458
+ };
459
+ const onDouble = (e) => {
460
+ const [x, y] = local(e);
461
+ const i = pickAt(s, x, y);
462
+ if (latest.current.onEvent) emit("dblclick", i, x, y, e);
463
+ else if (i >= 0) latest.current.onExpand?.(s.graph.nodes[i]);
464
+ if (i < 0) { s.fitAll = true; s.fitIdx = null; s.drill = null; s.aim = -1; s.userMoved = false; placeView(s); } // empty space: fit the whole graph, never a node's zoom
465
+ };
466
+ const onContext = (e) => {
467
+ const [x, y] = local(e);
468
+ emit("context", pickAt(s, x, y), x, y, e);
469
+ if (!latest.current.onEvent) e.preventDefault(); // no page menu: no browser menu either (OrbitControls did this before)
470
+ };
471
+ const onLeave = () => {
472
+ s.pointer = null;
473
+ if (s.hover >= 0) { s.hover = -1; el.title = ""; relight(s); latest.current.onEvent?.({ type: "hover", id: null, x: NaN, y: NaN, shift: false, meta: false, alt: false }); }
474
+ feedCard(s);
475
+ };
476
+ // Any pointer, wheel, key or touch on the surface restarts the idle clock (an idle turn is never a user move: no markUserMove).
477
+ const onInput = () => { s.lastInputAt = performance.now(); };
478
+ const INPUTS = ["pointerdown", "pointermove", "wheel", "keydown", "touchstart"];
479
+ for (const n of INPUTS) box.addEventListener(n, onInput, { capture: true, passive: true });
480
+ el.addEventListener("wheel", onWheel, { passive: true });
481
+ box.addEventListener("pointerdown", onDownCapture, true);
482
+ el.addEventListener("pointerdown", onDown);
483
+ el.addEventListener("contextmenu", onContext);
484
+ el.addEventListener("pointermove", onMove);
485
+ el.addEventListener("pointerup", onUp);
486
+ el.addEventListener("pointercancel", onCancel);
487
+ el.addEventListener("pointerleave", onLeave);
488
+ el.addEventListener("dblclick", onDouble);
489
+ return () => {
490
+ s.alive = false;
491
+ if (typeof cancelAnimationFrame === "function") cancelAnimationFrame(s.raf);
492
+ ro?.disconnect();
493
+ unwatchMotion();
494
+ s.layout?.stop();
495
+ s.bubbles?.dispose();
496
+ controls.removeEventListener("change", onChange);
497
+ baseEl.removeEventListener("webglcontextlost", onLost);
498
+ base.destroy();
499
+ baseEl.remove();
500
+ el.removeEventListener("wheel", onWheel);
501
+ for (const n of INPUTS) box.removeEventListener(n, onInput, true);
502
+ box.removeEventListener("pointerdown", onDownCapture, true);
503
+ el.removeEventListener("pointerdown", onDown);
504
+ el.removeEventListener("contextmenu", onContext);
505
+ el.removeEventListener("pointermove", onMove);
506
+ el.removeEventListener("pointerup", onUp);
507
+ el.removeEventListener("pointercancel", onCancel);
508
+ el.removeEventListener("pointerleave", onLeave);
509
+ el.removeEventListener("dblclick", onDouble);
510
+ if (st.current === s) st.current = null;
511
+ };
512
+ }, [height]);
513
+
514
+ useEffect(() => {
515
+ if (st.current) sync(st.current, latest.current);
516
+ }, [graph, layoutKey, hidden, sizeByDegree, focusId, cluster, drill, hops, dim, open, outlines]);
517
+
518
+ // A find term: relight only, as in 2D. Nothing here touches the layout, the positions or the camera.
519
+ useEffect(() => {
520
+ const s = st.current;
521
+ if (!s) return;
522
+ s.matchIds = match;
523
+ rematch(s);
524
+ relight(s);
525
+ }, [match]);
526
+
527
+ // Enter on a match: aim the camera at it and its neighbours (after sync, as in 2D).
528
+ useEffect(() => {
529
+ const s = st.current;
530
+ const key = aim ? `${aim.id}#${aim.n}` : "";
531
+ if (!s || key === s.aimKey) return;
532
+ s.aimKey = key;
533
+ s.aim = aim && s.index.has(aim.id) ? s.index.get(aim.id) : -1;
534
+ if (s.aim < 0) return;
535
+ s.fitIdx = null;
536
+ s.userMoved = false;
537
+ s.fitAll = false;
538
+ s.drill = null;
539
+ placeView(s);
540
+ }, [aim]);
541
+
542
+ // The page's marks (lens lit set and links, the selection, a list row's hover): relight only, as a find term.
543
+ useEffect(() => {
544
+ if (st.current) relight(st.current);
545
+ }, [marks]);
546
+
547
+ useEffect(() => {
548
+ const s = st.current;
549
+ if (!s?.physics) return;
550
+ const p = latest.current.physics;
551
+ if (p.repel === s.physics.repel && p.link === s.physics.link && p.centre === s.physics.centre) return;
552
+ s.physics = { ...p, cluster: false };
553
+ if (s.layout) s.layout.params(s.physics, s.phase === "global" ? null : clusterParams(s.physics)); // cluster and focus keep their strong pull
554
+ }, [physics.repel, physics.link, physics.centre]);
555
+
556
+ return (
557
+ <div
558
+ ref={wrap}
559
+ data-graph-surface=""
560
+ data-graph-view="3d"
561
+ className={`relative w-full overflow-hidden ${fill ? "h-full" : "rounded-lg"}`}
562
+ style={{ height: fill ? undefined : height, background: graphTheme().field }}
563
+ >
564
+ <canvas
565
+ ref={over}
566
+ role="img"
567
+ aria-label={`3D graph of ${graph.nodes.length} docs and ${graph.links.length} links`}
568
+ className="absolute left-0 top-0 block touch-none"
569
+ style={{ cursor: "grab" }}
570
+ />
571
+ <Gizmo onTurn={(name) => st.current && turnTo(st.current, name)} />
572
+ </div>
573
+ );
574
+ });
575
+
576
+ export default GraphCanvas3D;
577
+
578
+ /** Bring what is drawn in line with the props `p` (the 2D sync: relayout, recolour, re-mask, resize or relight). */
579
+ function sync(s, p) {
580
+ const key = p.layoutKey ?? p.graph;
581
+ const was = s.cluster;
582
+ s.cluster = !!p.cluster;
583
+ s.outlines = !!p.outlines;
584
+ let built = false;
585
+ if (!s.sc || key !== s.key) {
586
+ build(s, p);
587
+ built = true;
588
+ } else if (p.graph !== s.graph) {
589
+ if (sameTopology(s.ids, s.index, s.pairs, p.graph)) recolour(s, p);
590
+ else { build(s, p); built = true; }
591
+ }
592
+ if (!built && p.hidden !== s.hidden) { remask(s, p.hidden); s.base?.setVisible(s.sc); }
593
+ if (!built && p.sizeByDegree !== s.sizeByDegree) { reradius(s, p); s.base?.setStyle(s.sc); }
594
+ const drillKey = p.drill ? `${p.drill.id}#${p.drill.n}` : ""; // a repeat drill into the same folder re-aims (the nonce differs)
595
+ const drilled = drillKey !== s.drillKey;
596
+ if (drilled) { s.drillKey = drillKey; s.drill = p.drill ? p.drill.id : null; }
597
+ const hops = p.hops ?? 2, open = p.open ?? null;
598
+ const hopsChanged = hops !== s.hops || open !== s.open, focusChanged = p.focusId !== s.focusId;
599
+ s.hops = hops;
600
+ s.open = open;
601
+ const dimLevel = p.dim ?? DIM_DEFAULT;
602
+ 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
603
+ // keepView(id): a focus change to that id (a canvas click, 1b) leaves the camera where it is, only panning the doc on screen.
604
+ const kept = focusChanged && !built && !drilled && !!s.keep && s.keep.id === (p.focusId ?? null);
605
+ if (focusChanged || built) s.keep = null;
606
+ if (built || drilled || focusChanged) {
607
+ s.focusId = p.focusId;
608
+ s.focus = p.focusId != null && s.index.has(p.focusId) ? s.index.get(p.focusId) : -1;
609
+ s.userMoved = kept;
610
+ s.fitAll = false;
611
+ s.fitIdx = null;
612
+ }
613
+ // WIKI-GRAPH-LOCAL-HOPS-RECULL-1: a focus draws only its rings, so anything that moves the rings (a new node
614
+ // set, the focus, hops, an opened hub) re-culls here, before the relight and the framing read the mask.
615
+ const cull = localKeep(s.focus, ringsOf(s)), culled = cull !== s.cull;
616
+ if (culled) { s.cull = cull; remask(s, p.hidden); s.base?.setVisible(s.sc); }
617
+ if (built || drilled || focusChanged || hopsChanged || culled) {
618
+ relight(s);
619
+ if (kept) panInto(s);
620
+ else placeView(s, built && !s.ready);
621
+ }
622
+ if (!built && was !== undefined && was !== s.cluster) { if (s.cluster) clusterOn(s); else clusterOff(s); }
623
+ else if (!built && (focusChanged || hopsChanged)) { if (s.focus >= 0) focusOn(s); else focusOff(s); }
624
+ else if (built && s.focus >= 0 && !busy(s)) focusOn(s); // no worker: the base frame is already final
625
+ rebubble(s);
626
+ s.dirty = s.overlayDirty = true;
627
+ }
628
+
629
+ /** A new node set: positions (this surface's cache by id, else seed3d), the arrays the base and the overlay read, and a
630
+ * fresh 3D layout. */
631
+ function build(s, p) {
632
+ if (s.sc) { // the cache holds the base frame (the global or clustered layout), never a focus re-layout's shells
633
+ const at = s.focusFrame ?? s.goal ?? s.sc.xyz;
634
+ if (at.length === s.sc.xyz.length) for (let i = 0; i < s.sc.n; i++) s.cache.set(s.ids[i], [at[3 * i], at[3 * i + 1], at[3 * i + 2]]);
635
+ }
636
+ const live = s.cache.size > 0; // this surface has already drawn nodes: not a first render, however disjoint the new set is
637
+ stopLayout(s);
638
+ if (s.pins.size) { s.pins.clear(); s.latest.current.onPin?.({ phase: "pins", ids: [] }); } // pins belong to one surface
639
+ s.frames = { global: null, clustered: null };
640
+ s.follow = null; // a new node set: its first layout gets a fresh follow budget (start)
641
+ s.goal = null;
642
+ s.focusFrame = null;
643
+ s.afterGlide = null;
644
+ s.rings = null;
645
+ s.focus = -1; // sync sets the focus of the new node set right after
646
+
647
+ const { nodes, links } = p.graph;
648
+ const n = nodes.length;
649
+ s.ids = nodes.map((nd) => nd.id);
650
+ s.index = new Map(s.ids.map((id, i) => [id, i]));
651
+ if (s.graph) { // a collapse: the new folder starts where its children were, not at a fresh seed
652
+ const old = s.graph.nodes;
653
+ for (const [id, c] of collapseCentroids(old, (i) => s.cache.get(old[i].id), nodes)) s.cache.set(id, c);
654
+ }
655
+ let { xyz, warm } = seed3d(nodes, { cache: s.cache }); // cluster is not a layout input: every target is 0,0,0
656
+ // A node set that shares nothing with the surface, with clustering on, is placed in its group balls and never laid out
657
+ // (an overlapping set keeps 3D's warm clustered relayout, owner ruling 2026-10-21 "3D regroups").
658
+ const frozen = placesStatically({ cluster: !!p.cluster, warm, live, regroups: true });
659
+ if (frozen) xyz = seed3d(nodes, { cluster: true, cache: s.cache }).xyz;
660
+ const { pairs, dirs, weights, off, adj } = linkArrays(links, s.index, n, dirOf);
661
+ s.cpairs = containPairs(p.graph.contains, s.index);
662
+ const k = pairs.length;
663
+ s.pairs = pairs; s.dirs = dirs; s.weights = weights; s.off = off; s.adj = adj;
664
+ const degree = Uint32Array.from(nodes, (nd) => nd.degree || 0);
665
+ const order = Uint32Array.from({ length: n }, (_, i) => i).sort((a, b) => degree[b] - degree[a]);
666
+ s.titles = nodes.map((nd) => nd.title);
667
+ s.labelW = new Float32Array(n).fill(NaN);
668
+ const big = n > LOD_N;
669
+ s.sc = {
670
+ n, xyz, radius: new Float32Array(n), colors: new Uint32Array(n), mask: null, visPairs: new Uint32Array(k),
671
+ visDirs: new Uint8Array(k / 2), visWeights: new Float32Array(k / 2), visBuckets: null, visCount: 0, visibleN: n, big, edgeAlpha: lodFor(n, 1).edgeAlpha, degree, order,
672
+ };
673
+ s.ov = {
674
+ n, pos: new Float32Array(Math.max(2, 2 * n)), sizes: new Float32Array(n), view: { k: 1, x: 0, y: 0 }, mask: null, big,
675
+ colors: s.sc.colors, degree, order, visibleN: n,
676
+ };
677
+ s.scr = new Float32Array(3 * n);
678
+ s.ov.scr = s.scr; // the overlay picks labels with depth (labelDepth.js) from the same projection
679
+ s.pivotAt = new Float32Array(3);
680
+ s.pivotScr = new Float32Array(3);
681
+ s.pivotR = new Float32Array(1); // the pivot point's zero radius; its projected radius goes to pivotRo
682
+ s.pivotRo = new Float32Array(1);
683
+ s.rpx = new Float32Array(n);
684
+ s.key = p.layoutKey ?? p.graph;
685
+ s.hover = -1;
686
+ s.aim = -1;
687
+ s.fitIdx = null; // indices of the old node set
688
+ s.cycle = null;
689
+ if (s.flash) { s.flash = null; delete s.box.dataset.graphFlash; }
690
+ rematch(s);
691
+ colour(s, p);
692
+ reradius(s, p);
693
+ s.cull = null; // the old rings are indices into the old node set: sync re-culls once the focus is placed in this one
694
+ remask(s, p.hidden);
695
+ s.box.dataset.graphInfo = JSON.stringify({ nodes: n, links: k / 2, lod: big });
696
+ s.base?.setData(s.sc);
697
+ s.physics = { ...p.physics, cluster: false };
698
+ s.t0 = performance.now();
699
+ delete s.box.dataset.graphSettledMs;
700
+ if (!n) return;
701
+ if (frozen) { // no worker: the placed frame is the clustered frame, and cluster off lays out a global one (clusterOff)
702
+ s.phase = "cluster";
703
+ s.follow = followDone(viewKey(s)); // final already: sync frames it once, a later reheat does not
704
+ s.frames.clustered = copy(xyz);
705
+ report(s, "settled", null);
706
+ return;
707
+ }
708
+ start(s, { warm, phase: p.cluster ? "cluster" : "global" }); // a new node set is written directly, never glided
709
+ }
710
+
711
+ /** The group a node's bubble and cluster target count it in: a level view's folder carries its area as `bubbleGroup` (the
712
+ * page's folderGroups); otherwise its legend group, and a folder without one belongs to none. */
713
+ const bubbleGroupOf = (nd) => nd.bubbleGroup ?? (nd.kind === "folder" ? null : nd.group ?? null);
714
+
715
+ /** The view a layout belongs to, for the camera's follow budget (camFollow): the cluster, focus and hops state. A reheat, a slider or
716
+ * a pin keeps it; a new node set starts from a null follow (build). */
717
+ const viewKey = (s) => `${!!s.cluster}|${s.focus}|${s.hops}`;
718
+
719
+ /** A layout that is still moving nodes (a paused or settled one is not). */
720
+ const busy = (s) => !!s.layout && s.layoutState !== "settled" && s.layoutState !== "paused";
721
+
722
+ /** Start a layout. `phase`: "global" (targets 0, the user's physics), "cluster" (clusterTargets per group, clusterParams) or
723
+ * "focus" (`targets` from focusShells, every unlit node `pinned`, clusterParams). `from`: the start positions (default the
724
+ * drawn ones). With `glide`, ticks write `s.goal` and the frame loop eases the drawn positions toward it; without, ticks
725
+ * write the drawn positions. Ticks and states from any layout but the latest are dropped, so a stopped one never moves a
726
+ * node. A settled base layout (global or cluster) runs a waiting focus. */
727
+ function start(s, { warm = true, phase = s.cluster ? "cluster" : "global", from = null, targets = null, pinned = null, glide: gl = false } = {}) {
728
+ const sc = s.sc, tok = {}, nodes = s.graph.nodes, pos = from || sc.xyz;
729
+ s.phase = phase;
730
+ s.follow = followFor(s.follow, viewKey(s)); // a new view follows its first layout; a reheat of the same view does not
731
+ s.layoutState = null;
732
+ if (phase === "cluster" && !targets) targets = clusterTargets(pos, 3, (i) => bubbleGroupOf(nodes[i])).targets;
733
+ if (gl && (!s.goal || s.goal.length !== pos.length)) s.goal = Float32Array.from(pos);
734
+ s.glideAt = 0;
735
+ s.layoutTok = tok;
736
+ s.layout = startLayout({
737
+ xy: pos, pairs: layoutPairs(s.pairs, s.cpairs), weights: s.weights, targets: targets || new Float32Array(3 * sc.n),
738
+ physics: s.physics, big: sc.big, warm, dims: 3, radii: layoutRadii(nodes, (i) => sc.radius[i]), pinned,
739
+ // Cluster and focus both pull at CLUSTER_CENTRE: at the global 0.013 the focus shells barely pull (REPORT-c3 deviation).
740
+ params: phase === "global" ? null : clusterParams(s.physics),
741
+ alpha: phase === "global" ? null : warm ? 0.6 : 1,
742
+ maxTicks: phase === "global" || !warm ? null : 120,
743
+ onTick: (next) => {
744
+ if (s.layoutTok !== tok || s.sc !== sc || next.length !== 3 * sc.n) return; // a frame from a layout we already replaced
745
+ if (gl) {
746
+ if (!s.goal || s.goal.length !== next.length) s.goal = new Float32Array(next.length);
747
+ s.goal.set(next);
748
+ holdPins(s.goal, s.pins, s.index); // a frame posted before the last drag move carries the older position
749
+ s.dirty = true;
750
+ return;
751
+ }
752
+ sc.xyz.set(next);
753
+ holdPins(sc.xyz, s.pins, s.index);
754
+ s.base?.setPositions(sc);
755
+ 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
756
+ if (s.cluster && performance.now() - s.bubbleAt >= 1000 / BUBBLE_HZ) rebubble(s);
757
+ s.dirty = true;
758
+ },
759
+ onState: (state, ms) => {
760
+ if (s.layoutTok !== tok || s.sc !== sc) return;
761
+ if (state === "settled") {
762
+ s.box.dataset.graphSettledMs = String(Math.round(performance.now() - s.t0));
763
+ perfSpan("wiki-graph:settle", s.t0);
764
+ if (phase === "cluster") s.frames.clustered = Float32Array.from(gl && s.goal ? s.goal : sc.xyz);
765
+ if (!gl && follows(s.follow, "settled", performance.now(), s.userMoved)) placeView(s); // a glide's camera lands with the glide (stepGlide)
766
+ rebubble(s);
767
+ }
768
+ report(s, state, ms);
769
+ if (state === "settled" && phase !== "focus" && s.focus >= 0) focusOn(s); // a focus that came while this ran
770
+ },
771
+ });
772
+ if (s.layout) { // pins outlive a re-layout (cluster, focus, reheat): hold each at its dropped place
773
+ for (const [id, p] of s.pins) { const i = s.index.get(id); if (i !== undefined) s.layout.pin(i, p[0], p[1], p[2]); }
774
+ return;
775
+ }
776
+ s.layoutTok = null; // no Worker: the seeded layout is the layout; cluster and focus become a direct shift
777
+ if (!gl) s.follow.done = true; // nothing will move: the caller's placeView is the framing
778
+ if (phase !== "global") {
779
+ const to = phase === "cluster" ? clusterShift(pos, 3, (i) => bubbleGroupOf(nodes[i]), targets) : targets;
780
+ console.warn(`wiki graph: no layout worker, the ${phase} frame is a direct shift with no simulation (links and repulsion are not applied)`);
781
+ if (gl) s.goal.set(to);
782
+ else { sc.xyz.set(to); s.base?.setPositions(sc); }
783
+ if (phase === "cluster") s.frames.clustered = Float32Array.from(to);
784
+ }
785
+ report(s, "settled", null);
786
+ }
787
+
788
+ /** One pointer move of a pin drag: the dot follows the pointer in the camera-facing plane at its press-time depth. The worker holds it
789
+ * there (pin), and the drawn positions (and the glide's goal) are written now so the dot does not wait for the next worker frame. */
790
+ function dragPin(s, x, y) {
791
+ const d = s.pin, id = s.ids[d.i];
792
+ const at = pinTarget(d.cam, s.w, s.h, x, y, d.depth, d.offset);
793
+ d.moved = true;
794
+ s.pins.set(id, at);
795
+ s.sc.xyz.set(at, 3 * d.i);
796
+ s.goal?.set(at, 3 * d.i);
797
+ s.layout?.pin(d.i, at[0], at[1], at[2]);
798
+ s.base?.setPositions(s.sc);
799
+ s.dirty = s.overlayDirty = true;
800
+ }
801
+
802
+ function stopLayout(s) {
803
+ s.layout?.stop();
804
+ s.layout = null;
805
+ s.layoutTok = null;
806
+ }
807
+
808
+ function report(s, state, ms) {
809
+ s.layoutState = state;
810
+ s.latest.current.onLayoutState?.(state, ms);
811
+ }
812
+
813
+ const copy = (a) => Float32Array.from(a);
814
+
815
+ /** One frame of the glide: the drawn positions ease toward `s.goal` (layoutEngine glide). The camera follows at most every
816
+ * 150 ms (placeView restarts its tween, so every frame would hold it still). Once on the goal with no layout still moving,
817
+ * the glide ends: bubbles and camera settle and `afterGlide` runs. */
818
+ function stepGlide(s, now) {
819
+ const sc = s.sc;
820
+ if (s.goal.length !== sc.xyz.length) { s.goal = null; return; }
821
+ const dt = s.glideAt ? now - s.glideAt : 16;
822
+ s.glideAt = now;
823
+ const left = glide(sc.xyz, s.goal, dt, s.reduced ? 0 : undefined);
824
+ s.base?.setPositions(sc);
825
+ s.dirty = true;
826
+ if (s.cluster && performance.now() - s.bubbleAt >= 1000 / BUBBLE_HZ) rebubble(s);
827
+ if (follows(s.follow, "tick", now, s.userMoved)) placeView(s);
828
+ if (left > 0 || busy(s)) return;
829
+ s.goal = null;
830
+ s.glideAt = 0;
831
+ rebubble(s);
832
+ if (follows(s.follow, "settled", now, s.userMoved)) placeView(s);
833
+ const after = s.afterGlide;
834
+ s.afterGlide = null;
835
+ after?.();
836
+ }
837
+
838
+ /** Cluster on: keep the global frame (without any focus displacement), then lay out toward the group centres, gliding. */
839
+ function clusterOn(s) {
840
+ if (!s.sc?.n) return;
841
+ stopLayout(s);
842
+ const frame = s.focusFrame ?? copy(s.goal ?? s.sc.xyz);
843
+ s.focusFrame = null;
844
+ s.afterGlide = null;
845
+ s.frames.global = copy(frame);
846
+ s.frames.clustered = null;
847
+ s.goal = copy(frame);
848
+ start(s, { phase: "cluster", from: frame, glide: true });
849
+ }
850
+
851
+ /** Cluster off: no simulation, a glide straight back to the saved global frame (it ends exactly there), then a focus is
852
+ * re-applied. A node set built while clustered has no global frame: lay one out, gliding. */
853
+ function clusterOff(s) {
854
+ if (!s.sc?.n) return;
855
+ stopLayout(s);
856
+ s.focusFrame = null;
857
+ s.phase = "global";
858
+ if (s.frames.global?.length === s.sc.xyz.length) {
859
+ s.follow = followFor(s.follow, viewKey(s)); // a view change: the camera lands with the glide
860
+ s.goal = copy(s.frames.global);
861
+ s.glideAt = 0;
862
+ report(s, "settled", null);
863
+ s.afterGlide = () => { s.frames.clustered = null; if (s.focus >= 0) focusOn(s); };
864
+ } else start(s, { phase: "global", glide: true });
865
+ }
866
+
867
+ /** Focus set (or its hops changed): lay out the lit rings onto shells round the focus, every unlit node pinned where it is.
868
+ * Always from the saved base frame, so a previous focus's displaced nodes glide back while the new ring glides out. Waits
869
+ * while a base layout is still moving (its settle calls back here). */
870
+ function focusOn(s) {
871
+ const sc = s.sc;
872
+ if (!sc?.n || s.focus < 0) return;
873
+ if (s.layout && s.phase !== "focus" && busy(s)) return;
874
+ const rings = ringsOf(s);
875
+ if (!rings) return;
876
+ stopLayout(s);
877
+ if (!s.focusFrame) s.focusFrame = copy(s.goal ?? sc.xyz);
878
+ s.afterGlide = null;
879
+ const pinned = new Uint8Array(sc.n).fill(1);
880
+ for (const i of rings.lit) pinned[i] = 0;
881
+ pinned[s.focus] = 1;
882
+ const targets = focusShells(s.focusFrame, 3, rings.depth, s.focus, 0.9 * forceParams(s.physics).linkDistance);
883
+ s.goal = copy(s.focusFrame);
884
+ start(s, { phase: "focus", from: s.focusFrame, targets, pinned, glide: true });
885
+ }
886
+
887
+ /** Focus cleared: the displaced ring glides back to the saved base frame. */
888
+ function focusOff(s) {
889
+ if (!s.focusFrame) return;
890
+ if (s.phase === "focus") stopLayout(s);
891
+ s.goal = s.focusFrame;
892
+ s.glideAt = 0;
893
+ s.focusFrame = null;
894
+ s.follow = followFor(s.follow, viewKey(s));
895
+ s.phase = s.cluster ? "cluster" : "global";
896
+ report(s, "settled", null);
897
+ }
898
+
899
+ /** Resume / Reheat: the running layout's verb; with none, a fresh base layout from the base frame (a focus is re-applied
900
+ * when it settles). */
901
+ function restartOr(s, verb) {
902
+ if (!s?.sc?.n) return;
903
+ if (s.layout) { s.layout[verb](); return; }
904
+ const from = s.focusFrame ?? copy(s.sc.xyz);
905
+ s.focusFrame = null;
906
+ s.goal = copy(from);
907
+ start(s, { warm: true, from, glide: true });
908
+ }
909
+
910
+ /** The group bubbles while cluster is on (none when off). Their names are always drawn; the outlines (one faint disc in the
911
+ * scene plus a thin ring on the overlay, groupBubbles OUTLINE_STYLE) only with `outlines` (owner 2026-10-21: off by default
912
+ * in 3D). `data-graph-bubbles` counts the outlines drawn; `data-graph-geom` holds {R, bubbles: [{c, r}]} (R = the fit radius
913
+ * of the visible nodes) for the separation measurement (WIKI-GRAPH-CLUSTER-CHECK-1 run.mjs). */
914
+ function rebubble(s) {
915
+ s.bubbleAt = performance.now();
916
+ const sc = s.sc, nodes = s.graph?.nodes;
917
+ s.bubbleList = s.cluster && sc && nodes
918
+ ? groupBubbles(sc.xyz, 3, (i) => bubbleGroupOf(nodes[i]), s.visIdx, { radiusOf: (i) => sc.radius[i] })
919
+ .map((b) => ({ ...b, color: sc.colors[b.members[0]] }))
920
+ : [];
921
+ const shown = outlinesShown(3, s.cluster, s.outlines) && s.bubbleList.length > 0;
922
+ if (shown) s.bubbles?.update(s.bubbleList); else s.bubbles?.clear();
923
+ s.box.dataset.graphBubbles = String(shown ? s.bubbleList.length : 0);
924
+ if (s.bubbleList.length) {
925
+ const R = fitSphere(sc.xyz, s.visIdx, sc.radius)?.r ?? 0; // exact extent, not framing: the geometry dump measures every drawn node
926
+ s.box.dataset.graphGeom = JSON.stringify({ R, bubbles: s.bubbleList.map((b) => ({ c: [...b.centre], r: b.radius })) });
927
+ } else {
928
+ delete s.box.dataset.graphGeom;
929
+ s.bubbleLabels = [];
930
+ }
931
+ s.dirty = s.overlayDirty = true;
932
+ }
933
+
934
+ function colour(s, p) {
935
+ const { nodes } = p.graph;
936
+ for (let i = 0; i < nodes.length; i++) s.sc.colors[i] = parseInt(hueFor(nodes[i].color || OTHER).slice(1), 16);
937
+ s.graph = p.graph;
938
+ // 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.
939
+ const looks = nodeLooks(nodes), old = s.sc.looks;
940
+ s.sc.looks = looks;
941
+ return !old || old.some((v, i) => v !== looks[i]);
942
+ }
943
+
944
+ /** Same nodes and links, new graph object: recolour, resize and re-mask, no relayout. */
945
+ function recolour(s, p) {
946
+ const relooked = colour(s, p);
947
+ refreshSameTopology(s, p.graph, dirOf);
948
+ s.layout?.weights(s.weights); // new link strengths for the next ticks; positions stay where they are (refreshSameTopology also sent any new containment pairs)
949
+ const { nodes } = p.graph, degree = Uint32Array.from(nodes, (nd) => nd.degree || 0);
950
+ s.sc.degree = s.ov.degree = degree;
951
+ s.sc.order = s.ov.order = Uint32Array.from({ length: nodes.length }, (_, i) => i).sort((a, b) => degree[b] - degree[a]);
952
+ reradius(s, p);
953
+ remask(s, p.hidden);
954
+ if (relooked) s.base?.setData(s.sc);
955
+ s.base?.setStyle(s.sc);
956
+ s.base?.setVisible(s.sc);
957
+ }
958
+
959
+ function reradius(s, p) {
960
+ const { nodes } = p.graph;
961
+ for (let i = 0; i < nodes.length; i++) s.sc.radius[i] = nodeRadius(nodes[i], p.sizeByDegree);
962
+ s.sizeByDegree = p.sizeByDegree;
963
+ }
964
+
965
+ /** The legend mask, the drawn links and the visible index list (fit and picking use it). */
966
+ function remask(s, hidden) {
967
+ const sc = s.sc;
968
+ sc.mask = s.ov.mask = visibleMask(s.graph.nodes, hidden, s.cull); // s.cull: a focus's rings (localKeep), else null
969
+ sc.visCount = visibleLinks(s.pairs, sc.mask, sc.visPairs, s.dirs, sc.visDirs, s.weights, sc.visWeights);
970
+ sc.visBuckets = linkBuckets(sc.visWeights, sc.visCount); // the width passes the base draws (linkWeight.js)
971
+ const vis = [];
972
+ for (let i = 0; i < sc.n; i++) if (sc.mask[i]) vis.push(i);
973
+ s.visIdx = Uint32Array.from(vis);
974
+ sc.visibleN = s.ov.visibleN = vis.length;
975
+ s.hidden = hidden;
976
+ s.dirty = s.overlayDirty = true;
977
+ }
978
+
979
+ /** One drawn frame: project every node through the camera, fill the overlay's stand-in scene, render the base. */
980
+ function drawFrame(s) {
981
+ const { sc, ov, scr, rpx, base, controls } = s;
982
+ const f = base.frame();
983
+ s.frameAt = f;
984
+ s.bubbleLabels = bubbleLabels(s.bubbleList, f, s.w, s.h);
985
+ project(sc.xyz, sc.radius, sc.n, f.vp, f.focal, s.w, s.h, scr, rpx);
986
+ const t = controls.target, c = base.camera.position;
987
+ const k = f.focal / Math.max(1e-6, Math.hypot(c.x - t.x, c.y - t.y, c.z - t.z));
988
+ for (let i = 0; i < sc.n; i++) {
989
+ ov.pos[2 * i] = scr[3 * i] / k;
990
+ ov.pos[2 * i + 1] = scr[3 * i + 1] / k;
991
+ ov.sizes[i] = (2 * rpx[i]) / k;
992
+ }
993
+ ov.view = { k, x: 0, y: 0 };
994
+ // the label depth rule's pivot (the orbit target's depth) and the far edge (the deepest drawn doc)
995
+ s.pivotAt[0] = t.x; s.pivotAt[1] = t.y; s.pivotAt[2] = t.z;
996
+ project(s.pivotAt, s.pivotR, 1, f.vp, f.focal, s.w, s.h, s.pivotScr, s.pivotRo);
997
+ ov.pivotDepth = s.pivotScr[2] > 1e-6 ? s.pivotScr[2] : NaN;
998
+ let far = 0;
999
+ for (let i = 0; i < sc.n; i++) if (scr[3 * i + 2] > far && (!ov.mask || ov.mask[i])) far = scr[3 * i + 2];
1000
+ ov.farDepth = far;
1001
+ base.render(sc, { scr, focal: f.focal, eye: f.eye, k });
1002
+ s.overlayDirty = true;
1003
+ }
1004
+
1005
+ /** The lit links whose both ends are in front of the camera (an end behind it has no screen position). */
1006
+ function frontLinks(s) {
1007
+ const out = [], scr = s.scr, l = s.litLinks;
1008
+ for (let e = 0; e < l.length; e += 2) if (scr[3 * l[e] + 2] > 1e-6 && scr[3 * l[e + 1] + 2] > 1e-6) out.push(l[e], l[e + 1]);
1009
+ return out;
1010
+ }
1011
+
1012
+ /** The node under (x, y), from the pickable set only (graphView pickable): while something is dimmed a dimmed node is never picked. */
1013
+ function pickAt(s, x, y) {
1014
+ if (!s.sc || !s.sc.n || !s.ready) return -1;
1015
+ return pickProjected(s.scr, s.rpx, pickable(s.visIdx, s.litIdx, s.dim), x, y);
1016
+ }
1017
+
1018
+ /** The "+N" badges this frame: each capped hub of the focus ring that is lit, drawn and in front of the camera. */
1019
+ function badgeItems(s) {
1020
+ const out = [];
1021
+ if (!s.capped.size || !s.sc) return out;
1022
+ const lit = new Set(s.lit);
1023
+ for (const [i, n] of s.capped) {
1024
+ if (lit.has(i) && s.sc.mask[i] && s.scr[3 * i + 2] > 1e-6) out.push({ i, n, x: s.scr[3 * i], y: s.scr[3 * i + 1], r: s.rpx[i] });
1025
+ }
1026
+ return out;
1027
+ }
1028
+
1029
+ function hoverAt(s, x, y) {
1030
+ const i = pickAt(s, x, y);
1031
+ const behind = i >= 0 && s.latest.current.onHoverCard ? Math.max(0, stackAt(s, x, y).length - 1) : 0;
1032
+ if (i === s.hover && behind === s.hoverBehind) return;
1033
+ s.hoverBehind = behind;
1034
+ if (i !== s.hover) {
1035
+ s.hover = i;
1036
+ s.el.style.cursor = i >= 0 ? "pointer" : "grab";
1037
+ s.el.title = i >= 0 && !s.latest.current.onHoverCard ? s.titles[i] : ""; // with the anchored card, no native tooltip
1038
+ relight(s);
1039
+ const m = s.mods || { shift: false, meta: false, alt: false };
1040
+ s.latest.current.onEvent?.({ type: "hover", id: i >= 0 ? s.ids[i] : null, x, y, ...m });
1041
+ }
1042
+ feedCard(s);
1043
+ }
1044
+
1045
+ /** A turn or zoom by the user: auto-fit stops snapping the camera back, and any focus fly-to or drill target is let go. */
1046
+ function markUserMove(s) { s.userMoved = true; s.fitAll = false; s.fitIdx = null; s.drill = null; s.aim = -1; }
1047
+
1048
+ /** The fit distance the orientation's `k` is a multiple of: the whole drawn graph, as fit-all frames it (the current camera
1049
+ * distance with nothing drawn, so a turn still keeps its distance). */
1050
+ function fitDistOf(s) {
1051
+ const c = s.sc && s.visIdx.length ? robustSphere(s.sc.xyz, s.visIdx, s.sc.radius) : null; // the same sphere fit-all frames
1052
+ if (c) return Math.max(fitDistance(c.r, FOV, s.w / Math.max(1, s.h)), focalFor(s.h) / FIT_FLOOR);
1053
+ const t = s.controls.target, p = s.base.camera.position;
1054
+ return Math.hypot(p.x - t.x, p.y - t.y, p.z - t.z) || 1;
1055
+ }
1056
+
1057
+ /** The orientation gizmo (round 4): turn the camera to look along axis `name` ("+x".."-z") or back to the front, the target and
1058
+ * distance kept. A user move, so auto-fit leaves it; it never touches the layout, the pins, the marks or the URL. */
1059
+ function turnTo(s, name) {
1060
+ if (!s.base || !s.controls) return false;
1061
+ const t = s.controls.target, c = s.base.camera.position, tgt = [t.x, t.y, t.z], fit = fitDistOf(s);
1062
+ const want = snapOrient(name, orientationOf(tgt, [c.x, c.y, c.z], fit));
1063
+ if (!want) return false;
1064
+ const p = cameraFor(tgt, want, fit);
1065
+ markUserMove(s);
1066
+ fly(s, tgt, p, false, true);
1067
+ return true;
1068
+ }
1069
+
1070
+ const GIZ_KEYS = `${MOUSE_HELP}, Shift+arrows turn 15°, + / - zoom, 0 front view, ${CARD_KEYS_HELP}`;
1071
+ const GIZ = 72, GIZ_R = 24; // the gizmo's size in px and the axis length from its centre
1072
+ const AXES = [["+x", "X", "#e5484d"], ["-x", "", "#e5484d"], ["+y", "Y", "#30a46c"], ["-y", "", "#30a46c"], ["+z", "Z", "#3e63dd"], ["-z", "", "#3e63dd"]];
1073
+ const GIZ_AT = (a) => `translate(${(GIZ / 2 + a.sx * GIZ_R).toFixed(1)} ${(GIZ / 2 - a.sy * GIZ_R).toFixed(1)})`;
1074
+
1075
+ /** The orientation gizmo: a small SVG in the corner of the 3D surface (not in the WebGL scene). Its geometry is set by drawGizmo
1076
+ * from the camera when the camera changes, never through React state; this only renders the six axis ends and Front. */
1077
+ function Gizmo({ onTurn }) {
1078
+ const start = Object.fromEntries(gizmoAxes(null, null).map((a) => [a.name, a]));
1079
+ const press = (name) => (e) => { e.stopPropagation(); onTurn(name); };
1080
+ const key = (name) => (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onTurn(name); } };
1081
+ return (
1082
+ <div data-graph-gizmo="" title={GIZ_KEYS} className="absolute bottom-2 right-2 flex select-none flex-col items-center" style={{ width: GIZ }}>
1083
+ <svg width={GIZ} height={GIZ} viewBox={`0 0 ${GIZ} ${GIZ}`} role="group" aria-label={`Orientation (${GIZ_KEYS})`}>
1084
+ {AXES.map(([name, , col]) => (
1085
+ <line key={name} data-gizmo-line={name} x1={GIZ / 2} y1={GIZ / 2} x2={GIZ / 2 + start[name].sx * GIZ_R} y2={GIZ / 2 - start[name].sy * GIZ_R} stroke={col} strokeWidth="1.5" />
1086
+ ))}
1087
+ {AXES.map(([name, label, col]) => (
1088
+ <g key={name} data-gizmo-axis={name} role="button" tabIndex={0} aria-label={`Look along ${name.toUpperCase()}`} transform={GIZ_AT(start[name])} style={{ cursor: "pointer" }} onClick={press(name)} onKeyDown={key(name)}>
1089
+ <circle r={label ? 8 : 4.5} fill={label ? col : graphTheme().gizmoDot} stroke={col} strokeWidth="1.5" />
1090
+ {label ? <text textAnchor="middle" dy="3.5" fontSize="10" fontWeight="600" fill="#fff" style={{ pointerEvents: "none" }}>{label}</text> : null}
1091
+ </g>
1092
+ ))}
1093
+ </svg>
1094
+ <button type="button" data-gizmo-front="" onClick={press("front")} className="rounded border border-border bg-background/80 px-1.5 text-[10px] leading-4 text-muted-foreground hover:text-foreground">Front</button>
1095
+ </div>
1096
+ );
1097
+ }
1098
+
1099
+ /** The gizmo's moving parts (an axis end and its line, by name), or null when there is none. */
1100
+ function gizmoParts(root) {
1101
+ if (!root) return null;
1102
+ const parts = new Map();
1103
+ for (const [name] of AXES) parts.set(name, { g: root.querySelector(`[data-gizmo-axis="${name}"]`), line: root.querySelector(`[data-gizmo-line="${name}"]`) });
1104
+ return { root, parts, key: "" };
1105
+ }
1106
+
1107
+ /** Redraw the gizmo from the camera, only when the camera's orientation changed since the last draw. */
1108
+ function drawGizmo(s) {
1109
+ const gz = s.gizmo;
1110
+ if (!gz || !s.controls) return;
1111
+ const t = s.controls.target, c = s.base.camera.position;
1112
+ const axes = gizmoAxes([t.x, t.y, t.z], [c.x, c.y, c.z]);
1113
+ const f3 = (v) => (Math.abs(v) < 5e-4 ? 0 : v).toFixed(3); // never "-0.000"
1114
+ const key = axes.map((a) => `${f3(a.sx)},${f3(a.sy)},${f3(a.depth)}`).join("|");
1115
+ if (key === gz.key) return;
1116
+ gz.key = key;
1117
+ for (const a of axes) {
1118
+ const { g, line } = gz.parts.get(a.name);
1119
+ if (!g || !line) continue;
1120
+ g.setAttribute("transform", GIZ_AT(a));
1121
+ g.dataset.sx = f3(a.sx);
1122
+ g.dataset.sy = f3(a.sy);
1123
+ g.dataset.depth = f3(a.depth);
1124
+ g.style.opacity = String(0.45 + 0.55 * (a.depth + 1) / 2); // the ends toward the viewer are brighter
1125
+ line.setAttribute("x2", String(GIZ / 2 + a.sx * GIZ_R));
1126
+ line.setAttribute("y2", String(GIZ / 2 - a.sy * GIZ_R));
1127
+ }
1128
+ }
1129
+
1130
+ /** Where the camera should be: fit every visible node (also after fit() until you move), or the focused doc with its
1131
+ * neighbours (closer than focal / 3 is never needed: 2D caps the focus zoom at 3; a doc with no neighbours at focal / 2).
1132
+ * Keeps the current orbit angle. Tweens over TWEEN_MS unless `snap` (or nothing was drawn yet). */
1133
+ function placeView(s, snap = false) {
1134
+ if (!s.sc || !s.base || !s.sc.n) return;
1135
+ const { xyz, mask } = s.sc, aspect = s.w / Math.max(1, s.h), focal = focalFor(s.h);
1136
+ let c, d;
1137
+ const dt = !s.fitAll && s.drill ? drillTargets(s.graph.nodes, s.drill).filter((i) => mask[i]) : []; // a drilled folder and its drawn children
1138
+ const ft = !s.fitAll && s.fitIdx ? s.fitIdx.filter((i) => i < s.sc.n && mask[i]) : []; // fitTo(ids)
1139
+ if (ft.length) {
1140
+ c = robustSphere(xyz, ft, s.sc.radius);
1141
+ if (!c) return;
1142
+ d = Math.max(fitDistance(c.r, FOV, aspect, 1.2), focal / 3); // the focus fly-to's floor (3D-UX-1 §2)
1143
+ } else if (dt.length) {
1144
+ c = robustSphere(xyz, dt, s.sc.radius);
1145
+ if (!c) return;
1146
+ d = Math.max(fitDistance(c.r, FOV, aspect), focal / FIT_FLOOR);
1147
+ } else if (s.fitAll || (s.aim >= 0 ? !mask[s.aim] : s.focus < 0 || !mask[s.focus])) {
1148
+ c = robustSphere(xyz, s.visIdx, s.sc.radius); // radii in: a big folder sphere is shown whole; far-out nodes left out
1149
+ if (!c) return;
1150
+ d = Math.max(fitDistance(c.r, FOV, aspect), focal / FIT_FLOOR); // a tiny graph never fills the screen (2D fitBox caps its zoom at 4)
1151
+ } else {
1152
+ const f = s.aim >= 0 ? s.aim : s.focus; // an Enter on a find match frames it, the focus otherwise
1153
+ const at = { x: xyz[3 * f], y: xyz[3 * f + 1], z: xyz[3 * f + 2] };
1154
+ const around = (f === s.focus ? s.focusNear : near(s, f)).filter((i) => mask[i]);
1155
+ if (around.length > 1) {
1156
+ c = robustSphere(xyz, around, s.sc.radius);
1157
+ d = c ? fitDistance(c.r, FOV, aspect, 1.2) : 0; // null: every neighbour non-finite, centre on the doc
1158
+ if (d < focal / 3) { c = at; d = focal / 3; }
1159
+ } else {
1160
+ c = at; d = focal / 2;
1161
+ }
1162
+ }
1163
+ const t = s.controls.target, p = s.base.camera.position;
1164
+ let dx = p.x - t.x, dy = p.y - t.y, dz = p.z - t.z;
1165
+ const len = Math.hypot(dx, dy, dz);
1166
+ if (len > 1e-9) { dx /= len; dy /= len; dz /= len; } else { dx = 0; dy = 0; dz = 1; }
1167
+ fly(s, [c.x, c.y, c.z], [c.x + dx * d, c.y + dy * d, c.z + dz * d], snap);
1168
+ }
1169
+
1170
+ /** The idle turn (round 3, option "Turn slowly when idle", off by default): while `idleRate` says turn, one frame's share of the
1171
+ * azimuth round the orbit target, through applyCamera, so the frame draws; at 0 nothing is touched (the loop draws only when dirty).
1172
+ * A tween under way counts as input. The turn is not a user move: auto-fit and what the reader set stay as they are. */
1173
+ function idleStep(s, now, on) {
1174
+ const dt = s.idleAt === null ? 0 : Math.min(IDLE_STEP_MAX_MS, Math.max(0, now - s.idleAt));
1175
+ s.idleAt = now;
1176
+ if (s.lastInputAt === null || s.tween) s.lastInputAt = now;
1177
+ if (!s.sc || !s.ready) return;
1178
+ const hidden = typeof document !== "undefined" && document.visibilityState === "hidden";
1179
+ const rate = idleRate({ on, reduced: s.reduced, now, lastInputAt: s.lastInputAt, tweening: !!s.tween, hidden, dragging: !!s.down });
1180
+ if (!rate || !dt) return;
1181
+ const t = s.controls.target, c = s.base.camera.position;
1182
+ applyCamera(s, [t.x, t.y, t.z], turnAzimuth([t.x, t.y, t.z], [c.x, c.y, c.z], (rate * dt) / 1000));
1183
+ s.overlayDirty = true; // labels follow the camera
1184
+ }
1185
+
1186
+ /** Move the camera to target `t` and position `p`: a TWEEN_MS tween, or at once with `snap`, before the first frame, or with reduced motion (every caller cuts). */
1187
+ function fly(s, t, p, snap = false, arc = false) {
1188
+ const to = { t, p };
1189
+ if (snap || s.reduced || !s.ready) { s.tween = null; applyCamera(s, to.t, to.p); return; }
1190
+ const ct = s.controls.target, cp = s.base.camera.position;
1191
+ s.tween = { from: { t: [ct.x, ct.y, ct.z], p: [cp.x, cp.y, cp.z] }, to, t0: null, arc };
1192
+ }
1193
+
1194
+ /** keepView: the focused doc off screen (or behind the camera) is panned to the centre, the angle and distance kept. */
1195
+ function panInto(s) {
1196
+ if (!s.sc || !s.base || s.focus < 0 || !s.sc.mask[s.focus]) return;
1197
+ reproject(s);
1198
+ const f = s.focus, x = s.scr[3 * f], y = s.scr[3 * f + 1];
1199
+ if (s.scr[3 * f + 2] > 1e-6 && x >= 0 && x <= s.w && y >= 0 && y <= s.h) return;
1200
+ const t = s.controls.target, c = s.base.camera.position, xyz = s.sc.xyz;
1201
+ const at = [xyz[3 * f], xyz[3 * f + 1], xyz[3 * f + 2]];
1202
+ fly(s, at, [at[0] + c.x - t.x, at[1] + c.y - t.y, at[2] + c.z - t.z]);
1203
+ }
1204
+
1205
+ /** The projection now (screenPositions, idAt and keepView may be asked between frames). */
1206
+ function reproject(s) {
1207
+ if (!s.sc || !s.base) return;
1208
+ const f = s.base.frame();
1209
+ project(s.sc.xyz, s.sc.radius, s.sc.n, f.vp, f.focal, s.w, s.h, s.scr, s.rpx);
1210
+ }
1211
+
1212
+ /** The pivot: the focused doc when it is drawn, else the centre of the visible docs (3D-UX-1 §2). Null with nothing drawn. */
1213
+ function pivotOf(s) {
1214
+ const sc = s.sc;
1215
+ if (!sc?.n) return null;
1216
+ if (s.focus >= 0 && sc.mask[s.focus]) return [sc.xyz[3 * s.focus], sc.xyz[3 * s.focus + 1], sc.xyz[3 * s.focus + 2]];
1217
+ const c = robustSphere(sc.xyz, s.visIdx, sc.radius); // the centre of the mass, as fit-all frames it
1218
+ return c ? [c.x, c.y, c.z] : null;
1219
+ }
1220
+
1221
+ /** The hits under (x, y) front to back, from the pickable set only (pickAt's rule), so the stack's head is what a plain click picks. */
1222
+ function stackAt(s, x, y) {
1223
+ if (!s.sc || !s.sc.n || !s.ready) return [];
1224
+ return pickStack(s.scr, s.rpx, pickable(s.visIdx, s.litIdx, s.dim), x, y);
1225
+ }
1226
+
1227
+ /** The anchored hover-card feed (3D-UX-1 §2 "hover card"): the hovered dot's projection, or null while dragging, on touch, or
1228
+ * with the dot hidden or behind the camera. Sent only when it changed (rounded to whole px). */
1229
+ function feedCard(s) {
1230
+ const cb = s.latest.current.onHoverCard;
1231
+ if (!cb) return;
1232
+ const i = s.hover;
1233
+ const show = i >= 0 && !!s.sc?.mask?.[i] && s.scr[3 * i + 2] > 1e-6 && !s.down?.moved && s.pointerType !== "touch";
1234
+ const card = show ? { id: s.ids[i], x: s.scr[3 * i], y: s.scr[3 * i + 1], r: s.rpx[i], behind: s.hoverBehind, w: s.w, h: s.h } : null;
1235
+ const key = card ? `${card.id}|${Math.round(card.x)}|${Math.round(card.y)}|${Math.round(card.r)}|${card.behind}|${card.w}|${card.h}` : "";
1236
+ if (key === s.cardKey) return;
1237
+ s.cardKey = key;
1238
+ cb(card);
1239
+ }
1240
+
1241
+ /** Over the overlay: the selection's rings are drawn by drawOverlay; here the flash ring and, while turning, the pivot cross. */
1242
+ function drawMarksOver(ctx, s) {
1243
+ ctx.setTransform(s.dpr, 0, 0, s.dpr, 0, 0);
1244
+ const fl = s.flash;
1245
+ if (fl?.ring && s.scr[3 * fl.i + 2] > 1e-6) {
1246
+ ctx.globalAlpha = fl.ring.alpha;
1247
+ ctx.strokeStyle = graphTheme().hot;
1248
+ ctx.lineWidth = 2;
1249
+ ctx.beginPath();
1250
+ ctx.arc(s.scr[3 * fl.i], s.scr[3 * fl.i + 1], fl.ring.radius, 0, 2 * Math.PI);
1251
+ ctx.stroke();
1252
+ ctx.globalAlpha = 1;
1253
+ }
1254
+ for (const i of s.pinIdx) { // a pin head on each pinned dot, top right
1255
+ if (!(s.scr[3 * i + 2] > 1e-6) || !s.sc.mask[i]) continue;
1256
+ const r = Math.max(3, s.rpx[i]), hx = s.scr[3 * i] + r * 0.7, hy = s.scr[3 * i + 1] - r * 0.7;
1257
+ ctx.beginPath();
1258
+ ctx.arc(hx, hy, 3.5, 0, 2 * Math.PI);
1259
+ ctx.fillStyle = graphTheme().hot;
1260
+ ctx.fill();
1261
+ ctx.strokeStyle = graphTheme().focusRing;
1262
+ ctx.lineWidth = 1;
1263
+ ctx.stroke();
1264
+ }
1265
+ if (s.lasso && s.lasso.pts.length > 1) { // the lasso polygon, closed back to its first point as it will be judged
1266
+ ctx.beginPath();
1267
+ s.lasso.pts.forEach(([x, y], k) => (k ? ctx.lineTo(x, y) : ctx.moveTo(x, y)));
1268
+ ctx.closePath();
1269
+ ctx.fillStyle = graphTheme().lassoFill;
1270
+ ctx.fill();
1271
+ ctx.strokeStyle = graphTheme().hot;
1272
+ ctx.lineWidth = 1.5;
1273
+ ctx.setLineDash([5, 4]);
1274
+ ctx.stroke();
1275
+ ctx.setLineDash([]);
1276
+ }
1277
+ if (s.pivot && s.down?.moved && s.frameAt) {
1278
+ const at = new Float32Array(3), r = new Float32Array(1);
1279
+ project(Float32Array.from(s.pivot), [0], 1, s.frameAt.vp, s.frameAt.focal, s.w, s.h, at, r);
1280
+ if (at[2] > 1e-6) {
1281
+ ctx.strokeStyle = graphTheme().focusRing;
1282
+ ctx.lineWidth = 1.5;
1283
+ ctx.beginPath();
1284
+ ctx.moveTo(at[0] - 7, at[1]); ctx.lineTo(at[0] + 7, at[1]);
1285
+ ctx.moveTo(at[0], at[1] - 7); ctx.lineTo(at[0], at[1] + 7);
1286
+ ctx.stroke();
1287
+ }
1288
+ }
1289
+ }
1290
+
1291
+ function stepTween(s, now) {
1292
+ const tw = s.tween;
1293
+ if (tw.t0 === null) tw.t0 = now;
1294
+ const u = ease(Math.min(1, (now - tw.t0) / TWEEN_MS));
1295
+ const mix = (a, b) => [a[0] + (b[0] - a[0]) * u, a[1] + (b[1] - a[1]) * u, a[2] + (b[2] - a[2]) * u];
1296
+ applyCamera(s, mix(tw.from.t, tw.to.t), tw.arc ? arcPoint(tw.to.t, tw.from.p, tw.to.p, u) : mix(tw.from.p, tw.to.p)); // a gizmo turn goes round the target, never through it
1297
+ if (u >= 1) s.tween = null;
1298
+ }
1299
+
1300
+ function applyCamera(s, t, p) {
1301
+ s.camPrev = null; // a programmatic move is never redone round the pivot; it ends the pivot unless a press is still down
1302
+ if (!s.down) s.pivot = null;
1303
+ s.controls.target.set(t[0], t[1], t[2]);
1304
+ s.base.camera.position.set(p[0], p[1], p[2]);
1305
+ s.controls.update();
1306
+ s.dirty = true;
1307
+ }
1308
+
1309
+ function near(s, i) {
1310
+ const out = [i];
1311
+ for (let e = s.off[i]; e < s.off[i + 1]; e++) out.push(s.adj[e]);
1312
+ return out;
1313
+ }
1314
+
1315
+ /** The find matches as node indices of the drawn set (null with no term); ids not drawn are skipped (as in 2D). */
1316
+ function rematch(s) {
1317
+ s.match = s.matchIds ? s.matchIds.map((id) => s.index.get(id)).filter((i) => i !== undefined) : null;
1318
+ }
1319
+
1320
+ /** The lit set and its links (graphView litOf, the same rule as 2D) and the base dim. */
1321
+ function relight(s) {
1322
+ if (!s.sc) return;
1323
+ const rings = ringsOf(s);
1324
+ s.focusNear = rings ? [...rings.lit] : [];
1325
+ s.capped = rings ? rings.capped : new Map();
1326
+ // The page's marks (WIKI-GRAPH-3D-PORT-1): a list row's hover stands in for the pointer's, a lens lights its docs and links.
1327
+ const mk = s.latest.current.marks, at = (id) => s.index.get(id);
1328
+ const markHover = mk?.hoverId != null && at(mk.hoverId) !== undefined ? at(mk.hoverId) : -1;
1329
+ const hover = s.hover >= 0 ? s.hover : markHover;
1330
+ const out = litOf({ focus: s.focus, hover, near: (i) => near(s, i), match: s.match, focusLit: rings ? s.focusNear : null });
1331
+ let { lit, links, dim } = out;
1332
+ if (mk?.lit) {
1333
+ const all = new Set(lit);
1334
+ for (const id of mk.lit) { const i = at(id); if (i !== undefined) all.add(i); }
1335
+ lit = [...all];
1336
+ dim = true; // a lens lights over the dimmed base, as a find term
1337
+ }
1338
+ if (mk?.links?.length) {
1339
+ links = [...links];
1340
+ for (const [a, b] of mk.links) { const i = at(a), j = at(b); if (i !== undefined && j !== undefined) links.push(i, j); }
1341
+ }
1342
+ s.selIdx = mk?.selected ? [...mk.selected].map(at).filter((i) => i !== undefined) : [];
1343
+ s.pinIdx = mk?.pinned ? [...mk.pinned].map(at).filter((i) => i !== undefined) : [];
1344
+ if (mk?.pinned) s.box.dataset.graphPinned = String(s.pinIdx.length); else delete s.box.dataset.graphPinned;
1345
+ s.lit = lit;
1346
+ s.litIdx = Uint32Array.from(lit.filter((i) => s.sc.mask[i]));
1347
+ s.litLinks = links;
1348
+ s.dim = dim;
1349
+ s.hotHeads = s.focus >= 0 || hover >= 0 ? hotHeads(s.pairs, s.dirs, [s.focus, hover]) : null;
1350
+ dimBase(s.baseEl, dim, s.dimLevel);
1351
+ s.box.dataset.graphLit = s.match || mk?.lit ? String(s.lit.length) : "";
1352
+ if (mk?.selected) s.box.dataset.graphSelected = String(s.selIdx.length); else delete s.box.dataset.graphSelected;
1353
+ s.overlayDirty = true;
1354
+ }
1355
+
1356
+ /** The focus's hop rings (layoutEngine hopRings, the hub guard capping big hubs), cached on focus, hops, opened hubs and
1357
+ * node set. Null with no focus. `s.open` holds the page's badge-opened ids. */
1358
+ function ringsOf(s) {
1359
+ if (!s.sc || s.focus < 0) { s.rings = null; s.ringsKey = null; return null; }
1360
+ const key = [s.focus, s.hops, s.open, s.sc];
1361
+ if (s.rings && s.ringsKey && key.every((v, k) => v === s.ringsKey[k])) return s.rings;
1362
+ const open = s.open ? new Set([...s.open].map((id) => s.index.get(id)).filter((i) => i !== undefined)) : null;
1363
+ s.rings = hopRings({ get: (i) => near(s, i) }, s.focus, s.hops, { open });
1364
+ s.ringsKey = key;
1365
+ return s.rings;
1366
+ }