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