@smartmemory/wiki-components 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +23 -3
- package/package.json +26 -4
- package/src/Ledger.jsx +47 -0
- package/src/data-client.d.ts +40 -1
- package/src/data-client.jsx +5 -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 +11 -2
- package/src/index.js +4 -1
- package/src/lib.js +3 -0
- package/src/parts/LedgerTimeline.jsx +20 -0
- package/src/parts/Markdown.jsx +22 -0
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
// WIKI-GRAPH-OBSIDIAN-1: the Obsidian-style graph view's pure helpers (no DOM, no React, no Pixi), shared by the page,
|
|
2
|
+
// the canvas, both renderers and the tests: legend state, local graph, degree sizing, physics, label picking, renderer
|
|
3
|
+
// choice. Groups, colours and degrees come from buildGraph (wikiGraph.js). Ported from the spike's g6Graph.js (e95dfeb).
|
|
4
|
+
import { HUB_DEGREE, LABEL_ALPHA_MIN, labelFade } from "./wikiGraph";
|
|
5
|
+
|
|
6
|
+
/** URL params the view keeps its state in, so a link reproduces what the reader saw. `root` is the focused doc (today's
|
|
7
|
+
* meaning, kept); a focus narrows the graph to `hops` links around it (the old `local` param is gone and ignored).
|
|
8
|
+
* `doc` (WIKI-GRAPH-DOC-PANE-1) is the doc the right-hand pane shows; absent means no pane. `x` (WIKI-GRAPH-EXPLORE-1b-SHELL) is
|
|
9
|
+
* the filter chips' explore state (graphExplore encodeX); a change to it pushes a history entry. No param holds a camera angle:
|
|
10
|
+
* a shared link opens on the default view (owner). */
|
|
11
|
+
export const PARAM = {
|
|
12
|
+
hide: "hide", root: "root", hops: "hops", by: "by", size: "size", repel: "repel", link: "link", centre: "centre", cluster: "cluster",
|
|
13
|
+
renderer: "renderer", levels: "lv", flat: "flat", view: "view", doc: "doc", range: "lr", outlines: "outlines", dim: "dim", x: "x",
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/** Hidden legend groups in the URL: `hide=a,b` (names URI-encoded one by one, so a comma in a name survives). */
|
|
17
|
+
export function parseHidden(value) {
|
|
18
|
+
if (!value) return new Set();
|
|
19
|
+
return new Set(String(value).split(",").filter(Boolean).map((s) => {
|
|
20
|
+
try { return decodeURIComponent(s); } catch { return s; }
|
|
21
|
+
}));
|
|
22
|
+
}
|
|
23
|
+
export const formatHidden = (hidden) => [...hidden].sort().map(encodeURIComponent).join(",");
|
|
24
|
+
|
|
25
|
+
/** Toggle one legend group by its literal name (a group may be called "all" or "none"). Returns a new Set. */
|
|
26
|
+
export function toggleGroup(hidden, group) {
|
|
27
|
+
const next = new Set(hidden);
|
|
28
|
+
if (next.has(group)) next.delete(group); else next.add(group);
|
|
29
|
+
return next;
|
|
30
|
+
}
|
|
31
|
+
/** The legend's bulk commands, apart from the group names: show every group, hide every group. */
|
|
32
|
+
export const showEveryGroup = () => new Set();
|
|
33
|
+
export const hideEveryGroup = (groups) => new Set(groups.map((g) => g.name));
|
|
34
|
+
|
|
35
|
+
const endId = (end) => (typeof end === "object" && end ? end.id : end);
|
|
36
|
+
|
|
37
|
+
/** Undirected adjacency: id -> Set of neighbour ids. */
|
|
38
|
+
export function adjacencyOf(links) {
|
|
39
|
+
const adj = new Map();
|
|
40
|
+
const add = (a, b) => { let s = adj.get(a); if (!s) adj.set(a, (s = new Set())); s.add(b); };
|
|
41
|
+
for (const l of links) {
|
|
42
|
+
const s = endId(l.source), t = endId(l.target);
|
|
43
|
+
if (s === t) continue;
|
|
44
|
+
add(s, t); add(t, s);
|
|
45
|
+
}
|
|
46
|
+
return adj;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The ids within `hops` links of `root` (root included). hops <= 0 is the root alone; an unknown root is empty. */
|
|
50
|
+
export function hopSet(adj, root, hops, known = null) {
|
|
51
|
+
if (!root || (known && !known.has(root))) return new Set();
|
|
52
|
+
const seen = new Set([root]);
|
|
53
|
+
let frontier = [root];
|
|
54
|
+
for (let h = 0; h < hops && frontier.length; h++) {
|
|
55
|
+
const next = [];
|
|
56
|
+
for (const id of frontier) for (const n of adj.get(id) || []) if (!seen.has(n)) { seen.add(n); next.push(n); }
|
|
57
|
+
frontier = next;
|
|
58
|
+
}
|
|
59
|
+
return seen;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The local graph: only nodes inside `local` (a hop set), and only links whose two ends are both kept. */
|
|
63
|
+
export function localGraph(graph, local) {
|
|
64
|
+
if (!local) return { nodes: graph.nodes, links: graph.links };
|
|
65
|
+
const nodes = graph.nodes.filter((n) => local.has(n.id));
|
|
66
|
+
const links = graph.links.filter((l) => local.has(endId(l.source)) && local.has(endId(l.target)));
|
|
67
|
+
return { nodes, links };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Per node index: 1 drawn, 0 hidden by the legend or, with `keep` (a Set of node indices, localKeep), outside it. A link is
|
|
71
|
+
* drawn only when both its ends are. */
|
|
72
|
+
export function visibleMask(nodes, hidden, keep = null) {
|
|
73
|
+
const mask = new Uint8Array(nodes.length);
|
|
74
|
+
for (let i = 0; i < nodes.length; i++) mask[i] = hidden.has(nodes[i].group) || (keep && !keep.has(i)) ? 0 : 1;
|
|
75
|
+
return mask;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** WIKI-GRAPH-LOCAL-HOPS-RECULL-1: the nodes a focus may draw, or null (no cull). With a focus
|
|
79
|
+
* drawn (WIKI-GRAPH-FOCUS-HOPS-ONLY-1: always, no toggle), only the focus's hop rings (`rings`, layoutEngine hopRings as the canvas lights them, hub guard and opened hubs
|
|
80
|
+
* included): a node outside them is hidden, never drawn dimmed. The page's hopSet narrowing has no hub guard and runs over the
|
|
81
|
+
* doc graph, so on its own it keeps nodes past a capped hub (and level-view hubs with no links) that the rings never light.
|
|
82
|
+
* With no focus the whole graph is drawn. Both canvases mask through this one rule. */
|
|
83
|
+
export function localKeep(focus, rings) {
|
|
84
|
+
return focus >= 0 && rings ? rings.lit : null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** WIKI-GRAPH-DIM-NO-PICK-1: the nodes a pointer may pick. While anything is dimmed (a focus, a hover or a find match) only the
|
|
88
|
+
* lit nodes take pointer events, so a dimmed node under the cursor is as good as empty canvas (even when nothing is lit);
|
|
89
|
+
* with nothing dimmed every visible node is pickable. Both renderers' hit tests, hover, cursor, click, double-click, press and
|
|
90
|
+
* the 3D depth stack go through this one rule. `vis`, `lit`: node-index arrays (lit already limited to the visible set). */
|
|
91
|
+
export function pickable(vis, lit, dim) {
|
|
92
|
+
return dim ? lit : vis;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** The lit set both canvases draw over the dimmed base: the selection-focus treatment, one rule for 2D and 3D. `focus` /
|
|
96
|
+
* `hover`: node index or -1; `near(i)`: i and its neighbours. With no `match`: the focus and the hover, each with its
|
|
97
|
+
* neighbours, and their links. With a `match` (WIKI-GRAPH-FIND-FILTER-LIVE-1: node indices of a find term, possibly
|
|
98
|
+
* empty) the matches are what is lit, plus the focus and the hover themselves (the focus is always lit), and only the
|
|
99
|
+
* focus's and the hover's links to a lit node are drawn hot. `dim`: the base steps back (anything lit, or a find term).
|
|
100
|
+
* WIKI-GRAPH-FOCUS-HOPS-1: `focusLit` (node indices within X hops of the focus, layoutEngine hopRings) replaces the
|
|
101
|
+
* focus's one-hop `near`: every node in it is lit and every link with both ends in it is hot. With a `match` too, a node
|
|
102
|
+
* is lit only if it is in both (layoutEngine focusFindLit), the focus always. */
|
|
103
|
+
export function litOf({ focus, hover, near, match = null, focusLit = null }) {
|
|
104
|
+
const lit = new Set(), links = [];
|
|
105
|
+
const ring = focus >= 0 && focusLit ? new Set(focusLit) : null;
|
|
106
|
+
const ringLinks = () => {
|
|
107
|
+
for (const a of ring) {
|
|
108
|
+
if (!lit.has(a)) continue;
|
|
109
|
+
for (const b of near(a)) if (b > a && ring.has(b) && lit.has(b)) links.push(a, b);
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
if (match) {
|
|
113
|
+
if (ring) { for (const i of match) if (ring.has(i)) lit.add(i); } else for (const i of match) lit.add(i);
|
|
114
|
+
for (const c of [focus, hover]) if (c >= 0) lit.add(c);
|
|
115
|
+
if (ring) ringLinks();
|
|
116
|
+
for (const c of ring ? [hover] : [focus, hover]) {
|
|
117
|
+
if (c < 0) continue;
|
|
118
|
+
for (const j of near(c)) if (j !== c && lit.has(j)) links.push(c, j);
|
|
119
|
+
}
|
|
120
|
+
} else {
|
|
121
|
+
if (ring) { for (const i of ring) lit.add(i); ringLinks(); }
|
|
122
|
+
for (const c of ring ? [hover] : [focus, hover]) {
|
|
123
|
+
if (c < 0) continue;
|
|
124
|
+
for (const j of near(c)) { lit.add(j); if (j !== c) links.push(c, j); }
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return { lit: [...lit], links, dim: !!match || lit.size > 0 };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** How the base looks while something is lit (selection focus or a find term; owner 2026-10-21: near invisible, opacity `dim`
|
|
131
|
+
* and fully desaturated, with only the lit docs labelled, which the overlay's `litOnly` does). One level for 2D and 3D,
|
|
132
|
+
* the find filter and the focus: `dim` (URL `?dim=`), 0 to DIM_MAX, default DIM_DEFAULT. */
|
|
133
|
+
export const DIM_DEFAULT = 0.05, DIM_MAX = 0.5, DIM_FILTER = "saturate(0)";
|
|
134
|
+
/** The `dim` URL value as a level: missing or not a number is the default, anything else clamped to [0, DIM_MAX]. */
|
|
135
|
+
export function parseDim(raw) {
|
|
136
|
+
if (raw === null || raw === undefined || raw === "") return DIM_DEFAULT;
|
|
137
|
+
const n = Number(raw);
|
|
138
|
+
return Number.isFinite(n) ? Math.min(DIM_MAX, Math.max(0, n)) : DIM_DEFAULT;
|
|
139
|
+
}
|
|
140
|
+
export function dimBase(el, on, dim = DIM_DEFAULT) {
|
|
141
|
+
el.style.opacity = on ? String(dim) : "";
|
|
142
|
+
el.style.filter = on ? DIM_FILTER : "";
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** The drawn links as index pairs (two entries per link) into `out`; returns how many links were written.
|
|
146
|
+
* With `dirs` (one arrow flag per link, graphArrows.js) and `outDirs`, the flags of the drawn links are copied alongside;
|
|
147
|
+
* likewise `weights` and `outWeights` (linkWeight.js). */
|
|
148
|
+
export function visibleLinks(pairs, mask, out, dirs = null, outDirs = null, weights = null, outWeights = null) {
|
|
149
|
+
let k = 0;
|
|
150
|
+
for (let i = 0; i < pairs.length; i += 2) {
|
|
151
|
+
const a = pairs[i], b = pairs[i + 1];
|
|
152
|
+
if (mask[a] && mask[b]) {
|
|
153
|
+
if (dirs && outDirs) outDirs[k / 2] = dirs[i / 2];
|
|
154
|
+
if (weights && outWeights) outWeights[k / 2] = weights[i / 2];
|
|
155
|
+
out[k++] = a; out[k++] = b;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return k / 2;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Node diameter in world px from its degree: sqrt growth like Obsidian, so a hub is big but never swallows the view.
|
|
162
|
+
* `byDegree` false draws every node at the smallest size. */
|
|
163
|
+
export const SIZE_MIN = 4, SIZE_MAX = 40;
|
|
164
|
+
export function nodeSize(degree, byDegree = true) {
|
|
165
|
+
if (!byDegree) return SIZE_MIN;
|
|
166
|
+
const d = Math.max(0, Number(degree) || 0);
|
|
167
|
+
return Math.min(SIZE_MAX, SIZE_MIN + 2.2 * Math.sqrt(d));
|
|
168
|
+
}
|
|
169
|
+
/** A node never draws smaller than this on screen (px radius), however far you zoom out; smaller on a big graph. */
|
|
170
|
+
export const minScreenRadius = (big) => (big ? 1.25 : 2);
|
|
171
|
+
/** The radius in world units a node is drawn at for zoom `k`. */
|
|
172
|
+
export const drawRadius = (size, k, big) => Math.max(size / 2, minScreenRadius(big) / k);
|
|
173
|
+
|
|
174
|
+
/** Physics sliders: 0..100 each, mapped to force parameters by `forceParams`. */
|
|
175
|
+
export const PHYSICS_DEFAULTS = { repel: 50, link: 30, centre: 30 };
|
|
176
|
+
export function parsePhysics(sp) {
|
|
177
|
+
const out = { ...PHYSICS_DEFAULTS };
|
|
178
|
+
for (const k of Object.keys(PHYSICS_DEFAULTS)) {
|
|
179
|
+
const raw = sp.get(PARAM[k]);
|
|
180
|
+
const v = Number(raw);
|
|
181
|
+
if (raw !== null && raw !== "" && Number.isFinite(v)) out[k] = Math.max(0, Math.min(100, Math.round(v)));
|
|
182
|
+
}
|
|
183
|
+
return out;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Slider values -> d3-force parameters, the only place the sliders get their meaning. */
|
|
187
|
+
export function forceParams({ repel, link, centre, cluster = true }) {
|
|
188
|
+
return {
|
|
189
|
+
charge: -(2 + repel * 0.6), // forceManyBody strength
|
|
190
|
+
linkDistance: 6 + link * 0.9,
|
|
191
|
+
// forceX/forceY strength. Clustering on: towards the doc's group centre, 0.05 at the default (today's). Off: towards 0,0
|
|
192
|
+
// and weak (0.013 at the default), just enough to keep unlinked components from drifting off, so links do the layout.
|
|
193
|
+
centre: cluster ? 0.002 + centre * 0.0016 : 0.001 + centre * 0.0004,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// WIKI-GRAPH-P0-LEVEL0-ZOOM-1: the folder level's node size, shared by the 3D and 2D views. A folder's radius (world units)
|
|
198
|
+
// grows with the log of its doc count, so 1 doc and 3,559 docs differ ~6x and never swamp the view, and it is capped at
|
|
199
|
+
// FOLDER_R_CAP, a fraction of the default link distance, so a sphere never dwarfs the gap a link is drawn across. The
|
|
200
|
+
// layout keeps them apart (layoutRadii: link distance is surface to surface, plus collide), and fit counts the radii.
|
|
201
|
+
export const FOLDER_R_MIN = 3, FOLDER_R_LOG = 2;
|
|
202
|
+
export const FOLDER_R_CAP = 0.6 * forceParams(PHYSICS_DEFAULTS).linkDistance; // 19.8 at the default sliders
|
|
203
|
+
/** A folder's radius from its doc count (0 or missing counts as 1): FOLDER_R_MIN + FOLDER_R_LOG * ln(size), at most `cap`. */
|
|
204
|
+
export function folderRadius(size, cap = FOLDER_R_CAP) {
|
|
205
|
+
return Math.min(cap, FOLDER_R_MIN + FOLDER_R_LOG * Math.log(Math.max(1, Number(size) || 1)));
|
|
206
|
+
}
|
|
207
|
+
/** A level-view node's diameter (world units, nodeSize's unit): a folder by its doc count, a doc by its links. */
|
|
208
|
+
export function levelSize(node, byDegree = true) {
|
|
209
|
+
if (node?.kind === "folder") return byDegree ? 2 * folderRadius(node.size) : SIZE_MIN;
|
|
210
|
+
return nodeSize(node?.degree, byDegree);
|
|
211
|
+
}
|
|
212
|
+
/** The radii the layout must keep apart (link distance surface to surface, collide), or null: only a scene with a folder
|
|
213
|
+
* node gets them, so the flat docs view lays out exactly as before. `radius(i)` is node i's drawn radius. */
|
|
214
|
+
export function layoutRadii(nodes, radius) {
|
|
215
|
+
if (!nodes.some((nd) => nd.kind === "folder")) return null;
|
|
216
|
+
return Float32Array.from(nodes, (_, i) => radius(i));
|
|
217
|
+
}
|
|
218
|
+
/** The extra spacing collide keeps between two spheres that have no link (world units). */
|
|
219
|
+
export const COLLIDE_GAP = 4;
|
|
220
|
+
/** Past this many nodes the layout skips the collide force (its tree costs as much as the many-body one); the
|
|
221
|
+
* surface-to-surface link distance still holds. */
|
|
222
|
+
export const COLLIDE_N = 3000;
|
|
223
|
+
|
|
224
|
+
/** The link distance a layout worker sets for slider distance `d`: from surface to surface when `radii` are given (so a
|
|
225
|
+
* link between two big folders stays visible), else `d` itself. */
|
|
226
|
+
export const linkDistanceFn = (d, radii) => (radii ? (l) => d + radii[l.source.index] + radii[l.target.index] : d);
|
|
227
|
+
/** The collide force's per-node radius: the drawn radius plus half COLLIDE_GAP (two spheres keep a full gap). */
|
|
228
|
+
export const collideRadiusFn = (radii) => (nd) => radii[nd.index] + COLLIDE_GAP / 2;
|
|
229
|
+
|
|
230
|
+
/** How often the worker posts positions: ~100 ms at 5k docs, ~1 s at 50k (each post copies every position). */
|
|
231
|
+
export const progressMsFor = (n) => Math.max(33, Math.min(1000, Math.round(n / 50)));
|
|
232
|
+
/** The layout cools over this many ticks (fewer on a big graph, whose clustered seeds are most of the layout already). */
|
|
233
|
+
export const maxTicksFor = (big) => (big ? 160 : 300);
|
|
234
|
+
|
|
235
|
+
export const LABEL_CELL = 48;
|
|
236
|
+
const LABEL_H = 12, LABEL_DESC = 3, LABEL_PAD = 2, LABEL_GAP = 8;
|
|
237
|
+
/** The collision half of pickLabels, shared with the 3D depth picker (labelDepth.js): `place(i, a)` puts doc i's label at the
|
|
238
|
+
* first free spot (above, below, right, left of its dot) and records it in `out` as {i, x, y, a}; either way the dot blocks
|
|
239
|
+
* every later label. Same box rule for both renderers, so no new measuring. */
|
|
240
|
+
export function createLabelPacker({ at, width, w, h, mask }) {
|
|
241
|
+
const out = [], cells = new Map();
|
|
242
|
+
const span = (b, f) => {
|
|
243
|
+
const cx0 = Math.max(-32000, Math.floor(b[0] / LABEL_CELL)), cx1 = Math.min(32000, Math.floor(b[2] / LABEL_CELL));
|
|
244
|
+
const cy0 = Math.max(-32000, Math.floor(b[1] / LABEL_CELL)), cy1 = Math.min(32000, Math.floor(b[3] / LABEL_CELL));
|
|
245
|
+
for (let cy = cy0; cy <= cy1; cy++) for (let cx = cx0; cx <= cx1; cx++) if (f((cy + 32768) * 65536 + cx + 32768)) return true;
|
|
246
|
+
return false;
|
|
247
|
+
};
|
|
248
|
+
const free = (b) => !span(b, (key) => {
|
|
249
|
+
const l = cells.get(key);
|
|
250
|
+
if (l) for (const o of l) if (b[0] < o[2] && b[2] > o[0] && b[1] < o[3] && b[3] > o[1]) return true;
|
|
251
|
+
return false;
|
|
252
|
+
});
|
|
253
|
+
const take = (b) => span(b, (key) => { const l = cells.get(key); if (l) l.push(b); else cells.set(key, [b]); return false; });
|
|
254
|
+
const box = (x, y, lw) => [x - lw / 2 - LABEL_PAD, y - LABEL_H, x + lw / 2 + LABEL_PAD, y + LABEL_DESC];
|
|
255
|
+
const place = (i, a) => {
|
|
256
|
+
if (mask && !mask[i]) return;
|
|
257
|
+
const [sx, sy, r] = at(i);
|
|
258
|
+
if (sx < -50 || sx > w + 50 || sy < -20 || sy > h + 20) return;
|
|
259
|
+
const lw = width(i);
|
|
260
|
+
const spots = [ // [centre x, baseline y]: above, below, right, left of the dot
|
|
261
|
+
[sx, sy - (r + LABEL_GAP)], [sx, sy + r + LABEL_H + LABEL_PAD], [sx + r + 4 + lw / 2 + LABEL_PAD, sy + 4], [sx - r - 4 - lw / 2 - LABEL_PAD, sy + 4],
|
|
262
|
+
];
|
|
263
|
+
for (const [x, y] of spots) {
|
|
264
|
+
const b = box(x, y, lw);
|
|
265
|
+
if (!free(b)) continue;
|
|
266
|
+
take(b);
|
|
267
|
+
out.push({ i, x, y, a });
|
|
268
|
+
break;
|
|
269
|
+
}
|
|
270
|
+
take([sx - r, sy - r, sx + r, sy + r]); // later (lower-priority) labels never cover this dot
|
|
271
|
+
};
|
|
272
|
+
return { out, place };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** Labels for one frame, in screen space (WIKI-GRAPH-DECLUTTER-1; both renderers, the 3D one through its projection).
|
|
276
|
+
* Who gets a label, in priority order: the lit docs `lead` (focus, hover, then their neighbours; up to `cap`), then the
|
|
277
|
+
* hubs (`degree` >= HUB_DEGREE) best-linked first, then, in "zoomed" mode, the rest best-linked first, fading in with zoom
|
|
278
|
+
* (labelFade(k, labelK)). Docs past the lead stop at `budget` labels in all (labelBudget of the view). `mode` is lodFor's
|
|
279
|
+
* `labels`: "focus" = the lead and the hubs only, "all" = everyone at full alpha. `order` is node indices best-linked first
|
|
280
|
+
* (so the hubs are its prefix); `at(i)` gives [sx, sy, rPx]; `width(i)` the label's px width.
|
|
281
|
+
* Collision: a label tries above its dot, then below, right and left, and takes the first spot that overlaps no drawn
|
|
282
|
+
* label and no dot of a doc that came before it; a doc with no free spot gets no label. A uniform grid of LABEL_CELL px
|
|
283
|
+
* cells holds the boxes, so a frame is linear in the docs looked at, not quadratic. Returns [{i, x, y, a}]: the text's
|
|
284
|
+
* centre x, its baseline y, its alpha. */
|
|
285
|
+
export function pickLabels({ lead, order, mode, cap, budget = cap, at, width, w, h, mask, degree = null, k = 1, labelK = 0 }) {
|
|
286
|
+
const { out, place: one } = createLabelPacker({ at, width, w, h, mask });
|
|
287
|
+
const leadSet = new Set();
|
|
288
|
+
for (const i of lead) { if (out.length >= cap) return out; leadSet.add(i); one(i, 1); }
|
|
289
|
+
const limit = Math.min(cap, budget), fade = labelFade(k, labelK);
|
|
290
|
+
for (const i of order) {
|
|
291
|
+
if (out.length >= limit) break;
|
|
292
|
+
if (leadSet.has(i)) continue;
|
|
293
|
+
const hub = !!degree && degree[i] >= HUB_DEGREE;
|
|
294
|
+
if (mode === "all") { one(i, 1); continue; }
|
|
295
|
+
if (hub) { one(i, 1); continue; }
|
|
296
|
+
if (mode === "focus" || fade < LABEL_ALPHA_MIN) break; // past the hubs: only zoomed in, once they are visible at all
|
|
297
|
+
one(i, fade);
|
|
298
|
+
}
|
|
299
|
+
return out;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** Which renderer draws the graph: WebGL (Pixi) when the browser has it, else our Canvas2D drawing. `?renderer=canvas`
|
|
303
|
+
* forces the fallback (the bench and reviewers use it). */
|
|
304
|
+
export function chooseRenderer({ webgl, forced = null }) {
|
|
305
|
+
if (forced === "canvas") return "canvas";
|
|
306
|
+
return webgl ? "webgl" : "canvas";
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** WIKI-GRAPH-3D-1: which surface draws the graph. 3D is the main renderer (WebGL2 present and `view=2d` not asked), 2D
|
|
310
|
+
* otherwise. `view=3d` without WebGL2 is still 2D (the page logs why once). */
|
|
311
|
+
export const chooseView = ({ webgl2, requested = null }) => (requested === "2d" || !webgl2 ? "2d" : "3d");
|
|
312
|
+
|
|
313
|
+
/** The one warning the fallback logs: what is lost without WebGL. */
|
|
314
|
+
export const FALLBACK_WARNING = "wiki graph: WebGL is unavailable, drawing with Canvas2D instead. Lost: GPU drawing, so every "
|
|
315
|
+
+ "frame is redrawn in full and panning a large graph is slower.";
|
|
316
|
+
|
|
317
|
+
// WIKI-GRAPH-3D-COLOR-SPHERES-1: how a node LOOKS, one function for 3D and 2D so the two views never disagree. A doc is a plain
|
|
318
|
+
// solid sphere in its group colour, a folder is a glass shell (translucent, full radius) round a solid core, a hub (one of the
|
|
319
|
+
// best-linked docs) wears a white halo.
|
|
320
|
+
export const LOOK = { DOC: 0, FOLDER: 1, HUB: 2 };
|
|
321
|
+
export const HUB_SHARE = 0.02, HUB_MAX = 60, HUB_MIN_DEGREE = 8;
|
|
322
|
+
/** The node indices that are hubs: docs only (never a folder), the best-linked first, at most ceil(2%) of the docs and 60,
|
|
323
|
+
* and only with degree >= 8, so a sparse graph has none. Ties go to the lower index. */
|
|
324
|
+
export function hubIndices(nodes) {
|
|
325
|
+
const docs = [];
|
|
326
|
+
for (let i = 0; i < nodes.length; i++) if (nodes[i].kind !== "folder" && (nodes[i].degree || 0) >= HUB_MIN_DEGREE) docs.push(i);
|
|
327
|
+
const total = nodes.reduce((t, nd) => t + (nd.kind === "folder" ? 0 : 1), 0);
|
|
328
|
+
docs.sort((a, b) => nodes[b].degree - nodes[a].degree || a - b);
|
|
329
|
+
return docs.slice(0, Math.min(HUB_MAX, Math.ceil(HUB_SHARE * total)));
|
|
330
|
+
}
|
|
331
|
+
/** One look per node (Uint8Array of LOOK values) for `nodes`, hubs from `hubIndices` unless given. */
|
|
332
|
+
export function nodeLooks(nodes, hubs = hubIndices(nodes)) {
|
|
333
|
+
const out = new Uint8Array(nodes.length);
|
|
334
|
+
for (let i = 0; i < nodes.length; i++) out[i] = nodes[i].kind === "folder" ? LOOK.FOLDER : LOOK.DOC;
|
|
335
|
+
for (const i of hubs) out[i] = LOOK.HUB;
|
|
336
|
+
return out;
|
|
337
|
+
}
|
|
338
|
+
/** A folder's solid core is this fraction of its shell's radius. */
|
|
339
|
+
export const CORE = 0.4;
|
|
340
|
+
/** A hub's halo reaches this multiple of the doc's radius. */
|
|
341
|
+
export const HALO = 1.3;
|
|
342
|
+
/** The folder shell's opacity. */
|
|
343
|
+
export const SHELL_ALPHA = 0.35;
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// WIKI-GRAPH-EXPLORE-1c-UI round 5: saved views, a per-viewer list in this browser (design 11.3 a). Pure, no React, no three.
|
|
2
|
+
// An entry is `{name, url, savedAt}` and, saved in 3D, `orient: {az, el, k}` (graphOrient: orientation round the orbit target, distance as a
|
|
3
|
+
// multiple of the fit, never absolute coordinates) and `pins: [{id, x, y, z}]`. Camera and pins are never in the share URL.
|
|
4
|
+
// Everything read back from storage is untrusted: its shape is checked here and a bad entry is dropped, never thrown on.
|
|
5
|
+
import { cleanOrient } from "./graphOrient";
|
|
6
|
+
import { readStoredList, writeStore } from "./graphShell";
|
|
7
|
+
|
|
8
|
+
export const VIEWS_MAX = 50;
|
|
9
|
+
export const SAVE_FAILED = "Couldn't save on this browser";
|
|
10
|
+
const num = (v) => typeof v === "number" && Number.isFinite(v);
|
|
11
|
+
|
|
12
|
+
/** The saved-views store key, one list per workspace. */
|
|
13
|
+
export const viewsKey = (workspace) => `wiki.graph.views.${workspace}`;
|
|
14
|
+
|
|
15
|
+
/** A pin read from storage: a non-empty string id and three finite numbers, else null. */
|
|
16
|
+
export function cleanPin(p) {
|
|
17
|
+
return p && typeof p === "object" && typeof p.id === "string" && p.id && num(p.x) && num(p.y) && num(p.z) ? { id: p.id, x: p.x, y: p.y, z: p.z } : null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** One entry read from storage, made whole or null: it needs a non-empty string name, a string url and a finite savedAt. `orient`
|
|
21
|
+
* (an object) goes through cleanOrient, a non-array `pins` is dropped, bad pins are dropped. */
|
|
22
|
+
export function cleanView(v) {
|
|
23
|
+
if (!v || typeof v !== "object" || Array.isArray(v)) return null;
|
|
24
|
+
if (typeof v.name !== "string" || !v.name.trim() || typeof v.url !== "string" || !num(v.savedAt)) return null;
|
|
25
|
+
const out = { name: v.name, url: v.url, savedAt: v.savedAt };
|
|
26
|
+
if (v.orient && typeof v.orient === "object" && !Array.isArray(v.orient)) out.orient = cleanOrient(v.orient);
|
|
27
|
+
if (Array.isArray(v.pins)) out.pins = v.pins.map(cleanPin).filter(Boolean);
|
|
28
|
+
return out;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** A views list read from storage: a non-array is `[]`, a bad entry is dropped, a repeated name keeps its first (newest) entry, at most VIEWS_MAX. */
|
|
32
|
+
export function cleanViews(raw) {
|
|
33
|
+
if (!Array.isArray(raw)) return [];
|
|
34
|
+
const seen = new Set();
|
|
35
|
+
const out = [];
|
|
36
|
+
for (const e of raw) {
|
|
37
|
+
const v = cleanView(e);
|
|
38
|
+
if (v && !seen.has(v.name)) { seen.add(v.name); out.push(v); }
|
|
39
|
+
}
|
|
40
|
+
return out.slice(0, VIEWS_MAX);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export const VIEWS_DISCARDED = "Graph: saved views in this browser were malformed and were dropped.";
|
|
44
|
+
/** The stored views, newest first, shape-checked. Never throws; malformed stored state warns once with fixed text. */
|
|
45
|
+
export const readViews = (workspace) => readStoredList(viewsKey(workspace), VIEWS_DISCARDED, (e) => cleanView(e) !== null, cleanViews);
|
|
46
|
+
|
|
47
|
+
/** `list` with `entry` added at the front: a view of the same name is replaced, and past `max` the oldest go. */
|
|
48
|
+
export const addView = (list, entry, max = VIEWS_MAX) => [entry, ...(Array.isArray(list) ? list : []).filter((v) => v.name !== entry.name)].slice(0, max);
|
|
49
|
+
/** `list` without the view called `name`. */
|
|
50
|
+
export const removeView = (list, name) => (Array.isArray(list) ? list : []).filter((v) => v.name !== name);
|
|
51
|
+
|
|
52
|
+
/** A new entry. `url` is the page's own address (path and query, no origin). `orient` and `pins` are kept only when given (a 3D save); the
|
|
53
|
+
* orientation is cleaned and the pins shape-checked, so the entry is always one `cleanView` would accept. */
|
|
54
|
+
export function makeView({ name, url, orient, pins, now = Date.now() }) {
|
|
55
|
+
return cleanView({ name: String(name || "").trim(), url, savedAt: now, ...(orient ? { orient } : {}), ...(pins ? { pins } : {}) });
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The name offered for a save: the chips' text joined, else `View <n>` (the first n not taken). */
|
|
59
|
+
export function defaultViewName(chipTexts, list) {
|
|
60
|
+
const taken = new Set((Array.isArray(list) ? list : []).map((v) => v.name));
|
|
61
|
+
const chips = (chipTexts || []).filter(Boolean).join(", ").slice(0, 60);
|
|
62
|
+
if (chips) return chips;
|
|
63
|
+
let n = 1;
|
|
64
|
+
while (taken.has(`View ${n}`)) n++;
|
|
65
|
+
return `View ${n}`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Save `entry` into the workspace's list: `{ok, list}`. When storage refuses, ok is false and `list` is the unchanged one. */
|
|
69
|
+
export function saveView(workspace, entry) {
|
|
70
|
+
const cur = readViews(workspace);
|
|
71
|
+
const next = addView(cur, entry);
|
|
72
|
+
return writeStore(viewsKey(workspace), next) ? { ok: true, list: next } : { ok: false, list: cur };
|
|
73
|
+
}
|
|
74
|
+
/** Delete the view `name` from the workspace's list; returns the list now stored (the old one when storage refuses). */
|
|
75
|
+
export function deleteView(workspace, name) {
|
|
76
|
+
const cur = readViews(workspace);
|
|
77
|
+
const next = removeView(cur, name);
|
|
78
|
+
return writeStore(viewsKey(workspace), next) ? next : cur;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The pins of a view that can be restored: only ids drawn in the restored view. `{pins, skipped}` (skipped = how many were not drawn). */
|
|
82
|
+
export function restorablePins(pins, drawnIds) {
|
|
83
|
+
const list = Array.isArray(pins) ? pins : [];
|
|
84
|
+
const kept = list.filter((p) => drawnIds?.has?.(p.id));
|
|
85
|
+
return { pins: kept, skipped: list.length - kept.length };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The query part of a saved url as URLSearchParams (the page applies this; the path is ignored). A url with no query gives empty params. */
|
|
89
|
+
export function paramsOfUrl(url) {
|
|
90
|
+
const s = String(url ?? "");
|
|
91
|
+
const q = s.indexOf("?");
|
|
92
|
+
if (q < 0) return new URLSearchParams();
|
|
93
|
+
const h = s.indexOf("#", q);
|
|
94
|
+
return new URLSearchParams(s.slice(q + 1, h < 0 ? undefined : h));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The status line after applying a view; `view` is "3d" or "2d", `skipped` the pins not drawn. */
|
|
98
|
+
export function appliedText(v, view, skipped = 0) {
|
|
99
|
+
const parts = [`Applied "${v.name}".`];
|
|
100
|
+
if (view !== "3d" && (v.orient || v.pins?.length)) parts.push("Camera and pins apply in 3D only.");
|
|
101
|
+
if (view === "3d" && skipped > 0) parts.push(`${skipped} pinned ${skipped === 1 ? "doc is" : "docs are"} not in this view: skipped.`);
|
|
102
|
+
return parts.join(" ");
|
|
103
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// The "Cluster by group" helpers both canvases share. Pure: no three, no Pixi, no DOM.
|
|
2
|
+
//
|
|
3
|
+
// 3D (WIKI-GRAPH-CLUSTER-REGROUP-1): a clustered layout, plus an optional faint sphere round each group (`groupBubbles`,
|
|
4
|
+
// `outlinesShown`). 2D (WIKI-GRAPH-2D-CLUSTER-ONCE-1, owner 2026-10-22: "it shouldn't show outlines in 2D but rather relayout
|
|
5
|
+
// once then not relayout dynamically after that"): ONE clustered layout, then the positions stay put (`layout2d`); no outline
|
|
6
|
+
// is ever drawn in 2D. Was WIKI-GRAPH-CLUSTER-NORELAYOUT-1: a hull round each group and the layout frozen where it was.
|
|
7
|
+
//
|
|
8
|
+
// Also the frozen-layout seeding: a doc that appears while the layout stays put (a filter, a level change, a data refresh) is
|
|
9
|
+
// placed by `placeAppearing` from the docs that already have positions, and nothing else moves.
|
|
10
|
+
|
|
11
|
+
export const BUBBLE_MIN_DOCS = 4; // a group with fewer visible docs gets no bubble
|
|
12
|
+
export const BUBBLE_QUANTILE = 0.85; // the bubble reaches this share of its docs; outliers stay outside
|
|
13
|
+
export const BUBBLE_HZ = 4; // while positions change, bubbles are recomputed at most this often
|
|
14
|
+
// The one group-outline style for both surfaces (owner 2026-10-21, REGROUP addendum: the old shading was a defect). Was: 3D a
|
|
15
|
+
// BackSide sphere at opacity 0.07 plus a FrontSide rim at 0.025 (about 0.09 a bubble, 7 overlapping bubbles stacked into one
|
|
16
|
+
// blob), 2D a hull fill at 0.06 with a 0.3 line. Now: one flat layer at `fill`, a thin ring at `line`, `width` CSS px.
|
|
17
|
+
export const OUTLINE_STYLE = Object.freeze({ fill: 0.04, line: 0.3, width: 1 });
|
|
18
|
+
|
|
19
|
+
/** Whether group outlines are drawn: never with cluster off, never in 2D (WIKI-GRAPH-2D-CLUSTER-ONCE-1: the clusters show the
|
|
20
|
+
* groups by position); in 3D only with the `outlines` toggle (`?outlines=1`), off by default. */
|
|
21
|
+
export const outlinesShown = (dims, cluster, outlines = false) => !!cluster && dims === 3 && !!outlines;
|
|
22
|
+
|
|
23
|
+
const GOLDEN = Math.PI * (3 - Math.sqrt(5));
|
|
24
|
+
|
|
25
|
+
/** The bubble of each group: `[{group, centre, radius, members}]`, sorted by group name. `pos`: positions, `dims` (2 or 3)
|
|
26
|
+
* floats a node. `groupOf(i)`: node i's group, or null for a node that belongs to none (a level view's folder). `visibleIdx`:
|
|
27
|
+
* the visible node indices; hidden docs never count. `radiusOf(i)`: node i's world radius (0 when absent). `centre` is the
|
|
28
|
+
* members' mean; `radius` the `quantile` (nearest rank) of each member's distance from it plus its own radius. A group with
|
|
29
|
+
* fewer than `minDocs` visible members has no bubble. `members` holds every visible member, inside the radius or not. */
|
|
30
|
+
export function groupBubbles(pos, dims, groupOf, visibleIdx, { minDocs = BUBBLE_MIN_DOCS, quantile = BUBBLE_QUANTILE, radiusOf = null } = {}) {
|
|
31
|
+
const byGroup = new Map();
|
|
32
|
+
for (const i of visibleIdx) {
|
|
33
|
+
const g = groupOf(i);
|
|
34
|
+
if (g === null || g === undefined) continue;
|
|
35
|
+
let l = byGroup.get(g);
|
|
36
|
+
if (!l) byGroup.set(g, (l = []));
|
|
37
|
+
l.push(i);
|
|
38
|
+
}
|
|
39
|
+
const out = [];
|
|
40
|
+
for (const [group, members] of byGroup) {
|
|
41
|
+
if (members.length < minDocs) continue;
|
|
42
|
+
const centre = new Array(dims).fill(0);
|
|
43
|
+
for (const i of members) for (let d = 0; d < dims; d++) centre[d] += pos[dims * i + d];
|
|
44
|
+
for (let d = 0; d < dims; d++) centre[d] /= members.length;
|
|
45
|
+
const reach = members.map((i) => {
|
|
46
|
+
let s = 0;
|
|
47
|
+
for (let d = 0; d < dims; d++) s += (pos[dims * i + d] - centre[d]) ** 2;
|
|
48
|
+
return Math.sqrt(s) + (radiusOf ? radiusOf(i) : 0);
|
|
49
|
+
}).sort((a, b) => a - b);
|
|
50
|
+
const radius = reach[Math.min(reach.length - 1, Math.max(0, Math.ceil(quantile * reach.length) - 1))];
|
|
51
|
+
out.push({ group, centre, radius, members });
|
|
52
|
+
}
|
|
53
|
+
return out.sort((a, b) => (a.group < b.group ? -1 : a.group > b.group ? 1 : 0));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Positions for the nodes that have none, without moving any node that has one (the frozen layout's seeding). `pos`: `dims`
|
|
57
|
+
* floats a node, rewritten only at nodes whose `known[i]` is 0. A node is placed, in order of preference, at the mean of its
|
|
58
|
+
* known neighbours (`pairs`: node index pairs), else at the mean of the known docs of its group (`groupOf`), else at the
|
|
59
|
+
* mean of every known node; nodes that land on the same spot are fanned out a little round it (golden angle). With no known
|
|
60
|
+
* node at all nothing is written (the caller keeps its seed). Returns the number of nodes placed. `groupFirst` (2D cluster,
|
|
61
|
+
* WIKI-GRAPH-2D-CLUSTER-ONCE-1): the group's mean comes before the neighbours', so a new doc lands beside its own cluster even
|
|
62
|
+
* when it links into another one. */
|
|
63
|
+
export function placeAppearing(pos, dims, known, pairs, groupOf, { groupFirst = false } = {}) {
|
|
64
|
+
const n = known.length;
|
|
65
|
+
const sum = (idx) => {
|
|
66
|
+
if (!idx.length) return null;
|
|
67
|
+
const c = new Array(dims).fill(0);
|
|
68
|
+
for (const i of idx) for (let d = 0; d < dims; d++) c[d] += pos[dims * i + d];
|
|
69
|
+
return c.map((v) => v / idx.length);
|
|
70
|
+
};
|
|
71
|
+
const all = [], byGroup = new Map(), nbr = new Map();
|
|
72
|
+
for (let i = 0; i < n; i++) {
|
|
73
|
+
if (!known[i]) continue;
|
|
74
|
+
all.push(i);
|
|
75
|
+
const g = groupOf(i);
|
|
76
|
+
if (g !== null && g !== undefined) { if (!byGroup.has(g)) byGroup.set(g, []); byGroup.get(g).push(i); }
|
|
77
|
+
}
|
|
78
|
+
if (!all.length) return 0;
|
|
79
|
+
for (let e = 0; e < pairs.length; e += 2) {
|
|
80
|
+
const a = pairs[e], b = pairs[e + 1];
|
|
81
|
+
if (known[a] && !known[b]) { if (!nbr.has(b)) nbr.set(b, []); nbr.get(b).push(a); }
|
|
82
|
+
if (known[b] && !known[a]) { if (!nbr.has(a)) nbr.set(a, []); nbr.get(a).push(b); }
|
|
83
|
+
}
|
|
84
|
+
const everyone = sum(all), groupMean = new Map(), crowd = new Map();
|
|
85
|
+
let placed = 0;
|
|
86
|
+
for (let i = 0; i < n; i++) {
|
|
87
|
+
if (known[i]) continue;
|
|
88
|
+
const g = groupOf(i);
|
|
89
|
+
const ofGroup = () => {
|
|
90
|
+
if (g === null || g === undefined || !byGroup.has(g)) return null;
|
|
91
|
+
if (!groupMean.has(g)) groupMean.set(g, sum(byGroup.get(g)));
|
|
92
|
+
return groupMean.get(g);
|
|
93
|
+
};
|
|
94
|
+
const ofNbrs = () => (nbr.has(i) ? sum(nbr.get(i)) : null);
|
|
95
|
+
const c = (groupFirst ? ofGroup() || ofNbrs() : ofNbrs() || ofGroup()) || everyone;
|
|
96
|
+
const key = c.join(","), j = (crowd.get(key) || 0) + 1, r = 6 * Math.sqrt(j), a = j * GOLDEN;
|
|
97
|
+
crowd.set(key, j);
|
|
98
|
+
const off = dims === 3 ? [Math.cos(a) * r, Math.sin(a) * r, ((j % 2) ? 1 : -1) * 0.5 * r] : [Math.cos(a) * r, Math.sin(a) * r];
|
|
99
|
+
for (let d = 0; d < dims; d++) pos[dims * i + d] = c[d] + off[d];
|
|
100
|
+
placed++;
|
|
101
|
+
}
|
|
102
|
+
return placed;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** 3D (WIKI-GRAPH-CLUSTER-DISJOINT-FU-1): is a new node set (a build) placed statically, with no layout run? Never with cluster
|
|
106
|
+
* off. With cluster on (owner 2026-10-21 "3D regroups"), a set that overlaps the cache (`warm`) gets a warm clustered relayout and
|
|
107
|
+
* a surface's first render is laid out, so only a disjoint set on a live surface is static. `live`: this surface has drawn nodes
|
|
108
|
+
* before; `warm`: any node or parent was found in the cache. 2D has its own rule, `layout2d`. */
|
|
109
|
+
export function placesStatically({ cluster, warm, live }) {
|
|
110
|
+
return !!cluster && live && !warm;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** WIKI-GRAPH-2D-CLUSTER-ONCE-1: which layout the 2D canvas runs on `event`: "global" (the plain force layout), "cluster" (ONE
|
|
114
|
+
* grouped layout: each group pulled to its own centre, run until it settles, then stopped) or null (no layout message at all,
|
|
115
|
+
* nothing moves). Owner 2026-10-22: "relayout once then not relayout dynamically after that".
|
|
116
|
+
* - "build" (a new node set): cluster off, "global". Cluster on: null when the clustered layout already settled on this surface
|
|
117
|
+
* (`clustered`) and some node of the new set has a position here (`warm`: in the cache or under a cached parent), so the old
|
|
118
|
+
* docs stay and only the new ones are placed beside their group; otherwise (a first render, a disjoint set, or a clustered
|
|
119
|
+
* layout still running) "cluster".
|
|
120
|
+
* - "toggle": cluster turned on, "cluster"; turned off, null (nothing moves; Resume / Reheat offer the plain layout again).
|
|
121
|
+
* - "relayout" (the Reheat button): "cluster" with cluster on, else "global".
|
|
122
|
+
* - anything else (hover, focus, find, legend filter, resize, a same-topology refresh, a slider): null. */
|
|
123
|
+
export function layout2d({ event, cluster, clustered = false, warm = false }) {
|
|
124
|
+
if (event === "build") return !cluster ? "global" : clustered && warm ? null : "cluster";
|
|
125
|
+
if (event === "toggle") return cluster ? "cluster" : null;
|
|
126
|
+
if (event === "relayout") return cluster ? "cluster" : "global";
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// WIKI-GRAPH-DRIFT-LONG-EDGES-1: which containment pairs (hub -> child, graphLevels `contains`) are layout springs and which are
|
|
2
|
+
// drawn, one rule for both canvases and both workers' callers.
|
|
3
|
+
//
|
|
4
|
+
// Measured (units/WIKI-GRAPH-DRIFT-LONG-EDGES-1/report.md, owner shape: a "units" hub with 1500 children, four small hubs, 1166
|
|
5
|
+
// real links): every containment pair was a spring at the flat-link strength and a dashed line. The giant hub's 1500 springs
|
|
6
|
+
// pulled every child onto one shell round it (3D R90 219 with them, 358 without) and its spokes were 0.8 R90 long, so the canvas
|
|
7
|
+
// showed hundreds of dashed lines across the whole graph plus concentric rings. No spring setting makes 1500 spokes short.
|
|
8
|
+
// So: a hub with more than HUB_SPRING_MAX children has no springs (its children are laid out by their own links) and draws its
|
|
9
|
+
// lines only while it, or the child at the other end, is hovered or focused. A smaller hub keeps both: its children sit round it
|
|
10
|
+
// (spoke median about one real-link median) and the lines read as "these are mine".
|
|
11
|
+
export const HUB_SPRING_MAX = 50;
|
|
12
|
+
|
|
13
|
+
/** Children per hub index over the flat pairs `cp` ([hub0, child0, hub1, child1, ...]). */
|
|
14
|
+
export function hubCounts(cp) {
|
|
15
|
+
const out = new Map();
|
|
16
|
+
for (let e = 0; e < cp.length; e += 2) out.set(cp[e], (out.get(cp[e]) || 0) + 1);
|
|
17
|
+
return out;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const big = (counts, h) => (counts.get(h) || 0) > HUB_SPRING_MAX;
|
|
21
|
+
|
|
22
|
+
/** The pairs of `cp` that are layout springs: those of a hub with at most HUB_SPRING_MAX children. */
|
|
23
|
+
export function springPairs(cp, counts = hubCounts(cp)) {
|
|
24
|
+
const out = [];
|
|
25
|
+
for (let e = 0; e < cp.length; e += 2) if (!big(counts, cp[e])) out.push(cp[e], cp[e + 1]);
|
|
26
|
+
return Uint32Array.from(out);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The pairs of `cp` drawn as dashed lines: a small hub's always, a giant hub's only when an end is in `active` (node indices:
|
|
30
|
+
* the hover, the focus; negative ones are ignored). */
|
|
31
|
+
export function drawnPairs(cp, counts = hubCounts(cp), active = []) {
|
|
32
|
+
const on = new Set(active.filter((i) => i >= 0)), out = [];
|
|
33
|
+
for (let e = 0; e < cp.length; e += 2) {
|
|
34
|
+
const a = cp[e], b = cp[e + 1];
|
|
35
|
+
if (!big(counts, a) || on.has(a) || on.has(b)) out.push(a, b);
|
|
36
|
+
}
|
|
37
|
+
return Uint32Array.from(out);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** drawnPairs for the overlay, which redraws every frame: recomputed only when `cp` or the active ends change (`memo` holds it). */
|
|
41
|
+
export function drawnMemo(memo, cp, active) {
|
|
42
|
+
const key = active.join(",");
|
|
43
|
+
if (memo.cp !== cp || memo.key !== key) { memo.cp = cp; memo.key = key; memo.out = drawnPairs(cp, hubCounts(cp), active); }
|
|
44
|
+
return memo.out;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The pairs a layout worker gets: the real links `pairs`, then the springs of the containment pairs `cp` (springPairs). */
|
|
48
|
+
export function layoutPairs(pairs, cp) {
|
|
49
|
+
const sp = springPairs(cp);
|
|
50
|
+
return sp.length ? Uint32Array.from([...pairs, ...sp]) : pairs;
|
|
51
|
+
}
|