@smartmemory/wiki-components 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/README.md +23 -3
  2. package/package.json +26 -4
  3. package/src/Ledger.jsx +47 -0
  4. package/src/data-client.d.ts +40 -1
  5. package/src/data-client.jsx +5 -0
  6. package/src/graph/components/wiki/DocCard.jsx +46 -0
  7. package/src/graph/components/wiki/GraphBreadcrumb.jsx +23 -0
  8. package/src/graph/components/wiki/GraphCanvas.jsx +663 -0
  9. package/src/graph/components/wiki/GraphCanvas3D.jsx +1366 -0
  10. package/src/graph/components/wiki/GraphDocPane.jsx +56 -0
  11. package/src/graph/components/wiki/GraphLegend.jsx +119 -0
  12. package/src/graph/components/wiki/GraphLevelPicker.jsx +35 -0
  13. package/src/graph/components/wiki/graph/FilterChips.jsx +45 -0
  14. package/src/graph/components/wiki/graph/Omnibox.jsx +74 -0
  15. package/src/graph/components/wiki/graph/badges.js +46 -0
  16. package/src/graph/components/wiki/graph/bubbles3d.js +95 -0
  17. package/src/graph/components/wiki/graph/canvasBase.js +138 -0
  18. package/src/graph/components/wiki/graph/containment.js +49 -0
  19. package/src/graph/components/wiki/graph/overlay.js +134 -0
  20. package/src/graph/components/wiki/graph/pixiBase.js +210 -0
  21. package/src/graph/components/wiki/graph/threeBase.js +231 -0
  22. package/src/graph/hooks/useDebounced.js +11 -0
  23. package/src/graph/host.jsx +56 -0
  24. package/src/graph/index.d.ts +26 -0
  25. package/src/graph/index.js +4 -0
  26. package/src/graph/lib/areaFamilies.js +32 -0
  27. package/src/graph/lib/camFollow.js +30 -0
  28. package/src/graph/lib/canvasPort.js +93 -0
  29. package/src/graph/lib/cardVisibility.js +58 -0
  30. package/src/graph/lib/docCard.js +28 -0
  31. package/src/graph/lib/forceWorker.js +123 -0
  32. package/src/graph/lib/forceWorker3d.js +136 -0
  33. package/src/graph/lib/graph3d.js +330 -0
  34. package/src/graph/lib/graphArrows.js +108 -0
  35. package/src/graph/lib/graphExplore.js +546 -0
  36. package/src/graph/lib/graphFind.js +46 -0
  37. package/src/graph/lib/graphLasso.js +44 -0
  38. package/src/graph/lib/graphLevels.js +367 -0
  39. package/src/graph/lib/graphOrient.js +145 -0
  40. package/src/graph/lib/graphPath.js +86 -0
  41. package/src/graph/lib/graphPin.js +48 -0
  42. package/src/graph/lib/graphPress.js +27 -0
  43. package/src/graph/lib/graphShell.js +296 -0
  44. package/src/graph/lib/graphTheme.js +50 -0
  45. package/src/graph/lib/graphView.js +343 -0
  46. package/src/graph/lib/graphViews.js +103 -0
  47. package/src/graph/lib/groupBubbles.js +128 -0
  48. package/src/graph/lib/hubLines.js +51 -0
  49. package/src/graph/lib/idleTurn.js +31 -0
  50. package/src/graph/lib/labelDepth.js +59 -0
  51. package/src/graph/lib/layoutClient.js +69 -0
  52. package/src/graph/lib/layoutEngine.js +184 -0
  53. package/src/graph/lib/linkWeight.js +67 -0
  54. package/src/graph/lib/orphanPull.js +10 -0
  55. package/src/graph/lib/perf.js +14 -0
  56. package/src/graph/lib/reducedMotion.js +36 -0
  57. package/src/graph/lib/robustFit.js +62 -0
  58. package/src/graph/lib/wikiGraph.js +298 -0
  59. package/src/graph/pages/GraphPage.jsx +703 -0
  60. package/src/index.d.ts +11 -2
  61. package/src/index.js +4 -1
  62. package/src/lib.js +3 -0
  63. package/src/parts/LedgerTimeline.jsx +20 -0
  64. package/src/parts/Markdown.jsx +22 -0
@@ -0,0 +1,367 @@
1
+ // WIKI-GRAPH-LEVELS-1 c1: the level / expand data model for the graph. Pure functions (no DOM, no React, no Pixi, no
2
+ // coordinates), so the 2D page and the later 3D renderer (WIKI-GRAPH-3D-1) share one definition of what is visible.
3
+ //
4
+ // Input is the flat graph `buildGraph` (wikiGraph.js) returns: doc nodes `{id, folder, color, ...}` and links `{source, target}`.
5
+ // A level is the set of expanded folder paths and nothing else. The empty set is level 0: one node per top-level folder.
6
+ // Expanding a folder replaces it by its children (subfolders as folder nodes, docs directly in it as doc nodes).
7
+ // The root (docs whose folder is "") is a virtual folder with path "" and id `folder:`: collapsed at level 0 like any other,
8
+ // expanded by `expand(set, "")`. It is a sibling of the top-level folders, not their parent.
9
+
10
+ /** Id prefix of a folder node. Doc ids are used as they are, so a doc id that starts with this prefix would collide. */
11
+ export const FOLDER_PREFIX = "folder:";
12
+ const SEP = "\u0000";
13
+ // The shared "other" grey (wikiGraph OTHER, kept here so this module stays import-free; the folder-colour test pins the two).
14
+ export const OTHER_COLOUR = "#7f8ea5";
15
+ const ROOT_LABEL = "(root)";
16
+
17
+ export const folderId = (path) => FOLDER_PREFIX + path;
18
+ export const isFolderId = (id) => typeof id === "string" && id.startsWith(FOLDER_PREFIX);
19
+ /** The folder path of a folder node id (`folder:docs/core` gives "docs/core"). */
20
+ export const folderPathOf = (id) => id.slice(FOLDER_PREFIX.length);
21
+
22
+ const segmentsOf = (path) => String(path || "").split("/").filter(Boolean);
23
+ const cleanPath = (path) => segmentsOf(path).join("/");
24
+ const parentPath = (path) => { const s = segmentsOf(path); return s.length > 1 ? s.slice(0, -1).join("/") : null; };
25
+
26
+ /**
27
+ * @typedef {{id: string, kind: "doc"|"folder", parent: string|null, degree: number, [k: string]: any}} LevelNode
28
+ * Doc nodes are copies of the input node plus `kind: "doc"`. Folder nodes carry `{path, label, size, depth, color}`.
29
+ * `parent` is the id of the folder node this node sits under once that folder is expanded (null for top-level folders),
30
+ * so a renderer can start new children at their parent's position when animating an expand.
31
+ * `degree` is the summed weight of the node's links in the view.
32
+ * @typedef {{source: string, target: string, weight: number, ab: number, ba: number}} LevelLink one per node pair
33
+ * (`source` sorts before `target`). `weight` = `ab` + `ba`: `ab` counts the flat links running source -> target, `ba` those
34
+ * running target -> source, so a renderer can draw arrowheads (WIKI-GRAPH-ARROWS-1).
35
+ */
36
+
37
+ /**
38
+ * The view graph for a level.
39
+ * @param {{nodes: object[], links: {source: string, target: string}[]}} graph the flat graph (`buildGraph` output)
40
+ * @param {Set<string>|string[]} [expanded] expanded folder paths; empty is level 0
41
+ * @param {{visible?: (docId: string) => boolean}} [opts] applied to docs BEFORE aggregation (legend hide set, local N-hop)
42
+ * @returns {{nodes: LevelNode[], links: LevelLink[]}}
43
+ */
44
+ export function buildLevelGraph(graph, expanded = new Set(), { visible } = {}) {
45
+ const open = expanded instanceof Set ? expanded : new Set([...expanded].map(cleanPath));
46
+ // rep of a folder: the folder node id a doc in it collapses into, or null when every ancestor is expanded (the doc shows).
47
+ const repOfFolder = new Map();
48
+ const repFor = (folder) => {
49
+ let r = repOfFolder.get(folder);
50
+ if (r !== undefined) return r;
51
+ const segs = segmentsOf(folder);
52
+ if (!segs.length) r = open.has("") ? null : folderId("");
53
+ else {
54
+ r = null;
55
+ let path = "";
56
+ for (let i = 0; i < segs.length; i++) {
57
+ path = i ? path + "/" + segs[i] : segs[i];
58
+ if (!open.has(path)) { r = folderId(path); break; }
59
+ }
60
+ }
61
+ repOfFolder.set(folder, r);
62
+ return r;
63
+ };
64
+
65
+ const repOfDoc = new Map(); // doc id -> view node id, for visible docs only
66
+ const nodes = [];
67
+ const folders = new Map(); // folder node id -> node
68
+ const colours = new Map(); // folder node id -> Map(colour -> count)
69
+ for (const n of graph.nodes) {
70
+ if (visible && !visible(n.id)) continue;
71
+ const rep = repFor(n.folder || "");
72
+ if (rep === null) {
73
+ repOfDoc.set(n.id, n.id);
74
+ nodes.push({ ...n, kind: "doc", parent: folderId(cleanPath(n.folder)), degree: 0 });
75
+ continue;
76
+ }
77
+ repOfDoc.set(n.id, rep);
78
+ let f = folders.get(rep);
79
+ if (!f) {
80
+ const path = folderPathOf(rep), segs = segmentsOf(path);
81
+ f = { id: rep, kind: "folder", path, label: segs.length ? segs[segs.length - 1] : ROOT_LABEL, size: 0, depth: segs.length, parent: null, color: undefined, degree: 0 };
82
+ const pp = parentPath(path);
83
+ if (pp !== null) f.parent = folderId(pp);
84
+ folders.set(rep, f);
85
+ colours.set(rep, new Map());
86
+ }
87
+ f.size++;
88
+ if (n.color !== undefined) { const c = colours.get(rep); c.set(n.color, (c.get(n.color) || 0) + 1); }
89
+ }
90
+ // WIKI-GRAPH-P0-LEVEL0-ZOOM-1: a folder takes the most common NAMED colour of its docs (its leading area, say), so it
91
+ // matches a legend row; the shared "other" grey only when none of its docs has one. Grey used to win whenever a folder's
92
+ // docs were spread over many small groups that all share the grey (5 of 7 top folders on the bench fixture).
93
+ for (const [id, f] of folders) {
94
+ let best = 0;
95
+ for (const [c, k] of colours.get(id)) if (c !== OTHER_COLOUR && k > best) { best = k; f.color = c; }
96
+ if (f.color === undefined && colours.get(id).has(OTHER_COLOUR)) f.color = OTHER_COLOUR;
97
+ }
98
+ const out = [...folders.values(), ...nodes];
99
+
100
+ const byId = new Map(out.map((n) => [n.id, n]));
101
+ const agg = new Map();
102
+ for (const l of graph.links) {
103
+ const a = repOfDoc.get(l.source), b = repOfDoc.get(l.target);
104
+ if (a === undefined || b === undefined || a === b) continue;
105
+ const key = a < b ? a + SEP + b : b + SEP + a;
106
+ const fwd = a < b; // runs source -> target of the aggregated link
107
+ const hit = agg.get(key);
108
+ if (hit) { hit.weight++; if (fwd) hit.ab++; else hit.ba++; }
109
+ else agg.set(key, { source: fwd ? a : b, target: fwd ? b : a, weight: 1, ab: fwd ? 1 : 0, ba: fwd ? 0 : 1 });
110
+ }
111
+ const links = [...agg.values()];
112
+ for (const l of links) { byId.get(l.source).degree += l.weight; byId.get(l.target).degree += l.weight; }
113
+ return { nodes: out, links };
114
+ }
115
+
116
+ /** "Show all docs": today's flat graph. The same object back when nothing is filtered, otherwise the visible docs and the
117
+ * links between them (original node and link objects, no `kind` or `weight`). */
118
+ export function showAll(graph, visible) {
119
+ if (!visible) return graph;
120
+ const keep = new Set();
121
+ const nodes = graph.nodes.filter((n) => visible(n.id) && keep.add(n.id));
122
+ return { ...graph, nodes, links: graph.links.filter((l) => keep.has(l.source) && keep.has(l.target)) };
123
+ }
124
+
125
+ /** A new expanded set with `path` added. Ancestors are not added: a folder under a collapsed ancestor stays hidden until
126
+ * the ancestor is expanded too (`buildLevelGraph` walks top-down). */
127
+ export function expand(expanded, path) {
128
+ return new Set([...expanded, cleanPath(path)]);
129
+ }
130
+
131
+ /** A new expanded set without `path` and without every expanded descendant of it. */
132
+ export function collapse(expanded, path) {
133
+ const p = cleanPath(path);
134
+ const under = p ? p + "/" : null; // the root has no descendants
135
+ return new Set([...expanded].filter((e) => e !== p && !(under && e.startsWith(under))));
136
+ }
137
+
138
+ /** The path `upOne` would collapse: the deepest expanded path (the most recently expanded among equals), or null. */
139
+ export function currentLevel(expanded) {
140
+ let best = null, depth = -1;
141
+ for (const e of expanded) { const d = segmentsOf(e).length; if (d >= depth) { depth = d; best = e; } }
142
+ return best;
143
+ }
144
+
145
+ /** Up one level: collapse the deepest expanded path. */
146
+ export function upOne(expanded) {
147
+ const best = currentLevel(expanded);
148
+ return best === null ? new Set(expanded) : collapse(expanded, best);
149
+ }
150
+
151
+ const MAX_LEVELS = 200, MAX_LEVEL_PATH = 512;
152
+
153
+ /** The `lv=` URL value: expanded paths, each percent-encoded, comma separated; the root folder is a bare `/`. */
154
+ export const formatLevels = (expanded) => [...expanded].sort().map((p) => (p === "" ? "/" : encodeURIComponent(p))).join(",");
155
+
156
+ /** Parse `lv=` (never trusted): bounded, each path decoded (a malformed one is dropped) and cleaned, and only folders that
157
+ * exist (`known`: every folder path of the graph, "" for the root) whose ancestors are all expanded too are kept, so a
158
+ * hand-typed value can only open real folders. `base` (WIKI-GRAPH-LEVELS-AUTOEXPAND-1) is the set the page opens already
159
+ * expanded: its paths count as expanded ancestors but are not part of the result. */
160
+ export function parseLevels(value, known, base) {
161
+ const raw = new Set();
162
+ for (const part of String(value || "").split(",").slice(0, MAX_LEVELS)) {
163
+ if (!part || part.length > MAX_LEVEL_PATH) continue;
164
+ let path;
165
+ try { path = decodeURIComponent(part); } catch { continue; }
166
+ raw.add(cleanPath(path));
167
+ }
168
+ const out = new Set();
169
+ const shallowFirst = [...raw].sort((a, b) => segmentsOf(a).length - segmentsOf(b).length);
170
+ for (const p of shallowFirst) {
171
+ const up = parentPath(p);
172
+ if ((!known || known.has(p)) && (up === null || out.has(up) || (base && base.has(up)))) out.add(p);
173
+ }
174
+ return out;
175
+ }
176
+
177
+ /** Every folder path the docs sit in or under, plus "" when a doc is in the root: what `parseLevels` accepts. */
178
+ export function folderPaths(graph) {
179
+ const out = new Set();
180
+ for (const n of graph.nodes) {
181
+ const segs = segmentsOf(n.folder);
182
+ if (!segs.length) out.add("");
183
+ for (let i = 1; i <= segs.length; i++) out.add(segs.slice(0, i).join("/"));
184
+ }
185
+ return out;
186
+ }
187
+
188
+ /** The ancestor chain of a folder, top-level first, ending with the folder itself: `a/b` gives `a`, `a/b`.
189
+ * Does not include an "all" entry: the page adds it. The root gives one entry with path "". */
190
+ export function breadcrumb(path) {
191
+ const segs = segmentsOf(path);
192
+ if (!segs.length) return [{ path: "", label: ROOT_LABEL, id: folderId("") }];
193
+ return segs.map((label, i) => { const p = segs.slice(0, i + 1).join("/"); return { path: p, label, id: folderId(p) }; });
194
+ }
195
+
196
+ /** The page's base level (WIKI-GRAPH-LEVELS-AUTOEXPAND-1): while the view is ONE folder node, expand it, so the page never
197
+ * opens on a single dot. Stops at more than one node, at a lone doc, or at an empty view; bounded by the folder count, so it
198
+ * cannot loop. Not part of `lv=`: the URL holds the reader's own expansions on top of this set.
199
+ * @param {{nodes: object[], links: object[]}} graph the flat graph
200
+ * @param {(docId: string) => boolean} [visible] the same predicate the page's level view uses
201
+ * @returns {Set<string>} expanded folder paths (empty when level 0 already has several nodes) */
202
+ export function autoBase(graph, visible) {
203
+ let base = new Set();
204
+ for (let i = folderPaths(graph).size + 1; i > 0; i--) {
205
+ const { nodes } = buildLevelGraph(graph, base, { visible });
206
+ if (nodes.length !== 1 || nodes[0].kind !== "folder") break;
207
+ base = expand(base, nodes[0].path);
208
+ }
209
+ return base;
210
+ }
211
+
212
+ // WIKI-GRAPH-LEVELS-3D-1: depth ranges, hub folders and containment edges, on top of the model above (nothing above changes).
213
+ //
214
+ // A LEVEL is how deep a thing sits below what the page opens on. Level 1 is the page's opening view: the top-level folders
215
+ // (and the "(root)" folder of the docs at the top), or, when the workspace opens auto-expanded (`autoBase`), the children of
216
+ // the deepest base folder. A folder's sub-folders and the docs directly in it are one level below it. "Levels 1-2" draws every
217
+ // level-1 folder as a HUB (it stays on screen, with a dashed containment edge to each of its drawn children) and every level-2
218
+ // folder or doc as its own node, with the docs below level 2 collapsed into their level-2 folder. "Levels 2-3" is the same one
219
+ // level down: level-1 folders are opened but not drawn. A range is [from, to] with 1 <= from <= to <= maxLevel.
220
+
221
+ /** How many levels the base (autoBase) opened: the deepest base path's segment count (0 without a base). */
222
+ export function baseOffset(base) {
223
+ let d = 0;
224
+ for (const p of base || []) d = Math.max(d, segmentsOf(p).length);
225
+ return d;
226
+ }
227
+
228
+ /** The level of a folder path (see above). The root folder ("") is level 1, a sibling of the top-level folders. */
229
+ export const levelOf = (path, offset = 0) => (cleanPath(path) === "" ? 1 : segmentsOf(path).length - offset);
230
+
231
+ /** The deepest level any doc sits at (a doc is one level below its folder), 0 for an empty workspace. */
232
+ export function maxLevel(graph, offset = 0) {
233
+ let m = 0;
234
+ for (const n of graph.nodes) m = Math.max(m, levelOf(n.folder || "", offset) + 1);
235
+ return m;
236
+ }
237
+
238
+ /** The expanded set that shows levels `from`..`to`: the base plus every folder above level `to` (levels 1..to-1), so every
239
+ * level-`to` folder is drawn collapsed with its docs inside it. `from` does not change what is expanded, only what is drawn
240
+ * (`hubsFor`). `to` past the deepest level opens everything; `to` < 1 is level 1. */
241
+ export function expandToDepth(graph, from, to, { base = null } = {}) {
242
+ const offset = baseOffset(base), out = new Set(base || []);
243
+ const top = Math.max(1, Math.round(Number(to)) || 1);
244
+ for (const p of folderPaths(graph)) {
245
+ const l = levelOf(p, offset);
246
+ if (l >= 1 && l < top) out.add(p);
247
+ }
248
+ return out;
249
+ }
250
+
251
+ /** The expanded folders drawn as hubs: every expanded path that is not in the base and sits at level `from` or deeper.
252
+ * Shallower expanded folders are opened but not drawn (the range starts below them). */
253
+ export function hubsFor(expanded, from = 1, { base = null } = {}) {
254
+ const offset = baseOffset(base), out = new Set();
255
+ for (const p of expanded) if (!(base && base.has(p)) && levelOf(p, offset) >= from) out.add(p);
256
+ return out;
257
+ }
258
+
259
+ /** `buildLevelGraph` plus hubs: every path in `hubs` that is expanded stays drawn as a folder node (`hub: true`, size = the
260
+ * visible docs under it, majority colour), and `contains` holds one containment edge `{source: hub id, target: child id}`
261
+ * per drawn node whose parent is a hub. Containment edges are not links: they carry no weight, add no degree and never
262
+ * aggregate; `links` is exactly `buildLevelGraph`'s. A hub has no link edges (its docs are drawn on their own). */
263
+ export function buildLevelView(graph, expanded = new Set(), { visible, hubs } = {}) {
264
+ const lg = buildLevelGraph(graph, expanded, { visible });
265
+ const open = expanded instanceof Set ? expanded : new Set([...expanded].map(cleanPath));
266
+ const want = new Set([...(hubs || [])].map(cleanPath).filter((p) => open.has(p)));
267
+ if (!want.size) return { ...lg, contains: [] };
268
+ const made = new Map(), colours = new Map();
269
+ for (const n of graph.nodes) {
270
+ if (visible && !visible(n.id)) continue;
271
+ const segs = segmentsOf(n.folder);
272
+ const chain = segs.length ? segs.map((_, i) => segs.slice(0, i + 1).join("/")) : [""];
273
+ for (const p of chain) {
274
+ if (!want.has(p)) continue;
275
+ let h = made.get(p);
276
+ if (!h) {
277
+ const ps = segmentsOf(p), pp = parentPath(p);
278
+ h = { id: folderId(p), kind: "folder", hub: true, path: p, label: ps.length ? ps[ps.length - 1] : ROOT_LABEL, size: 0, depth: ps.length,
279
+ parent: pp === null ? null : folderId(pp), color: undefined, degree: 0 };
280
+ made.set(p, h);
281
+ colours.set(p, new Map());
282
+ }
283
+ h.size++;
284
+ if (n.color !== undefined) { const c = colours.get(p); c.set(n.color, (c.get(n.color) || 0) + 1); }
285
+ }
286
+ }
287
+ for (const [p, h] of made) {
288
+ let best = 0;
289
+ for (const [c, k] of colours.get(p)) if (k > best) { best = k; h.color = c; }
290
+ }
291
+ const nodes = [...made.values(), ...lg.nodes];
292
+ const hubIds = new Set([...made.values()].map((h) => h.id));
293
+ const contains = [];
294
+ for (const n of nodes) if (n.parent && hubIds.has(n.parent)) contains.push({ source: n.parent, target: n.id });
295
+ return { nodes, links: lg.links, contains };
296
+ }
297
+
298
+ /** The view nodes a drill into folder node `id` frames: the folder itself (when drawn, as a hub) and every drawn node whose
299
+ * parent it is. In the flat graph (All docs: doc nodes carry `folder` but no `parent`) it is every doc in that folder or under it.
300
+ * Returns node indices into `nodes`; empty when the folder has nothing drawn (the caller fits everything). */
301
+ export function drillTargets(nodes, id) {
302
+ const out = [];
303
+ if (!id) return out;
304
+ const path = isFolderId(id) ? folderPathOf(id) : null;
305
+ const under = (n) => path !== null && n.kind !== "folder" && n.parent === undefined && typeof n.folder === "string"
306
+ && (path === "" || cleanPath(n.folder) === path || cleanPath(n.folder).startsWith(path + "/"));
307
+ for (let i = 0; i < nodes.length; i++) if (nodes[i].id === id || nodes[i].parent === id || under(nodes[i])) out.push(i);
308
+ return out;
309
+ }
310
+
311
+ /** Collapse seeding: positions for folder nodes of `next` that were NOT drawn in `prev` but whose docs or sub-folders were,
312
+ * each at the centroid of those visible descendants' positions (`posOf(i)` gives prev node i's [x, y(, z)]), so collapsing
313
+ * pulls the children together where they were instead of jumping to a fresh seed. Returns Map(folder id -> position). */
314
+ export function collapseCentroids(prev, posOf, next) {
315
+ const had = new Set(prev.map((n) => n.id));
316
+ const want = new Map(); // folder path -> folder id, for new folders only
317
+ for (const n of next) if (n.kind === "folder" && !had.has(n.id)) want.set(n.path, n.id);
318
+ const sums = new Map();
319
+ if (!want.size) return sums;
320
+ for (let i = 0; i < prev.length; i++) {
321
+ const n = prev[i], segs = segmentsOf(n.kind === "folder" ? n.path : n.folder);
322
+ const chain = n.kind === "folder" ? segs.slice(0, -1) : segs; // a folder is under its ancestors, a doc under its folder too
323
+ const paths = chain.map((_, j) => chain.slice(0, j + 1).join("/"));
324
+ if (n.kind !== "folder" && !segs.length) paths.push(""); // a root doc sits under the root folder
325
+ const p = posOf(i);
326
+ if (!p || p.some((v) => !Number.isFinite(v))) continue;
327
+ for (const path of paths) {
328
+ const id = want.get(path);
329
+ if (id === undefined) continue;
330
+ let s = sums.get(id);
331
+ if (!s) sums.set(id, (s = { k: 0, v: new Array(p.length).fill(0) }));
332
+ s.k++;
333
+ for (let d = 0; d < p.length; d++) s.v[d] += p[d];
334
+ }
335
+ }
336
+ const out = new Map();
337
+ for (const [id, s] of sums) out.set(id, s.v.map((v) => v / s.k));
338
+ return out;
339
+ }
340
+
341
+ /** The `lr=` URL value of a range: "2" or "1-3". */
342
+ export const formatRange = (r) => (!r ? "" : r[0] === r[1] ? String(r[0]) : `${r[0]}-${r[1]}`);
343
+
344
+ /** Parse `lr=` (never trusted): "N" or "A-B", whole numbers, clamped to 1..max, swapped when reversed. Null when empty or
345
+ * malformed, or when there are no levels (max < 1): the page then uses its default. */
346
+ export function parseRange(value, max) {
347
+ if (!(max >= 1)) return null;
348
+ const m = /^\s*(\d{1,4})\s*(?:-\s*(\d{1,4})\s*)?$/.exec(String(value ?? ""));
349
+ if (!m) return null;
350
+ const clamp = (v) => Math.max(1, Math.min(max, Number(v)));
351
+ const a = clamp(m[1]), b = clamp(m[2] ?? m[1]);
352
+ return a <= b ? [a, b] : [b, a];
353
+ }
354
+
355
+ /** The picker's level chips: toggle level `k` in the shown range and snap the selection to the range that covers it. A
356
+ * click outside the range extends it to `k` (over a gap the levels between come too, `snapped`); a click on an end shrinks it
357
+ * by one; a click on a middle level would leave a gap, so the range stays (`snapped`). `snapped` tells the UI to say that
358
+ * levels show as a range. The last level never turns off. */
359
+ export function toggleLevel(range, k) {
360
+ const [a, b] = range;
361
+ if (k < a) return { range: [k, b], snapped: k < a - 1 }; // 1 picked with 3-4 on: 2 comes with it
362
+ if (k > b) return { range: [a, k], snapped: k > b + 1 };
363
+ if (a === b) return { range, snapped: false };
364
+ if (k === a) return { range: [a + 1, b], snapped: false };
365
+ if (k === b) return { range: [a, b - 1], snapped: false };
366
+ return { range, snapped: true };
367
+ }
@@ -0,0 +1,145 @@
1
+ // WIKI-GRAPH-EXPLORE-1c-UI round 4: the 3D camera's orientation as numbers a saved view can keep (round 5 reuses this codec).
2
+ // Orientation is relative to the fit, never absolute coordinates: azimuth and elevation in degrees round the orbit target, and
3
+ // `k`, the camera distance as a multiple of the fit distance, so the same view still frames the same way on a different graph size.
4
+ // Convention: offset = d * (cos el * sin az, sin el, cos el * cos az). az 0 / el 0 looks from +Z (the default view: the camera
5
+ // starts on +Z, three-base `camera.position.set(0, 0, 400)`); az +90 looks from +X; el +90 from +Y. Pure, no three.
6
+
7
+ export const DEFAULT_ORIENT = Object.freeze({ az: 0, el: 0, k: 1 });
8
+ /** Elevation clamp: OrbitControls allows the poles themselves, where `up` x view degenerates, so a snap stops one degree short. */
9
+ export const EL_MAX = 89;
10
+
11
+ const RAD = Math.PI / 180;
12
+ const num = (v) => typeof v === "number" && Number.isFinite(v);
13
+ const vec = (v) => Array.isArray(v) && v.length >= 3 && num(v[0]) && num(v[1]) && num(v[2]);
14
+
15
+ /** An angle wrapped into (-180, 180]. */
16
+ export function wrapAz(a) {
17
+ const w = ((((a + 180) % 360) + 360) % 360) - 180; // [-180, 180)
18
+ return w === -180 ? 180 : w;
19
+ }
20
+
21
+ /** Any partial or bad `{az, el, k}` made whole: a missing, non-finite or (for k) non-positive field takes its default, az is
22
+ * wrapped, el clamped to +-EL_MAX. Never NaN. */
23
+ export function cleanOrient(o) {
24
+ const src = o && typeof o === "object" ? o : {};
25
+ return {
26
+ az: num(src.az) ? wrapAz(src.az) : DEFAULT_ORIENT.az,
27
+ el: num(src.el) ? Math.max(-EL_MAX, Math.min(EL_MAX, src.el)) : DEFAULT_ORIENT.el,
28
+ k: num(src.k) && src.k > 0 ? src.k : DEFAULT_ORIENT.k,
29
+ };
30
+ }
31
+
32
+ /** `{az, el, k}` of a camera at `cameraPos` looking at `target`, with `fitDist` the fit distance. A bad input, a camera on its
33
+ * target or a non-positive fit distance gives the default orientation. */
34
+ export function orientationOf(target, cameraPos, fitDist) {
35
+ if (!vec(target) || !vec(cameraPos) || !num(fitDist) || fitDist <= 0) return { ...DEFAULT_ORIENT };
36
+ const dx = cameraPos[0] - target[0], dy = cameraPos[1] - target[1], dz = cameraPos[2] - target[2];
37
+ const d = Math.hypot(dx, dy, dz);
38
+ if (!(d > 1e-9)) return { ...DEFAULT_ORIENT };
39
+ return cleanOrient({ az: Math.atan2(dx, dz) / RAD, el: Math.asin(Math.max(-1, Math.min(1, dy / d))) / RAD, k: d / fitDist });
40
+ }
41
+
42
+ /** The camera position for orientation `o` round `target` at fit distance `fitDist`. Bad parts fall back to the default
43
+ * (target to the origin, fit distance to 1), so the result is always three finite numbers. */
44
+ export function cameraFor(target, o, fitDist) {
45
+ const t = vec(target) ? target : [0, 0, 0];
46
+ const f = num(fitDist) && fitDist > 0 ? fitDist : 1;
47
+ const { az, el, k } = cleanOrient(o);
48
+ const d = k * f, ce = Math.cos(el * RAD);
49
+ return [t[0] + d * ce * Math.sin(az * RAD), t[1] + d * Math.sin(el * RAD), t[2] + d * ce * Math.cos(az * RAD)];
50
+ }
51
+
52
+ /** The orientation that looks along a gizmo axis (`+x`, `-x`, `+y`, `-y`, `+z`, `-z`), or the front (`front`); `cur` is the
53
+ * current orientation. The Y axes keep the current azimuth (a pole has none: keeping it spares a spin). null for any other name. */
54
+ export const SNAPS = Object.freeze({
55
+ "+x": () => ({ az: 90, el: 0 }),
56
+ "-x": () => ({ az: -90, el: 0 }),
57
+ "+y": (cur) => ({ az: cur.az, el: EL_MAX }),
58
+ "-y": (cur) => ({ az: cur.az, el: -EL_MAX }),
59
+ "+z": () => ({ az: 0, el: 0 }),
60
+ "-z": () => ({ az: 180, el: 0 }),
61
+ front: () => ({ az: 0, el: 0 }),
62
+ });
63
+ export function snapOrient(name, cur) {
64
+ const f = Object.prototype.hasOwnProperty.call(SNAPS, name) ? SNAPS[name] : null;
65
+ if (!f) return null;
66
+ const c = cleanOrient(cur);
67
+ return { ...f(c), k: c.k }; // the distance (k) is never changed by a snap
68
+ }
69
+
70
+ /** The three world axes as the camera sees them: `right` and `up` are the screen axes (up is up on screen), `depth` > 0 is
71
+ * toward the viewer. Eye and target as for orientationOf; the camera's up is world +Y (what OrbitControls keeps). Each entry is
72
+ * `{name, sx, sy, depth}` for +x, -x, +y, -y, +z, -z. A camera on its target gives the default view's axes. */
73
+ export function gizmoAxes(target, cameraPos) {
74
+ let f = [0, 0, -1];
75
+ if (vec(target) && vec(cameraPos)) {
76
+ const d = [target[0] - cameraPos[0], target[1] - cameraPos[1], target[2] - cameraPos[2]], n = Math.hypot(d[0], d[1], d[2]);
77
+ if (n > 1e-9) f = [d[0] / n, d[1] / n, d[2] / n];
78
+ }
79
+ // right = forward x worldUp, normalised (a camera straight above or below: the x axis); up = right x forward
80
+ let r = [-f[2], 0, f[0]];
81
+ const rn = Math.hypot(r[0], r[1], r[2]);
82
+ r = rn > 1e-9 ? [r[0] / rn, 0, r[2] / rn] : [1, 0, 0];
83
+ const u = [r[1] * f[2] - r[2] * f[1], r[2] * f[0] - r[0] * f[2], r[0] * f[1] - r[1] * f[0]];
84
+ const out = [];
85
+ for (const [name, a] of [["+x", [1, 0, 0]], ["-x", [-1, 0, 0]], ["+y", [0, 1, 0]], ["-y", [0, -1, 0]], ["+z", [0, 0, 1]], ["-z", [0, 0, -1]]]) {
86
+ out.push({ name, sx: r[0] * a[0] + r[1] * a[1] + r[2] * a[2], sy: u[0] * a[0] + u[1] * a[1] + u[2] * a[2], depth: -(f[0] * a[0] + f[1] * a[1] + f[2] * a[2]) });
87
+ }
88
+ return out;
89
+ }
90
+
91
+ /** A point on the turn from camera position `p0` to `p1` round `t`: the direction slerps and the distance goes linearly, so a
92
+ * half turn never passes through the target (a straight lerp from +Z to -Z would). `u` in [0, 1]. An antipodal pair turns
93
+ * about the world Y axis (about X when it sits on Y). Bad input returns `p1`. */
94
+ export function arcPoint(t, p0, p1, u) {
95
+ if (!vec(t) || !vec(p0) || !vec(p1) || !num(u)) return vec(p1) ? [p1[0], p1[1], p1[2]] : [0, 0, 0];
96
+ const a = [p0[0] - t[0], p0[1] - t[1], p0[2] - t[2]], b = [p1[0] - t[0], p1[1] - t[1], p1[2] - t[2]];
97
+ const da = Math.hypot(a[0], a[1], a[2]), db = Math.hypot(b[0], b[1], b[2]);
98
+ if (da < 1e-9 || db < 1e-9) return [t[0] + (b[0] - a[0]) * u + a[0], t[1] + (b[1] - a[1]) * u + a[1], t[2] + (b[2] - a[2]) * u + a[2]];
99
+ const ua = a.map((v) => v / da), ub = b.map((v) => v / db);
100
+ const dot = Math.max(-1, Math.min(1, ua[0] * ub[0] + ua[1] * ub[1] + ua[2] * ub[2]));
101
+ const ang = Math.acos(dot);
102
+ let dir;
103
+ if (ang < 1e-6) dir = ub;
104
+ else {
105
+ let axis;
106
+ if (Math.PI - ang < 1e-6) { // antipodal: any axis perpendicular to ua will do, Y unless ua is on it
107
+ const ay = Math.abs(ua[1]) > 0.999 ? [1, 0, 0] : [0, 1, 0];
108
+ axis = [ua[1] * ay[2] - ua[2] * ay[1], ua[2] * ay[0] - ua[0] * ay[2], ua[0] * ay[1] - ua[1] * ay[0]];
109
+ const n = Math.hypot(axis[0], axis[1], axis[2]);
110
+ axis = axis.map((v) => v / n);
111
+ // rotate ua about `axis` by pi*u: ua*cos + (axis x ua)*sin (axis is perpendicular to ua, so no parallel term)
112
+ const th = Math.PI * u, c = Math.cos(th), s = Math.sin(th);
113
+ const cr = [axis[1] * ua[2] - axis[2] * ua[1], axis[2] * ua[0] - axis[0] * ua[2], axis[0] * ua[1] - axis[1] * ua[0]];
114
+ dir = [ua[0] * c + cr[0] * s, ua[1] * c + cr[1] * s, ua[2] * c + cr[2] * s];
115
+ } else {
116
+ const s = Math.sin(ang), wa = Math.sin((1 - u) * ang) / s, wb = Math.sin(u * ang) / s;
117
+ dir = [ua[0] * wa + ub[0] * wb, ua[1] * wa + ub[1] * wb, ua[2] * wa + ub[2] * wb];
118
+ }
119
+ }
120
+ const d = da + (db - da) * u;
121
+ return [t[0] + dir[0] * d, t[1] + dir[1] * d, t[2] + dir[2] * d];
122
+ }
123
+
124
+ /** Keyboard step (WIKI-GRAPH-3D-POLISH-1 r1). Shift+Left/Right turn the azimuth, Shift+Up/Down tilt the elevation, by STEP_DEG
125
+ * (el clamped to +-EL_MAX); `+`/`=` zoom in and `-`/`_` out by ZOOM_STEP (k clamped to K_MIN..K_MAX); `0` is the front view at
126
+ * the current k (as the gizmo's Front). `shift` is the Shift state: only the arrows need it. Always relative to `o` (an
127
+ * orientation about the orbit target), never a camera position. Unknown key: null. `o` goes through cleanOrient first. */
128
+ export const STEP_DEG = 15;
129
+ export const ZOOM_STEP = 0.8;
130
+ export const K_MIN = 0.2;
131
+ export const K_MAX = 5;
132
+ export function stepOrient(o, key, shift = false) {
133
+ const c = cleanOrient(o);
134
+ const kz = (f) => Math.max(K_MIN, Math.min(K_MAX, c.k * f));
135
+ if (shift) {
136
+ if (key === "ArrowLeft") return cleanOrient({ ...c, az: c.az - STEP_DEG });
137
+ if (key === "ArrowRight") return cleanOrient({ ...c, az: c.az + STEP_DEG });
138
+ if (key === "ArrowUp") return cleanOrient({ ...c, el: c.el + STEP_DEG });
139
+ if (key === "ArrowDown") return cleanOrient({ ...c, el: c.el - STEP_DEG });
140
+ }
141
+ if (key === "+" || key === "=") return { ...c, k: kz(ZOOM_STEP) };
142
+ if (key === "-" || key === "_") return { ...c, k: kz(1 / ZOOM_STEP) };
143
+ if (key === "0") return { ...DEFAULT_ORIENT, k: c.k };
144
+ return null;
145
+ }
@@ -0,0 +1,86 @@
1
+ // WIKI-GRAPH-EXPLORE-1c-UI round 3: the path lens, pure half (no DOM, no React, no canvas). A path lens (`@path:from:to` in the `x`
2
+ // param) marks the shortest undirected path between two docs and never removes anything (design WIKI-GRAPH-EXPLORE-1 §3, §6).
3
+ // The page lights the path's docs and links through the canvas `marks` (`lit`, `links`) and dims the rest at the dim level.
4
+ import { applyLenses, normaliseLens } from "./graphExplore";
5
+ import { drawnFor, findMatches } from "./graphFind";
6
+
7
+ /** The state's path lens (the last one when the URL holds several), or null. */
8
+ export const pathLens = (state) => [...(state?.lenses || [])].reverse().find((l) => l.lens === "path") || null;
9
+
10
+ /** Set the path lens: it replaces any earlier path lens, keeps the other lenses and every step. Returns the same state when nothing
11
+ * changes (an invalid pair, or the same pair again). */
12
+ export function setPathLens(state, from, to) {
13
+ const lens = normaliseLens({ lens: "path", from, to });
14
+ if (!lens) return state;
15
+ const cur = pathLens(state);
16
+ if (cur && cur.from === lens.from && cur.to === lens.to && state.lenses.filter((l) => l.lens === "path").length === 1) return state;
17
+ return { ...state, lenses: [...state.lenses.filter((l) => l.lens !== "path"), lens] };
18
+ }
19
+
20
+ /** Remove lens `i` (out of range: the same state). */
21
+ export const removeLens = (state, i) => (i < 0 || i >= state.lenses.length ? state : { ...state, lenses: state.lenses.filter((_, j) => j !== i) });
22
+
23
+ /** Drop every path lens (the Esc step). Returns the same state when there is none. */
24
+ export const clearPathLens = (state) => (state.lenses.some((l) => l.lens === "path") ? { ...state, lenses: state.lenses.filter((l) => l.lens !== "path") } : state);
25
+
26
+ const norm = (t) => String(t || "").trim().toLowerCase();
27
+
28
+ /** The doc a typed first argument means: the exact title (case-insensitive) when a doc has one, else the top title match, else null.
29
+ * `nodes`: the graph's docs, `visible`: findMatches' predicate. */
30
+ export function pickDoc(nodes, text, visible) {
31
+ const q = norm(text);
32
+ if (!q) return null;
33
+ const exact = nodes.find((n) => norm(n.title) === q && (!visible || visible(n.id)));
34
+ return exact || findMatches(nodes, text, visible)[0] || null;
35
+ }
36
+
37
+ /** Read the text after `path`: `{ from, left, right, hasTo }`. The first ` to ` whose two sides both read as docs splits them (an exact
38
+ * title wins over a prefix match, so a title that holds "to" still works); `from` is that doc (null when none), `right` the second
39
+ * argument ("" while it is not typed). With no ` to ` the whole text is the first argument. */
40
+ export function splitPathArgs(rest, nodes, visible) {
41
+ const text = String(rest || "").trim();
42
+ const cands = [...text.matchAll(/\s+to(?=\s|$)/gi)].map((m) => ({ left: text.slice(0, m.index).trim(), right: text.slice(m.index + m[0].length).trim() }));
43
+ if (!cands.length) return { from: pickDoc(nodes, text, visible), left: text, right: "", hasTo: false };
44
+ const isExact = (c) => !!c.left && nodes.some((n) => norm(n.title) === norm(c.left) && (!visible || visible(n.id)));
45
+ const leftHit = (c) => !!c.left && findMatches(nodes, c.left, visible).length > 0;
46
+ const rightOk = (c) => !c.right || findMatches(nodes, c.right, visible).length > 0; // the second argument is empty (still typing) or names a doc
47
+ const any = cands.find((c) => isExact(c) && rightOk(c)) || cands.find((c) => leftHit(c) && rightOk(c)) || cands.find(isExact) || cands.find(leftHit) || cands[0];
48
+ return { from: pickDoc(nodes, any.left, visible), left: any.left, right: any.right, hasTo: true };
49
+ }
50
+
51
+ /** The drawn marks for a path (ordered doc ids, or null): `{ lit: Set<drawn id>, links: [a, b][], folders: string[] }`. Each path doc
52
+ * shows as itself when drawn, else as the folder that holds it (graphFind drawnFor); a doc nothing draws is skipped. `links` joins
53
+ * the drawn nodes of consecutive path docs (none for two docs that share a folder node). `folders`: the lit ids that are folders
54
+ * standing in for hidden docs. Null when there is no path or nothing on it is drawn. */
55
+ export function pathLit(path, drawnIds, byId) {
56
+ if (!path || !path.length || !drawnIds) return null;
57
+ const shown = path.map((id) => { const d = byId.get(id); return d ? drawnFor(d, drawnIds) : null; });
58
+ const lit = new Set(shown.filter((id) => id !== null));
59
+ if (!lit.size) return null;
60
+ const links = [], seen = new Set();
61
+ for (let i = 0; i + 1 < shown.length; i++) {
62
+ const a = shown[i], b = shown[i + 1];
63
+ if (a === null || b === null || a === b) continue;
64
+ const key = a < b ? `${a}\u0000${b}` : `${b}\u0000${a}`;
65
+ if (!seen.has(key)) { seen.add(key); links.push([a, b]); }
66
+ }
67
+ const onPath = new Set(path);
68
+ return { lit, links, folders: [...lit].filter((id) => !onPath.has(id)) };
69
+ }
70
+
71
+ /** What the page draws for the state's path lens: `{ lens, path, marks: { lit, links } | null, folders }`, or null with no path lens.
72
+ * `path` is the ordered ids (null: no path, nothing is dimmed). */
73
+ export function pathView(state, idx, drawnIds, byId) {
74
+ const lens = pathLens(state);
75
+ if (!lens || !idx) return null;
76
+ const path = applyLenses(idx, [lens]).path;
77
+ const lit = pathLit(path, drawnIds, byId);
78
+ return { lens, path, marks: lit ? { lit: lit.lit, links: lit.links } : null, folders: lit ? lit.folders : [] };
79
+ }
80
+
81
+ /** The line under the canvas when a path crosses a collapsed folder ("" when every path doc is drawn). `folders`: pathView's ids,
82
+ * `title(id)` names one. */
83
+ export function pathNote(folders, title) {
84
+ if (!folders.length) return "";
85
+ return `${folders.length === 1 ? "A folder" : `${folders.length} folders`} stand${folders.length === 1 ? "s" : ""} in for path docs: ${folders.map(title).join(", ")}. Open ${folders.length === 1 ? "it" : "them"} to see the docs.`;
86
+ }
@@ -0,0 +1,48 @@
1
+ // WIKI-GRAPH-EXPLORE-1c-UI round 2: drag-to-pin on the 3D surface (3D-UX-1 REPORT row "drag-to-pin": move in the camera-facing plane
2
+ // at the dot's depth, pin fx/fy/fz in the worker, pins per surface). The pure parts: which press arms a pin drag, when a drag has
3
+ // started, and the pointer-to-plane math. The canvas owns the pointer events, the worker owns the pin itself (forceWorker3d `pin`).
4
+
5
+ /** True when a press starts a pin drag: a plain left-button mouse press (no modifier: Shift is the lasso, Alt/Ctrl/Meta pan or menu,
6
+ * touch turns) that lands on a doc, on a canvas that was given an `onPin`. `e` = {pointerType, button, shiftKey, altKey, ctrlKey, metaKey}. */
7
+ export function pinArmed(e, onDot, enabled = true) {
8
+ if (!enabled || !onDot) return false;
9
+ if (e.pointerType === "touch") return false;
10
+ return (e.button ?? 0) === 0 && !e.shiftKey && !e.altKey && !e.ctrlKey && !e.metaKey;
11
+ }
12
+
13
+ /** The drag decision: a press that has moved more than `px` from where it went down is a drag, anything less stays a click. */
14
+ export const dragStarted = (x0, y0, x, y, px) => Math.hypot(x - x0, y - y0) > px;
15
+
16
+ /** The camera as the plane math wants it, from a three camera's `matrixWorld.elements` (column-major: right, up, back), its eye and
17
+ * `focal` (px per world unit at distance 1, threeBase frame()). */
18
+ export function cameraOf(elements, eye, focal) {
19
+ const e = elements;
20
+ return { eye: [eye[0], eye[1], eye[2]], right: [e[0], e[1], e[2]], up: [e[4], e[5], e[6]], forward: [-e[8], -e[9], -e[10]], focal };
21
+ }
22
+
23
+ /** The world point under screen px (x, y) on the plane facing the camera at view depth `depth` (distance along the view axis, the
24
+ * projected w of a dot). A `w` x `h` px surface, y down. Inverse of graph3d `project` for that plane. */
25
+ export function planePoint(cam, w, h, x, y, depth) {
26
+ const k = depth / cam.focal, a = (x - w / 2) * k, b = -(y - h / 2) * k;
27
+ return [0, 1, 2].map((i) => cam.eye[i] + cam.forward[i] * depth + cam.right[i] * a + cam.up[i] * b);
28
+ }
29
+
30
+ /** Where the dot goes for a pointer at (x, y): the plane point plus `offset`, the press-time gap between the dot and the plane
31
+ * point under the pointer (so the dot does not jump to the pointer, it follows it 1:1). */
32
+ export const pinTarget = (cam, w, h, x, y, depth, offset) => planePoint(cam, w, h, x, y, depth).map((v, i) => v + offset[i]);
33
+
34
+ /** The offset to carry through a drag that went down at (x, y) on a dot at world `p` and view depth `depth`. */
35
+ export const pinOffset = (cam, w, h, x, y, depth, p) => planePoint(cam, w, h, x, y, depth).map((v, i) => p[i] - v);
36
+
37
+ /** Write every pin (`pins`: Map id -> [x, y, z]) into `xyz` at its doc's index (`index`: Map id -> i); an id the surface no longer has
38
+ * is skipped. Returns how many it wrote. Used after a worker frame, which may carry a position older than the last pointer move. */
39
+ export function holdPins(xyz, pins, index) {
40
+ let n = 0;
41
+ for (const [id, p] of pins) {
42
+ const i = index.get(id);
43
+ if (i === undefined) continue;
44
+ xyz[3 * i] = p[0]; xyz[3 * i + 1] = p[1]; xyz[3 * i + 2] = p[2];
45
+ n++;
46
+ }
47
+ return n;
48
+ }