@smartmemory/wiki-components 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/README.md +14 -2
  2. package/package.json +26 -4
  3. package/src/data-client.d.ts +27 -1
  4. package/src/data-client.jsx +3 -0
  5. package/src/graph/components/wiki/DocCard.jsx +46 -0
  6. package/src/graph/components/wiki/GraphBreadcrumb.jsx +23 -0
  7. package/src/graph/components/wiki/GraphCanvas.jsx +663 -0
  8. package/src/graph/components/wiki/GraphCanvas3D.jsx +1366 -0
  9. package/src/graph/components/wiki/GraphDocPane.jsx +56 -0
  10. package/src/graph/components/wiki/GraphLegend.jsx +119 -0
  11. package/src/graph/components/wiki/GraphLevelPicker.jsx +35 -0
  12. package/src/graph/components/wiki/graph/FilterChips.jsx +45 -0
  13. package/src/graph/components/wiki/graph/Omnibox.jsx +74 -0
  14. package/src/graph/components/wiki/graph/badges.js +46 -0
  15. package/src/graph/components/wiki/graph/bubbles3d.js +95 -0
  16. package/src/graph/components/wiki/graph/canvasBase.js +138 -0
  17. package/src/graph/components/wiki/graph/containment.js +49 -0
  18. package/src/graph/components/wiki/graph/overlay.js +134 -0
  19. package/src/graph/components/wiki/graph/pixiBase.js +210 -0
  20. package/src/graph/components/wiki/graph/threeBase.js +231 -0
  21. package/src/graph/hooks/useDebounced.js +11 -0
  22. package/src/graph/host.jsx +56 -0
  23. package/src/graph/index.d.ts +26 -0
  24. package/src/graph/index.js +4 -0
  25. package/src/graph/lib/areaFamilies.js +32 -0
  26. package/src/graph/lib/camFollow.js +30 -0
  27. package/src/graph/lib/canvasPort.js +93 -0
  28. package/src/graph/lib/cardVisibility.js +58 -0
  29. package/src/graph/lib/docCard.js +28 -0
  30. package/src/graph/lib/forceWorker.js +123 -0
  31. package/src/graph/lib/forceWorker3d.js +136 -0
  32. package/src/graph/lib/graph3d.js +330 -0
  33. package/src/graph/lib/graphArrows.js +108 -0
  34. package/src/graph/lib/graphExplore.js +546 -0
  35. package/src/graph/lib/graphFind.js +46 -0
  36. package/src/graph/lib/graphLasso.js +44 -0
  37. package/src/graph/lib/graphLevels.js +367 -0
  38. package/src/graph/lib/graphOrient.js +145 -0
  39. package/src/graph/lib/graphPath.js +86 -0
  40. package/src/graph/lib/graphPin.js +48 -0
  41. package/src/graph/lib/graphPress.js +27 -0
  42. package/src/graph/lib/graphShell.js +296 -0
  43. package/src/graph/lib/graphTheme.js +50 -0
  44. package/src/graph/lib/graphView.js +343 -0
  45. package/src/graph/lib/graphViews.js +103 -0
  46. package/src/graph/lib/groupBubbles.js +128 -0
  47. package/src/graph/lib/hubLines.js +51 -0
  48. package/src/graph/lib/idleTurn.js +31 -0
  49. package/src/graph/lib/labelDepth.js +59 -0
  50. package/src/graph/lib/layoutClient.js +69 -0
  51. package/src/graph/lib/layoutEngine.js +184 -0
  52. package/src/graph/lib/linkWeight.js +67 -0
  53. package/src/graph/lib/orphanPull.js +10 -0
  54. package/src/graph/lib/perf.js +14 -0
  55. package/src/graph/lib/reducedMotion.js +36 -0
  56. package/src/graph/lib/robustFit.js +62 -0
  57. package/src/graph/lib/wikiGraph.js +298 -0
  58. package/src/graph/pages/GraphPage.jsx +703 -0
  59. package/src/index.d.ts +6 -1
  60. package/src/index.js +2 -1
  61. package/src/lib.js +3 -0
  62. package/src/parts/Markdown.jsx +22 -0
@@ -0,0 +1,546 @@
1
+ // WIKI-GRAPH-EXPLORE-1a: the filter engine behind the Graph page's sentence row. Pure (no DOM, no fetch, no Pixi): a directed index
2
+ // of the FULL graph, every filter step as a function over byte masks, lenses that only mark, the `x` URL codec, the sentence text,
3
+ // the empty-state hint and the arrow-key neighbour picker. Design: smart-memory-docs docs/features/WIKI-GRAPH-EXPLORE-1/design.md
4
+ // §5-§8. The output plugs into LEVELS as `visible` (graphLevels.js `buildLevelGraph(graph, expanded, { visible })`).
5
+
6
+ const NO_AREA = "(no area)"; // same label wikiGraph.js `groupOf` gives a doc with no area
7
+ export const VERSION = 1;
8
+ export const MAX_HOPS = 4;
9
+ /** Longest `x` value and most steps decoded; anything past them is counted as ignored, never thrown on. */
10
+ export const MAX_X_LENGTH = 20000;
11
+ export const MAX_STEPS = 64;
12
+ /** Share says "too big for a link" past either of these (design §6). */
13
+ export const SHARE_MAX_IDS = 200;
14
+ export const SHARE_MAX_URL = 6000;
15
+
16
+ /** The verbs the omnibox offers: label -> step template. Owner decision 2: "Children of" is `near`, hops 1 (linked either way). */
17
+ export const VERBS = [
18
+ { label: "Children of", op: "near", hops: 1 },
19
+ { label: "Links from it", op: "from", hops: 1 },
20
+ { label: "Links to it", op: "to", hops: 1 },
21
+ { label: "Near (N hops)", op: "near", hops: 2 },
22
+ { label: "Plus one hop", op: "grow", hops: 1 },
23
+ { label: "Inside folder", op: "in" },
24
+ { label: "Linked at least", op: "degree" },
25
+ { label: "Orphans", op: "orphans" },
26
+ { label: "Area", op: "area" },
27
+ { label: "Kind", op: "kind" },
28
+ { label: "Changed within", op: "modified" },
29
+ { label: "Matching text", op: "match" },
30
+ { label: "Keep selection", op: "only" },
31
+ ];
32
+
33
+ /** A step from a verb label plus the arguments the omnibox collected (`id`, `path`, `min`, `names`, `q`, `ids`, `hops`, `within`...).
34
+ * Returns null for an unknown label. The result is a plain Step (not validated: `normaliseStep` does that). */
35
+ export function verbToStep(label, args = {}) {
36
+ const v = VERBS.find((x) => x.label === label);
37
+ if (!v) return null;
38
+ const { label: _l, ...tpl } = v;
39
+ return { ...tpl, ...args };
40
+ }
41
+
42
+ const clampHops = (h) => Math.max(1, Math.min(MAX_HOPS, Math.round(Number(h)) || 1));
43
+ const isStr = (v) => typeof v === "string" && v !== "";
44
+ const strList = (v) => (Array.isArray(v) ? v.filter((s) => typeof s === "string" && s !== "") : []);
45
+ const num = (v) => (v === undefined || v === null || v === "" ? undefined : Number.isFinite(Number(v)) ? Number(v) : NaN);
46
+ const dateOk = (s) => typeof s === "string" && !Number.isNaN(Date.parse(s));
47
+
48
+ /** Validate one step. Returns a clean copy, or null when it cannot be applied (the caller counts it as ignored). */
49
+ export function normaliseStep(raw) {
50
+ if (!raw || typeof raw !== "object") return null;
51
+ const out = { op: raw.op };
52
+ switch (raw.op) {
53
+ case "in": if (typeof raw.path !== "string") return null; out.path = raw.path.replace(/^\/+|\/+$/g, ""); break;
54
+ case "from": case "to": case "near":
55
+ if (!isStr(raw.id)) return null;
56
+ out.id = raw.id; out.hops = clampHops(raw.hops); break;
57
+ case "degree": {
58
+ const min = num(raw.min), max = num(raw.max);
59
+ if (Number.isNaN(min) || Number.isNaN(max) || (min === undefined && max === undefined)) return null;
60
+ if (min !== undefined) out.min = min;
61
+ if (max !== undefined) out.max = max;
62
+ break;
63
+ }
64
+ case "orphans": break;
65
+ case "area": case "kind": {
66
+ const names = strList(raw.names);
67
+ if (!names.length) return null;
68
+ out.names = names; break;
69
+ }
70
+ case "modified": {
71
+ if (raw.within !== undefined && !/^\d{1,4}d$/.test(String(raw.within))) return null;
72
+ if (raw.since !== undefined && !dateOk(raw.since)) return null;
73
+ if (raw.until !== undefined && !dateOk(raw.until)) return null;
74
+ if (raw.within === undefined && raw.since === undefined && raw.until === undefined) return null;
75
+ for (const k of ["since", "until", "within"]) if (raw[k] !== undefined) out[k] = String(raw[k]);
76
+ break;
77
+ }
78
+ case "match": if (!isStr(raw.q)) return null; out.q = raw.q; break;
79
+ case "only": { const ids = strList(raw.ids); if (!ids.length) return null; out.ids = ids; break; }
80
+ case "grow": out.hops = 1; break;
81
+ default: return null;
82
+ }
83
+ if (raw.not === true) out.not = true;
84
+ return out;
85
+ }
86
+
87
+ export function normaliseLens(raw) {
88
+ if (!raw || typeof raw !== "object") return null;
89
+ if (raw.lens === "highlight" && isStr(raw.q)) return { lens: "highlight", q: raw.q };
90
+ if (raw.lens === "path" && isStr(raw.from) && isStr(raw.to)) return { lens: "path", from: raw.from, to: raw.to };
91
+ return null;
92
+ }
93
+
94
+ // ---------------------------------------------------------------------------------------------------------------- index
95
+
96
+ /** The directed index of the FULL graph (`buildGraph`'s `{nodes, links}`): node order = `graph.nodes`, links in CSR form both ways.
97
+ * Built once per fetch. Self links and links to unknown ids are skipped; degree = in + out, the same count `buildGraph` keeps. */
98
+ export function buildIndex(graph) {
99
+ const nodes = graph.nodes, n = nodes.length;
100
+ const indexOf = new Map();
101
+ for (let i = 0; i < n; i++) indexOf.set(nodes[i].id, i);
102
+ const endIdx = (e) => indexOf.get(typeof e === "object" && e ? e.id : e);
103
+ const src = new Int32Array(graph.links.length), dst = new Int32Array(graph.links.length);
104
+ let m = 0;
105
+ const outStart = new Int32Array(n + 1), inStart = new Int32Array(n + 1);
106
+ for (const l of graph.links) {
107
+ const a = endIdx(l.source), b = endIdx(l.target);
108
+ if (a === undefined || b === undefined || a === b) continue;
109
+ src[m] = a; dst[m] = b; m++;
110
+ outStart[a + 1]++; inStart[b + 1]++;
111
+ }
112
+ for (let i = 0; i < n; i++) { outStart[i + 1] += outStart[i]; inStart[i + 1] += inStart[i]; }
113
+ const outAdj = new Int32Array(m), inAdj = new Int32Array(m);
114
+ const oc = outStart.slice(0, n), ic = inStart.slice(0, n);
115
+ for (let k = 0; k < m; k++) { outAdj[oc[src[k]]++] = dst[k]; inAdj[ic[dst[k]]++] = src[k]; }
116
+ const degree = new Int32Array(n);
117
+ for (let i = 0; i < n; i++) degree[i] = outStart[i + 1] - outStart[i] + inStart[i + 1] - inStart[i];
118
+ let hasKind = false, hasMtime = false;
119
+ const mtime = new Float64Array(n).fill(NaN);
120
+ for (let i = 0; i < n; i++) {
121
+ if (nodes[i].kind !== undefined && nodes[i].kind !== null) hasKind = true;
122
+ if (nodes[i].mtime !== undefined && nodes[i].mtime !== null) {
123
+ const t = typeof nodes[i].mtime === "number" ? nodes[i].mtime : Date.parse(nodes[i].mtime);
124
+ if (!Number.isNaN(t)) { mtime[i] = t; hasMtime = true; }
125
+ }
126
+ }
127
+ return { n, nodes, indexOf, outStart, outAdj, inStart, inAdj, degree, mtime, hasKind, hasMtime };
128
+ }
129
+
130
+ const idOf = (idx, i) => idx.nodes[i].id;
131
+
132
+ /** Docs reachable from `root` within `hops`, over the full graph. dir: "out" | "in" | "both". Root included. Byte mask; null for an unknown root. */
133
+ function walk(idx, root, hops, dir) {
134
+ const r = idx.indexOf.get(root);
135
+ if (r === undefined) return null;
136
+ const seen = new Uint8Array(idx.n);
137
+ seen[r] = 1;
138
+ let frontier = [r];
139
+ for (let h = 0; h < hops && frontier.length; h++) {
140
+ const next = [];
141
+ for (const u of frontier) {
142
+ if (dir !== "in") for (let k = idx.outStart[u]; k < idx.outStart[u + 1]; k++) { const v = idx.outAdj[k]; if (!seen[v]) { seen[v] = 1; next.push(v); } }
143
+ if (dir !== "out") for (let k = idx.inStart[u]; k < idx.inStart[u + 1]; k++) { const v = idx.inAdj[k]; if (!seen[v]) { seen[v] = 1; next.push(v); } }
144
+ }
145
+ frontier = next;
146
+ }
147
+ return seen;
148
+ }
149
+
150
+ /** Shortest undirected path between two doc ids over the full graph: ordered ids, or null when either is unknown or they are not connected. */
151
+ export function shortestPath(idx, from, to) {
152
+ const a = idx.indexOf.get(from), b = idx.indexOf.get(to);
153
+ if (a === undefined || b === undefined) return null;
154
+ if (a === b) return [from];
155
+ const prev = new Int32Array(idx.n).fill(-2);
156
+ prev[a] = -1;
157
+ let frontier = [a];
158
+ while (frontier.length && prev[b] === -2) {
159
+ const next = [];
160
+ for (const u of frontier) {
161
+ for (let pass = 0; pass < 2; pass++) {
162
+ const st = pass ? idx.inStart : idx.outStart, ad = pass ? idx.inAdj : idx.outAdj;
163
+ for (let k = st[u]; k < st[u + 1]; k++) { const v = ad[k]; if (prev[v] === -2) { prev[v] = u; next.push(v); } }
164
+ }
165
+ }
166
+ frontier = next;
167
+ }
168
+ if (prev[b] === -2) return null;
169
+ const path = [];
170
+ for (let v = b; v !== -1; v = prev[v]) path.push(idOf(idx, v));
171
+ return path.reverse();
172
+ }
173
+
174
+ // ---------------------------------------------------------------------------------------------------------------- steps
175
+
176
+ const DAY = 86400000;
177
+ /** `ctx`: `{ now?: number (ms), matches?: Map<q, Iterable<docId>> }`. `matches` carries the server content-search ids per query. */
178
+ const matchSet = (ctx, q) => { const m = ctx && ctx.matches && ctx.matches.get(q); return m ? new Set(m) : null; };
179
+
180
+ /** The predicate form of a step: (i) -> boolean over node index, or null when the step is not a predicate. Status is set on `info`. */
181
+ function predicateOf(idx, step, ctx, info) {
182
+ switch (step.op) {
183
+ case "in": {
184
+ const p = step.path;
185
+ return (i) => { const f = idx.nodes[i].folder || ""; return p === "" || f === p || f.startsWith(p + "/"); };
186
+ }
187
+ case "degree": { const lo = step.min ?? -Infinity, hi = step.max ?? Infinity; return (i) => idx.degree[i] >= lo && idx.degree[i] <= hi; }
188
+ case "orphans": return (i) => idx.degree[i] === 0;
189
+ case "area": { const s = new Set(step.names); return (i) => s.has(idx.nodes[i].area || NO_AREA); }
190
+ case "kind": {
191
+ if (!idx.hasKind) { info.status = "unsupported"; return null; }
192
+ const s = new Set(step.names);
193
+ return (i) => { const k = idx.nodes[i].kind; return k !== undefined && k !== null && s.has(k); };
194
+ }
195
+ case "modified": {
196
+ if (!idx.hasMtime) { info.status = "unsupported"; return null; }
197
+ const now = ctx && ctx.now !== undefined ? ctx.now : Date.now();
198
+ let lo = step.since ? Date.parse(step.since) : -Infinity;
199
+ const hi = step.until ? Date.parse(step.until) : Infinity;
200
+ if (step.within) lo = Math.max(lo, now - parseInt(step.within, 10) * DAY);
201
+ return (i) => { const t = idx.mtime[i]; return !Number.isNaN(t) && t >= lo && t <= hi; };
202
+ }
203
+ case "match": {
204
+ const ids = matchSet(ctx, step.q);
205
+ if (!ids) { info.status = "pending"; return null; }
206
+ return (i) => ids.has(idOf(idx, i));
207
+ }
208
+ case "only": { const s = new Set(step.ids); return (i) => s.has(idOf(idx, i)); }
209
+ default: return null;
210
+ }
211
+ }
212
+
213
+ /** One step over a mask: returns `{ mask, status }`. status: "ok" | "unsupported" (no node carries the field: the step passes through) |
214
+ * "pending" (a `match` whose server ids have not landed: passes through) | "missing-root" (the walk's root is not in the graph: matches nothing). */
215
+ export function applyStep(idx, maskIn, step, ctx = {}) {
216
+ const info = { status: "ok" };
217
+ const out = new Uint8Array(idx.n);
218
+ const neg = step.not === true;
219
+ if (step.op === "from" || step.op === "to" || step.op === "near") {
220
+ const reach = walk(idx, step.id, step.hops, step.op === "from" ? "out" : step.op === "to" ? "in" : "both");
221
+ if (!reach) info.status = "missing-root";
222
+ for (let i = 0; i < idx.n; i++) if (maskIn[i] && (reach ? reach[i] === 1 : false) !== neg) out[i] = 1;
223
+ return { mask: out, status: info.status };
224
+ }
225
+ if (step.op === "grow") {
226
+ // adds the neighbours (either direction, full graph) of the current set. `not grow` keeps only the ring it would add.
227
+ out.set(maskIn);
228
+ for (let i = 0; i < idx.n; i++) {
229
+ if (!maskIn[i]) continue;
230
+ for (let k = idx.outStart[i]; k < idx.outStart[i + 1]; k++) out[idx.outAdj[k]] = 1;
231
+ for (let k = idx.inStart[i]; k < idx.inStart[i + 1]; k++) out[idx.inAdj[k]] = 1;
232
+ }
233
+ if (neg) for (let i = 0; i < idx.n; i++) if (maskIn[i]) out[i] = 0;
234
+ return { mask: out, status: "ok" };
235
+ }
236
+ const pred = predicateOf(idx, step, ctx, info);
237
+ if (!pred) { out.set(maskIn); return { mask: out, status: info.status }; }
238
+ for (let i = 0; i < idx.n; i++) if (maskIn[i] && pred(i) !== neg) out[i] = 1;
239
+ return { mask: out, status: "ok" };
240
+ }
241
+
242
+ const count = (mask) => { let c = 0; for (let i = 0; i < mask.length; i++) c += mask[i]; return c; };
243
+
244
+ const stepKey = (step, ctx) => {
245
+ let k = JSON.stringify(step);
246
+ if (step.op === "match") { const m = matchSet(ctx, step.q); k += ":" + (m ? "m" + JSON.stringify([...m].sort()) : "p"); }
247
+ if (step.op === "modified" && step.within) k += ":" + Math.floor((ctx && ctx.now !== undefined ? ctx.now : Date.now()) / 60000);
248
+ return k;
249
+ };
250
+
251
+ /** An explorer over one fetched graph: the index plus a prefix cache, so editing the last chip recomputes one step. */
252
+ export function createExplorer(graph) {
253
+ const idx = buildIndex(graph);
254
+ const all = new Uint8Array(idx.n).fill(1);
255
+ /** @type {{key: string, mask: Uint8Array, status: string, count: number}[]} */
256
+ let cache = [];
257
+ return {
258
+ idx,
259
+ /** Run a stack. Returns `{ mask, count, total, steps: [{ count, status }] }` (steps[i] = docs left after step i). */
260
+ run(steps, ctx = {}) {
261
+ const keys = steps.map((s) => stepKey(s, ctx));
262
+ let keep = 0;
263
+ while (keep < cache.length && keep < keys.length && cache[keep].key === keys[keep]) keep++;
264
+ cache = cache.slice(0, keep);
265
+ for (let i = keep; i < steps.length; i++) {
266
+ const { mask, status } = applyStep(idx, i ? cache[i - 1].mask : all, steps[i], ctx);
267
+ cache.push({ key: keys[i], mask, status, count: count(mask) });
268
+ }
269
+ const mask = steps.length ? cache[steps.length - 1].mask : all;
270
+ return {
271
+ mask,
272
+ count: steps.length ? cache[steps.length - 1].count : idx.n,
273
+ total: idx.n,
274
+ steps: cache.map((c) => ({ count: c.count, status: c.status })),
275
+ };
276
+ },
277
+ };
278
+ }
279
+
280
+ /** One-shot convenience: the mask for a state's steps, no cache. */
281
+ export function runStack(graph, state, ctx = {}) { return createExplorer(graph).run(state.steps, ctx); }
282
+
283
+ /** The `visible` predicate LEVELS takes: docId -> boolean from a mask. */
284
+ export function visiblePredicate(idx, mask) { return (id) => { const i = idx.indexOf.get(id); return i !== undefined && mask[i] === 1; }; }
285
+
286
+ // ---------------------------------------------------------------------------------------------------------------- lenses
287
+
288
+ /** Lenses only mark. Returns `{ dim, path }`: `dim` is a Uint8Array (1 = dimmed, i.e. does not match the highlight) or null with no
289
+ * highlight lens; `path` is the ordered ids of the shortest path or null. Nothing is ever removed. `ctx.matches` adds server ids. */
290
+ export function applyLenses(idx, lenses, ctx = {}) {
291
+ let dim = null, path = null;
292
+ for (const l of lenses) {
293
+ if (l.lens === "highlight") {
294
+ const q = l.q.toLowerCase(), ids = matchSet(ctx, l.q);
295
+ const hit = new Uint8Array(idx.n);
296
+ for (let i = 0; i < idx.n; i++) {
297
+ const nd = idx.nodes[i];
298
+ if (String(nd.title || "").toLowerCase().includes(q) || String(nd.id).toLowerCase().includes(q) || (ids && ids.has(nd.id))) hit[i] = 1;
299
+ }
300
+ dim = dim || new Uint8Array(idx.n).fill(1);
301
+ for (let i = 0; i < idx.n; i++) if (hit[i]) dim[i] = 0;
302
+ } else if (l.lens === "path") path = shortestPath(idx, l.from, l.to);
303
+ }
304
+ return { dim, path };
305
+ }
306
+
307
+ // ---------------------------------------------------------------------------------------------------------------- URL codec
308
+
309
+ const enc = (s) => encodeURIComponent(s).replace(/[!~'()*]/g, (c) => "%" + c.charCodeAt(0).toString(16).toUpperCase());
310
+ const dec = (s) => decodeURIComponent(s);
311
+ const encList = (a) => a.map(enc).join(",");
312
+ const decList = (s) => s.split(",").filter(Boolean).map(dec);
313
+ const optNum = (v) => (v === undefined ? "" : String(v));
314
+
315
+ function encodeStep(s) {
316
+ const bang = s.not ? "!" : "";
317
+ switch (s.op) {
318
+ case "in": return `${bang}in:${enc(s.path)}`;
319
+ case "from": case "to": case "near": return `${bang}${s.op}:${enc(s.id)}:${s.hops}`;
320
+ case "degree": return `${bang}degree:${optNum(s.min)}:${optNum(s.max)}`;
321
+ case "orphans": return `${bang}orphans`;
322
+ case "area": case "kind": return `${bang}${s.op}:${encList(s.names)}`;
323
+ case "modified": return `${bang}modified:${enc(s.since || "")}:${enc(s.until || "")}:${s.within || ""}`;
324
+ case "match": return `${bang}match:${enc(s.q)}`;
325
+ case "only": return `${bang}only:${encList(s.ids)}`;
326
+ case "grow": return `${bang}grow:${s.hops}`;
327
+ default: return "";
328
+ }
329
+ }
330
+
331
+ function decodeStep(token) {
332
+ const not = token.startsWith("!");
333
+ const parts = (not ? token.slice(1) : token).split(":");
334
+ const f = parts.slice(1);
335
+ let raw;
336
+ switch (parts[0]) {
337
+ case "in": raw = { op: "in", path: dec(f[0] ?? "") }; break;
338
+ case "from": case "to": case "near": raw = { op: parts[0], id: dec(f[0] ?? ""), hops: f[1] }; break;
339
+ case "degree": raw = { op: "degree", min: num(f[0]), max: num(f[1]) }; break;
340
+ case "orphans": raw = { op: "orphans" }; break;
341
+ case "area": case "kind": raw = { op: parts[0], names: decList(f[0] ?? "") }; break;
342
+ case "modified": raw = { op: "modified", since: f[0] ? dec(f[0]) : undefined, until: f[1] ? dec(f[1]) : undefined, within: f[2] || undefined }; break;
343
+ case "match": raw = { op: "match", q: dec(f[0] ?? "") }; break;
344
+ case "only": raw = { op: "only", ids: decList(f[0] ?? "") }; break;
345
+ case "grow": raw = { op: "grow", hops: 1 }; break;
346
+ default: return null;
347
+ }
348
+ if (not) raw.not = true;
349
+ return normaliseStep(raw);
350
+ }
351
+
352
+ function decodeLens(token) {
353
+ const parts = token.slice(1).split(":");
354
+ if (parts[0] === "highlight") return normaliseLens({ lens: "highlight", q: dec(parts[1] ?? "") });
355
+ if (parts[0] === "path") return normaliseLens({ lens: "path", from: dec(parts[1] ?? ""), to: dec(parts[2] ?? "") });
356
+ return null;
357
+ }
358
+
359
+ export const emptyState = () => ({ v: VERSION, steps: [], lenses: [] });
360
+
361
+ /** `x` param value for a state ("" when it has no steps and no lenses). Steps that fail validation are skipped. */
362
+ export function encodeX(state) {
363
+ const steps = (state.steps || []).map(normaliseStep).filter(Boolean).map(encodeStep);
364
+ const lenses = (state.lenses || []).map(normaliseLens).filter(Boolean).map((l) =>
365
+ l.lens === "highlight" ? `@highlight:${enc(l.q)}` : `@path:${enc(l.from)}:${enc(l.to)}`);
366
+ return steps.length || lenses.length ? [String(VERSION), ...steps, ...lenses].join("~") : "";
367
+ }
368
+
369
+ /** The visible notice for dropped filters (design §6): "1 filter from a newer link was ignored". */
370
+ export const ignoredNotice = (n) => (n <= 0 ? "" : n === 1 ? "1 filter from a newer link was ignored" : `${n} filters from a newer link were ignored`);
371
+
372
+ /** Decode an `x` value. Never throws: hostile input yields what could be read plus `ignored` (count of tokens dropped) and one
373
+ * console warning. A newer version drops everything. Returns `{ state, ignored, notice }`. */
374
+ export function decodeX(value) {
375
+ const state = emptyState();
376
+ if (typeof value !== "string" || value === "") return { state, ignored: 0, notice: "" };
377
+ let ignored = 0;
378
+ let text = value;
379
+ if (text.length > MAX_X_LENGTH) { text = text.slice(0, MAX_X_LENGTH); ignored++; }
380
+ const tokens = text.split("~");
381
+ const ver = /^\d{1,4}$/.test(tokens[0]) ? Number(tokens[0]) : NaN;
382
+ if (ver !== VERSION) {
383
+ ignored += Math.max(1, tokens.length - (Number.isNaN(ver) ? 0 : 1));
384
+ } else {
385
+ for (const tok of tokens.slice(1, 1 + MAX_STEPS + 64)) {
386
+ if (tok === "") continue;
387
+ let item = null;
388
+ try { item = tok.startsWith("@") ? decodeLens(tok) : decodeStep(tok); } catch { item = null; }
389
+ if (!item) ignored++;
390
+ else if (tok.startsWith("@")) state.lenses.push(item);
391
+ else if (state.steps.length < MAX_STEPS) state.steps.push(item);
392
+ else ignored++;
393
+ }
394
+ if (tokens.length - 1 > MAX_STEPS + 64) ignored += tokens.length - 1 - (MAX_STEPS + 64);
395
+ }
396
+ if (ignored > 0 && typeof console !== "undefined") console.warn(`wiki graph: ignored ${ignored} unreadable or newer filter(s) in the x parameter`);
397
+ return { state, ignored, notice: ignoredNotice(ignored) };
398
+ }
399
+
400
+ const hopsOf = (v) => clampHops(v);
401
+
402
+ /** Read a state from URL params (`URLSearchParams` or a query string). `x` wins; with no `x` the legacy params map over:
403
+ * `hide=a,b` -> `not area` (the old `local=1` is ignored: a focus always narrows to its hops). Returns `{ state, focus, ignored, notice }`, where
404
+ * `focus` is `root` (it stays the focus, not a filter). */
405
+ export function parseExploreParams(params) {
406
+ const sp = typeof params === "string" ? new URLSearchParams(params) : params;
407
+ const focus = sp.get("root") || null;
408
+ const x = sp.get("x");
409
+ if (x !== null) return { focus, ...decodeX(x) };
410
+ const state = emptyState();
411
+ const hide = (sp.get("hide") || "").split(",").filter(Boolean).map((s) => { try { return dec(s); } catch { return s; } });
412
+ if (hide.length) state.steps.push({ op: "area", names: hide, not: true });
413
+ return { focus, state, ignored: 0, notice: "" };
414
+ }
415
+
416
+ /** A copy of `params` carrying `state` in the `x` form and none of the legacy `hide` / `local` / `hops` (design §6). */
417
+ export function writeExploreParams(params, state) {
418
+ const sp = new URLSearchParams(typeof params === "string" ? params : params.toString());
419
+ for (const k of ["hide", "local", "hops", "x"]) sp.delete(k);
420
+ const x = encodeX(state);
421
+ if (x) sp.set("x", x);
422
+ return sp;
423
+ }
424
+
425
+ /** Whether Share can copy a link for this state (design §6: over 200 `only` ids or ~6 KB of URL it says "save it instead"). */
426
+ export function shareable(state, base = "") {
427
+ const big = (state.steps || []).some((s) => s.op === "only" && s.ids.length > SHARE_MAX_IDS);
428
+ return !big && base.length + encodeX(state).length <= SHARE_MAX_URL;
429
+ }
430
+
431
+ // ---------------------------------------------------------------------------------------------------------------- sentence
432
+
433
+ const plural = (n, w) => `${n} ${w}${n === 1 ? "" : "s"}`;
434
+ const titleOf = (idx, id) => { const i = idx && idx.indexOf.get(id); return i === undefined || i === null ? id : idx.nodes[i].title || id; };
435
+
436
+ /** The phrase for one step ("linked ≥ 3"), without the `not `. */
437
+ export function phraseOf(step, idx = null) {
438
+ switch (step.op) {
439
+ case "in": return step.path === "" ? "in the root" : `in ${step.path}`;
440
+ case "from": return step.hops === 1 ? `linked from ${titleOf(idx, step.id)}` : `reached from ${titleOf(idx, step.id)} (${plural(step.hops, "hop")})`;
441
+ case "to": return step.hops === 1 ? `linking to ${titleOf(idx, step.id)}` : `leading to ${titleOf(idx, step.id)} (${plural(step.hops, "hop")})`;
442
+ case "near": return step.hops === 1 ? `children of ${titleOf(idx, step.id)}` : `within ${plural(step.hops, "hop")} of ${titleOf(idx, step.id)}`;
443
+ case "degree": {
444
+ if (step.min !== undefined && step.max !== undefined) return `linked ${step.min}–${step.max}`;
445
+ return step.min !== undefined ? `linked ≥ ${step.min}` : `linked ≤ ${step.max}`;
446
+ }
447
+ case "orphans": return "orphans";
448
+ case "area": return `area ${step.names.join(", ")}`;
449
+ case "kind": return `kind ${step.names.join(", ")}`;
450
+ case "modified": {
451
+ if (step.within) return `changed within ${step.within}`;
452
+ if (step.since && step.until) return `changed ${step.since.slice(0, 10)} to ${step.until.slice(0, 10)}`;
453
+ return step.since ? `changed since ${step.since.slice(0, 10)}` : `changed until ${step.until.slice(0, 10)}`;
454
+ }
455
+ case "match": return `matching "${step.q}"`;
456
+ case "only": return `selection (${step.ids.length})`;
457
+ case "grow": return "+1 hop";
458
+ default: return step.op;
459
+ }
460
+ }
461
+
462
+ /** The path lens's chip: `path A → B (k links)` (the shortest undirected path over the full graph), or `no path A → B` when the two docs
463
+ * are not connected. Without an index it names the ids' titles only. */
464
+ export function pathChipText(lens, idx = null) {
465
+ const names = `${titleOf(idx, lens.from)} → ${titleOf(idx, lens.to)}`;
466
+ if (!idx) return `path ${names}`;
467
+ const path = shortestPath(idx, lens.from, lens.to);
468
+ if (!path) return `no path ${names}`;
469
+ const k = path.length - 1;
470
+ return `path ${names} (${k} ${k === 1 ? "link" : "links"})`;
471
+ }
472
+
473
+ /** The sentence row's parts: `{ chips: [{ text, not, unsupported }], lenses: [{ text }], countText }`. `run` is a result of
474
+ * `explorer.run` (for the count and for `unsupported`); `idx` names docs by title. */
475
+ export function sentenceOf(state, run = null, idx = null) {
476
+ const chips = state.steps.map((s, i) => ({
477
+ text: (s.not ? "not " : "") + phraseOf(s, idx),
478
+ not: s.not === true,
479
+ unsupported: !!(run && run.steps[i] && run.steps[i].status === "unsupported"),
480
+ }));
481
+ const lenses = state.lenses.map((l) => ({ text: l.lens === "highlight" ? `highlight "${l.q}"` : pathChipText(l, idx), ...(l.lens === "path" ? { path: true } : {}) }));
482
+ let countText = "";
483
+ if (run) {
484
+ const f = (v) => v.toLocaleString("en-US");
485
+ countText = run.count < run.total ? `${f(run.count)} of ${f(run.total)} docs` : `${f(run.count)} docs`;
486
+ }
487
+ return { chips, lenses, countText };
488
+ }
489
+
490
+ /** When the stack leaves nothing: the one chip whose removal brings back the most docs (ties go to the rightmost chip). Returns
491
+ * `{ index, gain, text }` (text e.g. `No docs match. Remove "linked ≥ 3" to see 41 docs.`) or null when the stack is not empty or no
492
+ * single removal helps. */
493
+ export function emptyHint(explorer, state, ctx = {}) {
494
+ const { idx } = explorer;
495
+ if (!state.steps.length || explorer.run(state.steps, ctx).count > 0) return null;
496
+ let best = null;
497
+ for (let i = 0; i < state.steps.length; i++) {
498
+ const rest = state.steps.filter((_, j) => j !== i);
499
+ const c = runWithout(explorer, rest, ctx);
500
+ if (c > 0 && (!best || c >= best.gain)) best = { index: i, gain: c };
501
+ }
502
+ if (!best) return null;
503
+ const step = state.steps[best.index];
504
+ return { ...best, text: `No docs match. Remove "${(step.not ? "not " : "") + phraseOf(step, idx)}" to see ${plural(best.gain, "doc")}.` };
505
+ }
506
+
507
+ // a separate cache so probing removals does not evict the live stack's prefix cache
508
+ function runWithout(explorer, steps, ctx) {
509
+ let mask = new Uint8Array(explorer.idx.n).fill(1);
510
+ for (const s of steps) mask = applyStep(explorer.idx, mask, s, ctx).mask;
511
+ return count(mask);
512
+ }
513
+
514
+ // ---------------------------------------------------------------------------------------------------------------- arrow keys
515
+
516
+ const DIRS = { left: [-1, 0], right: [1, 0], up: [0, -1], down: [0, 1] };
517
+
518
+ /** The neighbour in a screen direction for the arrow keys. `positions`: `[{ id, x, y, visible }]` (screen px, y down; 3D passes
519
+ * projected points). Candidates sit in the 90° cone around the direction; the nearest wins, with sideways offset weighing double
520
+ * so "right" prefers the doc straight to the right. Ties go to the smaller id. Returns an id or null. */
521
+ export function neighbourInDirection(positions, fromId, dir) {
522
+ const d = DIRS[dir];
523
+ const from = positions.find((p) => p.id === fromId);
524
+ if (!d || !from) return null;
525
+ let best = null, bestScore = Infinity;
526
+ for (const p of positions) {
527
+ if (p.id === fromId || p.visible === false) continue;
528
+ const dx = p.x - from.x, dy = p.y - from.y;
529
+ const along = dx * d[0] + dy * d[1], across = Math.abs(dx * d[1] - dy * d[0]);
530
+ if (along <= 0 || across > along) continue;
531
+ const score = along + 2 * across;
532
+ if (score < bestScore || (score === bestScore && String(p.id) < String(best))) { best = p.id; bestScore = score; }
533
+ }
534
+ return best;
535
+ }
536
+
537
+ /** With nothing focused: the biggest visible doc (`size` or `degree` on each position; ties go to the smaller id). */
538
+ export function biggestVisible(positions) {
539
+ let best = null, bs = -Infinity;
540
+ for (const p of positions) {
541
+ if (p.visible === false) continue;
542
+ const s = p.size ?? p.degree ?? 0;
543
+ if (s > bs || (s === bs && String(p.id) < String(best))) { best = p.id; bs = s; }
544
+ }
545
+ return best;
546
+ }
@@ -0,0 +1,46 @@
1
+ // WIKI-GRAPH-FIND-FILTER-LIVE-1: "Find a doc by title" filters the graph live. Typing (debounced FIND_DEBOUNCE_MS) sets a
2
+ // match set; both canvases light the matches and dim everything else through the selection-focus treatment (graphView
3
+ // `litOf`, the canvases' `relight`), with no layout pass and no node moved. The pure parts live here so the page and both
4
+ // renderers share one rule.
5
+ import { searchNodes } from "./wikiGraph";
6
+ import { folderId } from "./graphLevels";
7
+
8
+ export const FIND_DEBOUNCE_MS = 100;
9
+ export const FIND_LIST = 8; // the dropdown shows the best few; the count and the lit set cover every match
10
+
11
+ /** Is there anything to find? An empty or whitespace term is no filter. */
12
+ export const hasTerm = (term) => String(term || "").trim().length > 0;
13
+
14
+ /** Every doc of `nodes` whose title holds `term` (case-insensitive substring), in searchNodes' order: title prefixes first,
15
+ * then shorter titles, then by title. Only the docs `visible(id)` keeps count (the legend's hidden groups, the local
16
+ * graph's hops); no `visible` keeps all. [] for an empty term. */
17
+ export function findMatches(nodes, term, visible) {
18
+ if (!hasTerm(term)) return [];
19
+ return searchNodes(visible ? nodes.filter((n) => visible(n.id)) : nodes, term, Infinity);
20
+ }
21
+
22
+ /** The drawn node a doc shows as: the doc itself when it is drawn, else (a level view) the deepest drawn folder holding
23
+ * it (the virtual root folder `folderId("")` last, which holds the root docs, `folder: ""`), else null (not drawn at all). `drawnIds`: the
24
+ * ids the canvas draws. */
25
+ export function drawnFor(doc, drawnIds) {
26
+ if (drawnIds.has(doc.id)) return doc.id;
27
+ const parts = String(doc.folder || "").split("/").filter(Boolean);
28
+ for (let k = parts.length; k >= 0; k--) {
29
+ const id = folderId(parts.slice(0, k).join("/"));
30
+ if (drawnIds.has(id)) return id;
31
+ }
32
+ return null;
33
+ }
34
+
35
+ /** The drawn ids the matches light, in match order, each once: a matching doc, or the folder that holds it in a level view. */
36
+ export function litIds(matches, drawnIds) {
37
+ const out = new Set();
38
+ for (const d of matches) {
39
+ const id = drawnFor(d, drawnIds);
40
+ if (id !== null) out.add(id);
41
+ }
42
+ return [...out];
43
+ }
44
+
45
+ /** The text next to the box. */
46
+ export const countText = (n) => (n === 0 ? "No doc title matches." : n === 1 ? "1 match" : `${n} matches`);
@@ -0,0 +1,44 @@
1
+ // WIKI-GRAPH-EXPLORE-1c-UI round 1: the 3D lasso's pure parts (3D-UX-1 REPORT rows "lasso" and D2). Shift-drag on the 3D surface draws a
2
+ // polygon in surface px; release selects every doc whose projected centre is inside it, THROUGH the ball (front and back), and the live
3
+ // count says how many of them were behind the pivot. The canvas draws and samples the polygon, the page calls lassoHits on what
4
+ // `screenPositions()` reports (x, y, visible, depth = view depth, `pivotDepth()` = the turn pivot's view depth).
5
+
6
+ export const LASSO_STEP_PX = 4; // a drag point closer than this to the last one is dropped (keeps the polygon short)
7
+ export const LASSO_MIN_POINTS = 3; // fewer than this is a line, not an area: it catches nothing
8
+
9
+ /** Ray-casting point-in-polygon for `pts` = [[x, y], ...] (implicitly closed). A point on an edge may go either way. */
10
+ export function pointInPolygon(x, y, pts) {
11
+ let inside = false;
12
+ for (let i = 0, j = pts.length - 1; i < pts.length; j = i++) {
13
+ const [xi, yi] = pts[i], [xj, yj] = pts[j];
14
+ if (yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) inside = !inside;
15
+ }
16
+ return inside;
17
+ }
18
+
19
+ /** The polygon after the pointer reaches (x, y): the same array when the point is under `step` px from the last one, else a new
20
+ * array with it appended. A non-finite point is ignored. */
21
+ export function lassoSample(pts, x, y, step = LASSO_STEP_PX) {
22
+ if (!Number.isFinite(x) || !Number.isFinite(y)) return pts;
23
+ const last = pts[pts.length - 1];
24
+ if (last && Math.hypot(x - last[0], y - last[1]) < step) return pts;
25
+ return [...pts, [x, y]];
26
+ }
27
+
28
+ /** What a polygon catches. `list` = screenPositions rows {id, x, y, visible, depth}; `pivotDepth` = the pivot's view depth (null: nothing
29
+ * counts as behind). A row counts when it is drawn (`visible`, so in front of the camera, on screen and not hidden) and its centre is
30
+ * inside the polygon, whatever its depth. `behind` = those farther than the pivot. Returns {ids, inside, behind}. */
31
+ export function lassoHits(pts, list, pivotDepth = null) {
32
+ if (!pts || pts.length < LASSO_MIN_POINTS) return { ids: [], inside: 0, behind: 0 };
33
+ const ids = [];
34
+ let behind = 0;
35
+ for (const r of list) {
36
+ if (!r.visible || !pointInPolygon(r.x, r.y, pts)) continue;
37
+ ids.push(r.id);
38
+ if (pivotDepth != null && r.depth > pivotDepth) behind++;
39
+ }
40
+ return { ids, inside: ids.length, behind };
41
+ }
42
+
43
+ /** The live count while dragging, and the same words once released. */
44
+ export const lassoText = (inside, behind) => `${inside} inside (${behind} behind)`;