@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,49 @@
1
+ // WIKI-GRAPH-LEVELS-3D-1: the dashed lines from a hub folder to the nodes it contains (graphLevels `contains`). Drawn on the
2
+ // 2D overlay canvas under the labels, for both surfaces, so a hub reads as the parent of what sits around it. They are not
3
+ // links: no arrowheads, no weight, no hover lighting.
4
+ import { graphTheme } from "../../../lib/graphTheme";
5
+ export const CONTAIN_COLOUR = "#94a3b8";
6
+ export const CONTAIN_ALPHA = 0.35;
7
+ export const CONTAIN_DIM = 0.12; // while a node is lit the containment lines step back
8
+ const DASH = [4, 4];
9
+
10
+ /** Flat index pairs `[a0, b0, a1, b1, ...]` of the containment edges `[{source, target}]` (ids) over `index` (id -> node index);
11
+ * an edge with an end that is not drawn is skipped. */
12
+ export function containPairs(contains, index) {
13
+ const out = [];
14
+ for (const c of contains || []) {
15
+ const a = index.get(c.source), b = index.get(c.target);
16
+ if (a !== undefined && b !== undefined) out.push(a, b);
17
+ }
18
+ return Uint32Array.from(out);
19
+ }
20
+
21
+ /** The line alpha for a `dim` state: false (or absent) is undimmed, a number is a level clamped to [0, CONTAIN_DIM], anything else truthy is lit. */
22
+ export function containAlpha(dim) {
23
+ if (typeof dim === "number") return Number.isFinite(dim) ? Math.min(CONTAIN_DIM, Math.max(0, dim)) : CONTAIN_DIM;
24
+ return dim ? CONTAIN_DIM : CONTAIN_ALPHA;
25
+ }
26
+
27
+ /** Draw the containment pairs `cp` on `ctx`: dashed, one path. `sx(i)` / `sy(i)`: screen position of node i; `ok(i)`: false for
28
+ * a node that is hidden or behind the camera (its edges are skipped). `dim`: `false` = nothing is lit, so the full alpha; `true` = a node is lit, so fainter; a number is the
29
+ * base's dim level (0 = fully dimmed, never brighter than the dimmed nodes), so zero stays zero, never the undimmed branch. */
30
+ export function drawContainment(ctx, cp, sx, sy, ok, dim = false) {
31
+ if (!cp || !cp.length) return 0;
32
+ ctx.save();
33
+ ctx.globalAlpha = containAlpha(dim);
34
+ ctx.strokeStyle = graphTheme().contain;
35
+ ctx.lineWidth = 1;
36
+ ctx.setLineDash(DASH);
37
+ ctx.beginPath();
38
+ let n = 0;
39
+ for (let e = 0; e < cp.length; e += 2) {
40
+ const a = cp[e], b = cp[e + 1];
41
+ if (!ok(a) || !ok(b)) continue;
42
+ ctx.moveTo(sx(a), sy(a));
43
+ ctx.lineTo(sx(b), sy(b));
44
+ n++;
45
+ }
46
+ ctx.stroke();
47
+ ctx.restore();
48
+ return n;
49
+ }
@@ -0,0 +1,134 @@
1
+ // WIKI-GRAPH-OBSIDIAN-1: the 2D overlay above either base layer (pixiBase.js / canvasBase.js): the hover/focus highlight
2
+ // (lit links, lit nodes with their rings) and the labels, nothing else. It is redrawn only when the view, the positions or
3
+ // the lit set change, and a hover touches only this canvas, never the 50k nodes underneath. The dimming of everything
4
+ // else is the base canvas's CSS opacity (GraphCanvas.jsx). Colours, font and label rules are today's (GraphCanvas before
5
+ // this unit): labels #cbd5e1 outlined #0f172a at 12px, titles cut at 32 characters; which labels show is pickLabels'
6
+ // (WIKI-GRAPH-DECLUTTER-1: a budget per view, hubs first, fade with zoom, never two overlapping).
7
+ import { drawRadius, pickLabels } from "../../../lib/graphView";
8
+ import { LABEL_CAP, labelBudget, lodFor } from "../../../lib/wikiGraph";
9
+ import { pickDepthLabels } from "../../../lib/labelDepth";
10
+ import { ARROW_CAP, fillHotArrows } from "../../../lib/graphArrows";
11
+ import { graphTheme } from "../../../lib/graphTheme";
12
+
13
+ export const HOT = "#2bb1ff";
14
+ export const FOCUS_RING = "#ffffff";
15
+ export const SELECT_RING = "#facc15"; // WIKI-GRAPH-3D-PORT-1: a selected doc's own ring (the port's `marks.selected`)
16
+ export const LABEL = "#cbd5e1", LABEL_OUTLINE = "#0f172a";
17
+ export const LABEL_FONT = "12px Inter, ui-sans-serif, system-ui, sans-serif";
18
+ export const LABEL_MAX = 32;
19
+
20
+ const hotTris = new Float32Array(6 * ARROW_CAP);
21
+ const NO_ORDER = new Uint32Array(0);
22
+ const hex = (c) => (typeof c === "number" ? `#${c.toString(16).padStart(6, "0")}` : c);
23
+ /** A title as the label shows it: at most LABEL_MAX characters, the rest an ellipsis. */
24
+ export const labelText = (title) => (title.length > LABEL_MAX ? title.slice(0, LABEL_MAX - 1) + "…" : title);
25
+
26
+ /** Draw one overlay frame on `ctx` (a 2D context of a w x h CSS px canvas at `dpr`) for scene `sc` (the arrays both bases
27
+ * read, plus `degree`, `order` = node indices best-linked first, and `visibleN` = how many nodes the legend shows).
28
+ * `lit`: node indices to highlight (focus, hover and their neighbours); `litLinks`: index pairs (two entries per link)
29
+ * touching the focus or the hover; `focus` / `hover`: an index or -1; `titles`: per index; `labelW`: per-index label
30
+ * width cache (NaN = not measured yet), filled here. `heads`: hotHeads() of the focus and the hover, drawn in HOT at the
31
+ * lit rings whatever the zoom (WIKI-GRAPH-DECLUTTER-1: the base never puts heads on a hub). Labels follow pickLabels (budget
32
+ * per view, hubs, fade with zoom, no overlaps). `under(ctx, sx, sy)`: drawn right after the clear, below everything else
33
+ * (WIKI-GRAPH-LEVELS-3D-1: the containment edges). `litOnly`: the base is dimmed (selection focus or a find term,
34
+ * WIKI-GRAPH-FIND-FILTER-LIVE-1), so only the lit docs get labels, never the dimmed hubs. `selected`: node indices that get
35
+ * their own SELECT_RING outside the dot (WIKI-GRAPH-3D-PORT-1 marks.selected; none by default). Returns the labels drawn
36
+ * ([{i, x, y, a}], in draw order) so tests can read them. `sc.scr` (3 floats per node: x, y, depth), `sc.pivotDepth` and
37
+ * `sc.farDepth` switch the labels to the depth rule (labelDepth.js, the 3D scene only). */
38
+ export function drawOverlay(ctx, sc, { lit, litLinks, focus = -1, hover = -1, titles, labelW, dpr, w, h, heads = null, under, litOnly = false, selected = null }) {
39
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
40
+ ctx.clearRect(0, 0, w, h);
41
+ if (!sc || !sc.n) return [];
42
+ const { pos, view: v, mask, sizes, big, colors } = sc;
43
+ const sx = (i) => pos[2 * i] * v.k + v.x, sy = (i) => pos[2 * i + 1] * v.k + v.y;
44
+ const rPx = (i) => drawRadius(sizes[i], v.k, big) * v.k;
45
+ if (under) under(ctx, sx, sy);
46
+
47
+ if (litLinks && litLinks.length) {
48
+ ctx.strokeStyle = graphTheme().hot;
49
+ ctx.lineWidth = 1.5;
50
+ ctx.beginPath();
51
+ for (let e = 0; e < litLinks.length; e += 2) {
52
+ const a = litLinks[e], b = litLinks[e + 1];
53
+ if (!mask[a] || !mask[b]) continue;
54
+ ctx.moveTo(sx(a), sy(a));
55
+ ctx.lineTo(sx(b), sy(b));
56
+ }
57
+ ctx.stroke();
58
+ }
59
+
60
+ const ring = (i) => rPx(i) * (i === focus ? 1.8 : 1.2);
61
+ if (heads && heads.length) {
62
+ const t = fillHotArrows(heads, (i) => [sx(i), sy(i), ring(i)], mask, hotTris);
63
+ if (t) {
64
+ ctx.fillStyle = graphTheme().hot;
65
+ ctx.beginPath();
66
+ for (let o = 0; o < 6 * t; o += 6) {
67
+ ctx.moveTo(hotTris[o], hotTris[o + 1]);
68
+ ctx.lineTo(hotTris[o + 2], hotTris[o + 3]);
69
+ ctx.lineTo(hotTris[o + 4], hotTris[o + 5]);
70
+ ctx.closePath();
71
+ }
72
+ ctx.fill();
73
+ }
74
+ }
75
+
76
+ for (const i of lit) {
77
+ if (!mask[i]) continue;
78
+ const x = sx(i), y = sy(i);
79
+ if (x < -50 || x > w + 50 || y < -50 || y > h + 50) continue;
80
+ ctx.beginPath();
81
+ ctx.arc(x, y, ring(i), 0, 2 * Math.PI);
82
+ ctx.fillStyle = hex(colors[i]);
83
+ ctx.fill();
84
+ ctx.lineWidth = 2;
85
+ ctx.strokeStyle = i === focus ? graphTheme().focusRing : graphTheme().hot;
86
+ ctx.stroke();
87
+ }
88
+
89
+ if (selected && selected.length) {
90
+ ctx.lineWidth = 2;
91
+ ctx.strokeStyle = graphTheme().selectRing;
92
+ for (const i of selected) {
93
+ if (!mask[i]) continue;
94
+ const x = sx(i), y = sy(i);
95
+ if (x < -50 || x > w + 50 || y < -50 || y > h + 50) continue;
96
+ ctx.beginPath();
97
+ ctx.arc(x, y, ring(i) + 4, 0, 2 * Math.PI);
98
+ ctx.stroke();
99
+ }
100
+ }
101
+
102
+ ctx.font = LABEL_FONT;
103
+ ctx.textAlign = "center";
104
+ ctx.lineJoin = "round";
105
+ const width = (i) => {
106
+ if (!(labelW[i] >= 0)) labelW[i] = ctx.measureText(labelText(titles[i])).width;
107
+ return labelW[i];
108
+ };
109
+ const deg = sc.degree;
110
+ const lod = lodFor(sc.visibleN ?? sc.n, v.k);
111
+ const at = (i) => [sx(i), sy(i), rPx(i)];
112
+ // WIKI-GRAPH-3D-POLISH-1 round 4: a scene with a projection (`sc.scr`, the 3D one) picks labels with depth (labelDepth.js); the
113
+ // 2D scene has none and keeps pickLabels exactly.
114
+ const labels = sc.scr
115
+ ? pickDepthLabels({
116
+ scr: sc.scr, mask, always: selected && selected.length ? [...lit, ...selected] : lit, order: litOnly ? NO_ORDER : sc.order, pivot: sc.pivotDepth, far: sc.farDepth,
117
+ focus, hover, mode: lod.labels, cap: LABEL_CAP, budget: labelBudget(w, h), at, width, w, h, degree: deg, k: v.k, labelK: lod.labelK,
118
+ })
119
+ : pickLabels({
120
+ lead: [...lit].sort((a, b) => (b === focus) - (a === focus) || (b === hover) - (a === hover) || deg[b] - deg[a]), order: litOnly ? NO_ORDER : sc.order, mode: lod.labels,
121
+ cap: LABEL_CAP, budget: labelBudget(w, h), mask, w, h, width, degree: deg, k: v.k, labelK: lod.labelK, at,
122
+ });
123
+ ctx.lineWidth = 3;
124
+ ctx.strokeStyle = graphTheme().labelOutline;
125
+ ctx.fillStyle = graphTheme().label;
126
+ for (const { i, x, y, a } of labels) {
127
+ const t = labelText(titles[i]);
128
+ if (a < 1) ctx.globalAlpha = a;
129
+ ctx.strokeText(t, x, y);
130
+ ctx.fillText(t, x, y);
131
+ if (a < 1) ctx.globalAlpha = 1;
132
+ }
133
+ return labels;
134
+ }
@@ -0,0 +1,210 @@
1
+ // WIKI-GRAPH-OBSIDIAN-1: the graph's base layer on the GPU (Pixi v8 WebGL): every edge and every node, nothing else.
2
+ // Labels and the hover/focus highlight are drawn on the 2D overlay above it (overlay.js), so a hover never restyles
3
+ // 50k sprites. Nodes: one ParticleContainer of white-circle particles (tint = colour, scale = size), all properties
4
+ // static, re-uploaded only after a position frame, a zoom or a restyle, so a pan is only a transform. Edges: one Mesh with
5
+ // our own line-list shader; its vertex buffer IS the node positions array and its index buffer the drawn links as node
6
+ // index pairs, so a position frame is one buffer upload and a legend toggle one index upload.
7
+ // Pixi's own ticker and event system are not used: GraphCanvas runs one requestAnimationFrame loop for both layers.
8
+ import { Container, Geometry, GlProgram, Mesh, Particle, ParticleContainer, Shader, Texture, autoDetectRenderer } from "pixi.js";
9
+ import { ARROW_CAP, arrowsFor, fillArrows } from "../../../lib/graphArrows";
10
+ import { CORE, HALO, LOOK, SHELL_ALPHA, drawRadius } from "../../../lib/graphView";
11
+ import { edgeAlphaFor, linksInView } from "../../../lib/wikiGraph";
12
+ import { fillThickLinks, linkBuckets, thickBuffers, thickCount } from "../../../lib/linkWeight";
13
+ import { graphTheme } from "../../../lib/graphTheme";
14
+
15
+ // GLSL ES 1.00 (runs on WebGL 1 and 2). Pixi binds the matrices by name: uProjectionMatrix and uWorldTransformMatrix from
16
+ // the global uniform group (100), uTransformMatrix and uColor (the mesh's tint x alpha, premultiplied) from the mesh's
17
+ // local group (101): pixi.js lib/scene/mesh/gl/GlMeshAdaptor.mjs execute(), lib/scene/mesh/shared/MeshPipe.mjs.
18
+ const VERTEX = `attribute vec2 aPosition;
19
+ uniform mat3 uProjectionMatrix;
20
+ uniform mat3 uWorldTransformMatrix;
21
+ uniform mat3 uTransformMatrix;
22
+ void main() {
23
+ mat3 m = uProjectionMatrix * uWorldTransformMatrix * uTransformMatrix;
24
+ gl_Position = vec4((m * vec3(aPosition, 1.0)).xy, 0.0, 1.0);
25
+ }`;
26
+ const FRAGMENT = `uniform vec4 uColor;
27
+ void main() { gl_FragColor = uColor; }`;
28
+
29
+ const TEX = 64; // the circle texture's size; a particle at scale 1 is TEX world units wide
30
+ const ARROW_ALPHA = 0.75;
31
+ const THICK_START = 4096; // quads the links wider than a hairline (linkWeight.js buckets 2..) start with; the buffers grow to draw them all (thickBuffers)
32
+
33
+ function circleTexture() {
34
+ const c = document.createElement("canvas");
35
+ c.width = c.height = TEX;
36
+ const g = c.getContext("2d");
37
+ g.fillStyle = "#ffffff";
38
+ g.beginPath();
39
+ g.arc(TEX / 2, TEX / 2, TEX / 2 - 1, 0, 2 * Math.PI);
40
+ g.fill();
41
+ return Texture.from(c);
42
+ }
43
+
44
+ /** A WebGL base on `canvas`. Rejects when Pixi cannot make a WebGL renderer (the caller falls back to Canvas2D). */
45
+ export async function createPixiBase(canvas, { w, h, dpr, background }) {
46
+ const EDGE = graphTheme().edgeHex; // by the mode the base is built in (the canvas is keyed by it)
47
+ const renderer = await autoDetectRenderer({
48
+ preference: ["webgl"], canvas, width: w, height: h, resolution: dpr, autoDensity: true, antialias: true, background,
49
+ hello: false, eventFeatures: { move: false, globalMove: false, click: false, wheel: false },
50
+ });
51
+ const stage = new Container();
52
+ stage.eventMode = "none";
53
+ stage.interactiveChildren = false;
54
+ const world = new Container();
55
+ stage.addChild(world);
56
+ const texture = circleTexture();
57
+ const shader = new Shader({ glProgram: GlProgram.from({ vertex: VERTEX, fragment: FRAGMENT, name: "wiki-graph-edges" }) });
58
+ let particles = null, mesh = null, all = [], lastK = 0;
59
+ // Looks (graphView LOOK, the same as 3D): a folder is a translucent full-size shell particle under a solid CORE particle, a hub
60
+ // has a white particle HALO x its size under it. `rims` are those extra particles ({p, i, f}: node i, size factor f), drawn first.
61
+ let rims = [];
62
+ // Arrowheads: one more Mesh (triangle list, same shader) whose vertex buffer holds the heads of the links in view, in world
63
+ // units. Rebuilt on a position frame, a zoom, a legend change, or a pan past `ARROW_MARGIN` px (heads are culled to the view
64
+ // grown by that much), never on a small pan, so a pan stays a transform.
65
+ const ARROW_MARGIN = 200;
66
+ const tris = new Float32Array(6 * ARROW_CAP), triIndex = Uint32Array.from({ length: 3 * ARROW_CAP }, (_, i) => i);
67
+ let alphaAt = null, alphaStale = true; // edge alpha from the links in view, redone on stale or a real move
68
+ // Links wider than a hairline (WIKI-GRAPH-LEVEL-WEIGHTS-FU-1): a line-list mesh is always 1 px, so bucket 1 stays in `mesh` and
69
+ // buckets 2.. are one triangle-list mesh of quads, `bucket` screen px wide in world units, rebuilt with the arrows.
70
+ let tb = thickBuffers(null, THICK_START);
71
+ let thick = null, thickStale = true, thickK = 0;
72
+ let arrows = null, arrowAt = null, arrowStale = true, last = null, box = { w, h };
73
+
74
+ const rescale = (sc) => {
75
+ const { sizes, view, big, n } = sc;
76
+ for (let i = 0; i < n; i++) { const p = all[i]; p.scaleX = p.scaleY = (2 * drawRadius(sizes[i], view.k, big) * (sc.looks?.[i] === LOOK.FOLDER ? CORE : 1)) / TEX; }
77
+ for (const { p, i, f } of rims) p.scaleX = p.scaleY = (2 * drawRadius(sizes[i], view.k, big) * f) / TEX;
78
+ lastK = view.k;
79
+ particles.update();
80
+ };
81
+ const restyle = (sc) => {
82
+ for (let i = 0; i < sc.n; i++) all[i].tint = sc.colors[i];
83
+ for (const { p, i, f } of rims) p.tint = f === HALO ? graphTheme().haloHex : sc.colors[i];
84
+ rescale(sc);
85
+ };
86
+ const revisible = (sc) => {
87
+ const kids = [];
88
+ for (const r of rims) if (sc.mask[r.i]) kids.push(r.p);
89
+ for (let i = 0; i < sc.n; i++) if (sc.mask[i]) kids.push(all[i]);
90
+ particles.particleChildren = kids;
91
+ particles.update();
92
+ const bk = sc.visBuckets || linkBuckets(null, sc.visCount), thin = new Uint32Array(2 * (bk.start[2] - bk.start[1]));
93
+ for (let j = bk.start[1]; j < bk.start[2]; j++) { const e = 2 * bk.idx[j], o = 2 * (j - bk.start[1]); thin[o] = sc.visPairs[e]; thin[o + 1] = sc.visPairs[e + 1]; }
94
+ mesh.geometry.indexBuffer.data = thin.length ? thin : new Uint32Array(2);
95
+ mesh.visible = thin.length > 0;
96
+ arrowStale = true; alphaStale = true; thickStale = true;
97
+ };
98
+
99
+ const rethick = (sc) => {
100
+ if (!thickStale && thickK === sc.view.k) return; // positions, the legend or the zoom changed: the quads' world width is px / k
101
+ thickStale = false; thickK = sc.view.k;
102
+ const bk = sc.visBuckets;
103
+ const need = bk ? thickCount(bk) : 0;
104
+ if (need > tb.cap) { tb = thickBuffers(tb, need); thick.geometry.getBuffer("aPosition").data = tb.quads; } // never drop the overflow: grow
105
+ const q = need > 0 ? fillThickLinks(sc.pos, sc.visPairs, bk, sc.view.k, tb.quads, tb.cap) : 0;
106
+ thick.visible = q > 0;
107
+ if (!q) return;
108
+ thick.geometry.indexBuffer.data = tb.index.subarray(0, 6 * q);
109
+ thick.geometry.getBuffer("aPosition").update();
110
+ };
111
+
112
+ const rearrow = (sc) => {
113
+ const v = sc.view, on = arrowsFor(sc.visibleN ?? sc.n, v.k, sc.big);
114
+ if (mesh && (alphaStale || !alphaAt || alphaAt.k !== v.k || Math.abs(alphaAt.x - v.x) > ARROW_MARGIN / 2 || Math.abs(alphaAt.y - v.y) > ARROW_MARGIN / 2)) {
115
+ alphaStale = false;
116
+ alphaAt = { x: v.x, y: v.y, k: v.k };
117
+ mesh.alpha = thick.alpha = edgeAlphaFor(linksInView(sc.pos, sc.visPairs, sc.visCount, v, box.w, box.h), box.w, box.h);
118
+ }
119
+ rethick(sc);
120
+ if (!on) { arrows.visible = false; arrowAt = null; return; }
121
+ const moved = !arrowAt || arrowAt.k !== v.k || Math.abs(arrowAt.x - v.x) > ARROW_MARGIN / 2 || Math.abs(arrowAt.y - v.y) > ARROW_MARGIN / 2;
122
+ if (!arrowStale && !moved) return;
123
+ arrowStale = false;
124
+ arrowAt = { x: v.x, y: v.y, k: v.k };
125
+ const t = fillArrows(sc, v, box.w, box.h, tris, ARROW_MARGIN);
126
+ arrows.visible = t > 0;
127
+ if (!t) return;
128
+ arrows.geometry.indexBuffer.data = triIndex.subarray(0, 3 * t);
129
+ arrows.geometry.getBuffer("aPosition").update();
130
+ };
131
+
132
+ return {
133
+ kind: "webgl",
134
+ renderer,
135
+ /** A new node set: rebuild the particles and the edge mesh. */
136
+ setData(sc) {
137
+ if (particles) { world.removeChild(particles); particles.destroy({ children: false }); }
138
+ if (mesh) { world.removeChild(mesh); mesh.geometry.destroy(); mesh.destroy({ children: false }); }
139
+ if (arrows) { world.removeChild(arrows); arrows.geometry.destroy(); arrows.destroy({ children: false }); }
140
+ all = new Array(sc.n);
141
+ for (let i = 0; i < sc.n; i++) all[i] = new Particle({ texture, x: sc.pos[2 * i], y: sc.pos[2 * i + 1], anchorX: 0.5, anchorY: 0.5 });
142
+ rims = [];
143
+ for (let i = 0; i < sc.n; i++) {
144
+ const look = sc.looks?.[i];
145
+ if (look !== LOOK.FOLDER && look !== LOOK.HUB) continue;
146
+ const alpha = look === LOOK.FOLDER ? SHELL_ALPHA : 1;
147
+ rims.push({ p: new Particle({ texture, x: sc.pos[2 * i], y: sc.pos[2 * i + 1], anchorX: 0.5, anchorY: 0.5, alpha }), i, f: look === LOOK.FOLDER ? 1 : HALO });
148
+ }
149
+ particles = new ParticleContainer({ texture, dynamicProperties: { position: false, vertex: false, rotation: false, uvs: false, color: false } });
150
+ const geometry = new Geometry({
151
+ attributes: { aPosition: { buffer: sc.pos, format: "float32x2" } },
152
+ indexBuffer: sc.visCount ? sc.visPairs.subarray(0, 2 * sc.visCount) : new Uint32Array(2),
153
+ topology: "line-list",
154
+ });
155
+ mesh = new Mesh({ geometry, shader });
156
+ mesh.tint = EDGE;
157
+ alphaStale = true;
158
+ if (thick) { world.removeChild(thick); thick.geometry.destroy(); thick.destroy({ children: false }); }
159
+ thick = new Mesh({
160
+ geometry: new Geometry({ attributes: { aPosition: { buffer: tb.quads, format: "float32x2" } }, indexBuffer: tb.index.subarray(0, 3), topology: "triangle-list" }),
161
+ shader,
162
+ });
163
+ thick.tint = EDGE;
164
+ thick.visible = false;
165
+ arrows = new Mesh({
166
+ geometry: new Geometry({ attributes: { aPosition: { buffer: tris, format: "float32x2" } }, indexBuffer: triIndex.subarray(0, 3), topology: "triangle-list" }),
167
+ shader,
168
+ });
169
+ arrows.tint = EDGE;
170
+ arrows.alpha = ARROW_ALPHA;
171
+ arrows.visible = false;
172
+ arrowStale = true;
173
+ world.addChild(mesh, thick, arrows, particles);
174
+ restyle(sc);
175
+ revisible(sc);
176
+ },
177
+ setStyle: restyle,
178
+ setVisible: revisible,
179
+ /** A position frame: `sc.pos` was rewritten in place. */
180
+ setPositions(sc) {
181
+ for (let i = 0; i < sc.n; i++) { const p = all[i]; p.x = sc.pos[2 * i]; p.y = sc.pos[2 * i + 1]; }
182
+ for (const { p, i } of rims) { p.x = sc.pos[2 * i]; p.y = sc.pos[2 * i + 1]; }
183
+ particles.update();
184
+ mesh.geometry.getBuffer("aPosition").update();
185
+ arrowStale = true; alphaStale = true; thickStale = true;
186
+ },
187
+ setView(sc) {
188
+ last = sc;
189
+ world.position.set(sc.view.x, sc.view.y);
190
+ world.scale.set(sc.view.k);
191
+ if (particles && sc.view.k !== lastK) rescale(sc); // keeps the minimum screen size of a node on zoom-out
192
+ },
193
+ resize(w2, h2, dpr2) { box = { w: w2, h: h2 }; arrowStale = true; alphaStale = true; thickStale = true; renderer.resize(w2, h2, dpr2); },
194
+ render() {
195
+ if (arrows && last) rearrow(last);
196
+ renderer.render(stage);
197
+ return true;
198
+ },
199
+ destroy() {
200
+ particles?.destroy({ children: false });
201
+ mesh?.geometry.destroy();
202
+ arrows?.geometry.destroy();
203
+ thick?.geometry.destroy();
204
+ stage.destroy({ children: true });
205
+ shader.destroy();
206
+ texture.destroy(true);
207
+ renderer.destroy({ removeView: false });
208
+ },
209
+ };
210
+ }
@@ -0,0 +1,231 @@
1
+ // WIKI-GRAPH-3D-1: the 3D graph's base layer, the three.js analogue of pixiBase.js: every node, every edge and the
2
+ // arrowheads, nothing else. Labels and the hover/focus highlight stay on the 2D overlay (overlay.js), fed the camera
3
+ // projection by GraphCanvas3D.jsx, so a hover never touches the 50k instances. Nodes: one InstancedMesh of icosahedra (80
4
+ // triangles at most: smooth normals, and the detail steps down with the node count, `sphereDetail`) with per-instance colour; a
5
+ // hidden doc is a zero-scale instance, so the count never changes. A folder is a solid core (0.4 x radius) in a translucent
6
+ // shell (its own InstancedMesh, folders only), a hub gets a white back-face halo (its own InstancedMesh, hubs only). Edges: one LineSegments whose position buffer is rewritten on a position frame; a legend toggle only moves the draw
7
+ // range. Arrowheads: one InstancedMesh of cones, refilled per drawn frame from the projection (fillArrows3d), capped at
8
+ // ARROW_CAP. three's own render loop is not used: GraphCanvas3D runs one requestAnimationFrame loop and renders when dirty.
9
+ // destroy() disposes every geometry and material and forces the context loss, so toggling 2D/3D never leaks a context.
10
+ import {
11
+ AmbientLight, Color, ConeGeometry, DirectionalLight, DynamicDrawUsage, IcosahedronGeometry, InstancedMesh, LineBasicMaterial,
12
+ LineSegments, BufferAttribute, BufferGeometry, BackSide, MOUSE, Matrix4, MeshBasicMaterial, MeshLambertMaterial, Object3D, PerspectiveCamera,
13
+ Scene, TOUCH, WebGLRenderer,
14
+ } from "three";
15
+ import { OrbitControls } from "three/addons/controls/OrbitControls.js";
16
+ import { LineMaterial } from "three/addons/lines/LineMaterial.js";
17
+ import { LineSegments2 } from "three/addons/lines/LineSegments2.js";
18
+ import { LineSegmentsGeometry } from "three/addons/lines/LineSegmentsGeometry.js";
19
+ import { LINK_BUCKETS, linkBuckets } from "../../../lib/linkWeight";
20
+ import { ARROW_CAP, arrowsFor } from "../../../lib/graphArrows";
21
+ import { edgeAlphaFor, linksInView } from "../../../lib/wikiGraph";
22
+ import { FOV, SHELL_DETAIL, fillArrows3d, sphereDetail, writeInstances, writeLinkPositionsBy, writeSubset } from "../../../lib/graph3d";
23
+ import { HALO, LOOK, SHELL_ALPHA } from "../../../lib/graphView";
24
+ import { graphTheme } from "../../../lib/graphTheme";
25
+
26
+ const ARROW_ALPHA = 0.75; // pixiBase ARROW_ALPHA
27
+ const ARROW_MARGIN = 200; // heads are culled to the view grown by this many px (pixiBase)
28
+ const NEAR = 0.5, FAR = 1e6;
29
+ const ID_VIEW = { k: 1, x: 0, y: 0 }; // the projection is already in screen px
30
+
31
+ /** A WebGL2 base on `canvas`. Throws when three cannot make a WebGL renderer (the caller tells the page to drop to 2D). */
32
+ export function createThreeBase(canvas, { w, h, dpr, background }) {
33
+ const EDGE = graphTheme().edgeHex; // by the mode the base is built in (the canvas is keyed by it)
34
+ const renderer = new WebGLRenderer({ canvas, antialias: true, powerPreference: "high-performance" });
35
+ renderer.setPixelRatio(Math.min(dpr || 1, 2));
36
+ renderer.setSize(w, h);
37
+ renderer.setClearColor(new Color(background), 1);
38
+ const scene = new Scene();
39
+ const camera = new PerspectiveCamera(FOV, w / Math.max(1, h), NEAR, FAR);
40
+ camera.position.set(0, 0, 400);
41
+ // Lights ride on the camera (spike values), so the side facing you is always lit, whichever way you orbit.
42
+ scene.add(new AmbientLight(0xffffff, 1.4));
43
+ const sun = new DirectionalLight(0xffffff, 1.6), aim = new Object3D();
44
+ sun.position.set(0.5, 1, 0);
45
+ aim.position.set(0, 0, -1);
46
+ sun.target = aim;
47
+ camera.add(sun, aim);
48
+ scene.add(camera);
49
+
50
+ const nodeMat = new MeshLambertMaterial({ color: 0xffffff });
51
+ const shellMat = new MeshLambertMaterial({ color: 0xffffff, transparent: true, opacity: SHELL_ALPHA, depthWrite: false });
52
+ const haloMat = new MeshBasicMaterial({ color: graphTheme().haloHex, side: BackSide });
53
+ const lineMat = new LineBasicMaterial({ color: EDGE, transparent: true, opacity: 0.22, depthWrite: false });
54
+ const arrowMat = new MeshBasicMaterial({ color: EDGE, transparent: true, opacity: ARROW_ALPHA, depthWrite: false });
55
+ const cone = new ConeGeometry(1, 1, 6).translate(0, -0.5, 0); // apex at the origin, base at y = -1 (graph3d arrowMatrix)
56
+ const arrows = new InstancedMesh(cone, arrowMat, ARROW_CAP);
57
+ arrows.instanceMatrix.setUsage(DynamicDrawUsage);
58
+ arrows.count = 0;
59
+ arrows.frustumCulled = false;
60
+ scene.add(arrows);
61
+
62
+ // Links wider than a hairline (WIKI-GRAPH-LEVEL-WEIGHTS-FU-1): WebGL lines are 1 px, so bucket 1 stays in `lines` and each wider
63
+ // bucket (linkWeight.js) is a LineSegments2 whose LineMaterial draws `bucket` screen px, refilled with the link positions.
64
+ const thickMats = [];
65
+ for (let b = 2; b <= LINK_BUCKETS; b++) thickMats[b] = new LineMaterial({ color: EDGE, linewidth: b, transparent: true, opacity: 0.22, depthWrite: false });
66
+ const thick = [];
67
+ const edgeOpacity = (o) => { lineMat.opacity = o; for (const m of thickMats) if (m) m.opacity = o; };
68
+ let shells = null, shellGeo = null, shellIdx = [], halos = null, haloGeo = null, haloIdx = [];
69
+ let nodes = null, nodeGeo = null, detail = -1, lines = null, linePos = null, controls = null;
70
+ const tint = new Color(), vp = new Matrix4(), eye = [0, 0, 0];
71
+ let box = { w, h };
72
+
73
+ const restyle = (sc) => {
74
+ for (let i = 0; i < sc.n; i++) nodes.setColorAt(i, tint.setHex(sc.colors[i]));
75
+ if (nodes.instanceColor) nodes.instanceColor.needsUpdate = true;
76
+ if (shells) {
77
+ shellIdx.forEach((i, j) => shells.setColorAt(j, tint.setHex(sc.colors[i])));
78
+ shells.instanceColor.needsUpdate = true;
79
+ }
80
+ reinstance(sc);
81
+ };
82
+ const reinstance = (sc) => {
83
+ writeInstances(nodes.instanceMatrix.array, sc.xyz, sc.radius, sc.mask, sc.n, sc.looks);
84
+ nodes.instanceMatrix.needsUpdate = true;
85
+ if (shells) { writeSubset(shells.instanceMatrix.array, sc.xyz, sc.radius, sc.mask, shellIdx, 1); shells.instanceMatrix.needsUpdate = true; }
86
+ if (halos) { writeSubset(halos.instanceMatrix.array, sc.xyz, sc.radius, sc.mask, haloIdx, HALO); halos.instanceMatrix.needsUpdate = true; }
87
+ };
88
+ /** An InstancedMesh of `count` unit spheres of `geo` that moves every frame (never frustum-culled). */
89
+ const sphereMesh = (geo, mat, count) => {
90
+ const m = new InstancedMesh(geo, mat, Math.max(1, count));
91
+ m.count = count;
92
+ m.instanceMatrix.setUsage(DynamicDrawUsage);
93
+ m.frustumCulled = false;
94
+ scene.add(m);
95
+ return m;
96
+ };
97
+ const relink = (sc) => {
98
+ const bk = sc.visBuckets || linkBuckets(null, sc.visCount), n1 = bk.start[2] - bk.start[1];
99
+ writeLinkPositionsBy(linePos, sc.xyz, sc.visPairs, bk.idx, bk.start[1], bk.start[2]);
100
+ lines.geometry.attributes.position.needsUpdate = true;
101
+ lines.geometry.setDrawRange(0, 2 * n1);
102
+ lines.visible = n1 > 0;
103
+ for (let b = 2; b <= LINK_BUCKETS; b++) {
104
+ const cnt = bk.start[b + 1] - bk.start[b], seg = thick[b];
105
+ seg.visible = cnt > 0;
106
+ if (cnt) seg.geometry.setPositions(writeLinkPositionsBy(new Float32Array(6 * cnt), sc.xyz, sc.visPairs, bk.idx, bk.start[b], bk.start[b + 1]));
107
+ }
108
+ };
109
+
110
+ return {
111
+ kind: "three",
112
+ renderer,
113
+ camera,
114
+ scene, // WIKI-GRAPH-CLUSTER-NORELAYOUT-1: the group bubbles (bubbles3d.js) add their own meshes
115
+ /** OrbitControls on `el` (the overlay canvas, which takes the pointer), damped, zooming to the cursor. WIKI-GRAPH-3D-PORT-1
116
+ * (3D-UX-1 D1): plain drag turns, middle-drag pans (GraphCanvas3D makes a Cmd or Option/Alt drag pan too, lib/graphPress.js), the right
117
+ * button does nothing here (it stays the node menu: on mac `contextmenu` fires on press, so a right-drag pan would open
118
+ * menus); one finger turns, two fingers zoom and pan. OrbitControls' own `contextmenu` listener is removed: it
119
+ * preventDefaults every right click, which would stop the page's Radix node menu from opening (GraphCanvas3D listens itself). */
120
+ controls(el) {
121
+ controls?.dispose();
122
+ controls = new OrbitControls(camera, el);
123
+ controls.enableDamping = true;
124
+ controls.dampingFactor = 0.12;
125
+ controls.zoomToCursor = true;
126
+ controls.minDistance = 1;
127
+ controls.maxDistance = FAR / 4;
128
+ controls.mouseButtons = { LEFT: MOUSE.ROTATE, MIDDLE: MOUSE.PAN, RIGHT: -1 };
129
+ controls.touches = { ONE: TOUCH.ROTATE, TWO: TOUCH.DOLLY_PAN };
130
+ if (typeof controls._onContextMenu === "function") el.removeEventListener("contextmenu", controls._onContextMenu);
131
+ else console.warn("wiki graph: OrbitControls has no _onContextMenu (three changed): right clicks stay preventDefaulted, a page node menu will not open");
132
+ return controls;
133
+ },
134
+ /** A new node set: rebuild the node instances and the edge buffer. */
135
+ setData(sc) {
136
+ const want = sphereDetail(sc.n);
137
+ if (nodes) { scene.remove(nodes); nodes.dispose(); }
138
+ if (want !== detail) { nodeGeo?.dispose(); nodeGeo = new IcosahedronGeometry(1, want); detail = want; }
139
+ for (const m of [shells, halos]) if (m) { scene.remove(m); m.dispose(); }
140
+ shellGeo?.dispose(); haloGeo?.dispose();
141
+ shells = halos = shellGeo = haloGeo = null;
142
+ shellIdx = []; haloIdx = [];
143
+ for (let i = 0; i < sc.n; i++) {
144
+ if (sc.looks?.[i] === LOOK.FOLDER) shellIdx.push(i);
145
+ else if (sc.looks?.[i] === LOOK.HUB) haloIdx.push(i);
146
+ }
147
+ if (shellIdx.length) { shellGeo = new IcosahedronGeometry(1, shellIdx.length > 300 ? 4 : SHELL_DETAIL); shells = sphereMesh(shellGeo, shellMat, shellIdx.length); }
148
+ if (haloIdx.length) { haloGeo = new IcosahedronGeometry(1, Math.min(want, 4)); halos = sphereMesh(haloGeo, haloMat, haloIdx.length); }
149
+ nodes = new InstancedMesh(nodeGeo, nodeMat, Math.max(1, sc.n));
150
+ nodes.count = sc.n;
151
+ nodes.instanceMatrix.setUsage(DynamicDrawUsage);
152
+ nodes.frustumCulled = false; // positions move every frame; the bounding sphere three would cache goes stale
153
+ scene.add(nodes);
154
+ if (lines) { scene.remove(lines); lines.geometry.dispose(); }
155
+ linePos = new Float32Array(Math.max(6, 3 * sc.visPairs.length));
156
+ const g = new BufferGeometry();
157
+ g.setAttribute("position", new BufferAttribute(linePos, 3).setUsage(DynamicDrawUsage));
158
+ lines = new LineSegments(g, lineMat);
159
+ lines.frustumCulled = false;
160
+ for (let b = 2; b <= LINK_BUCKETS; b++) {
161
+ if (thick[b]) { scene.remove(thick[b]); thick[b].geometry.dispose(); }
162
+ thick[b] = new LineSegments2(new LineSegmentsGeometry(), thickMats[b]);
163
+ thick[b].frustumCulled = false;
164
+ thick[b].visible = false;
165
+ scene.add(thick[b]);
166
+ }
167
+ edgeOpacity(sc.edgeAlpha);
168
+ scene.add(lines);
169
+ arrows.count = 0;
170
+ restyle(sc);
171
+ relink(sc);
172
+ },
173
+ /** Colours or radii changed. */
174
+ setStyle: restyle,
175
+ /** The legend mask changed: hidden instances go to scale 0, the drawn links shrink to the visible ones. */
176
+ setVisible(sc) { reinstance(sc); relink(sc); },
177
+ /** A position frame: `sc.xyz` was rewritten in place. */
178
+ setPositions(sc) { reinstance(sc); relink(sc); },
179
+ /** The camera for this frame: `vp` (projection x view, column-major), the eye position and `focal` (px per world unit at
180
+ * distance 1), the inputs of graph3d project() and fillArrows3d. */
181
+ frame() {
182
+ camera.updateMatrixWorld();
183
+ vp.multiplyMatrices(camera.projectionMatrix, camera.matrixWorldInverse);
184
+ eye[0] = camera.position.x; eye[1] = camera.position.y; eye[2] = camera.position.z;
185
+ return { vp: vp.elements, eye, focal: (camera.projectionMatrix.elements[5] * box.h) / 2 };
186
+ },
187
+ resize(w2, h2, dpr2) {
188
+ box = { w: w2, h: h2 };
189
+ for (const m of thickMats) m?.resolution.set(w2, h2);
190
+ renderer.setPixelRatio(Math.min(dpr2 || 1, 2));
191
+ renderer.setSize(w2, h2);
192
+ camera.aspect = w2 / Math.max(1, h2);
193
+ camera.updateProjectionMatrix();
194
+ },
195
+ /** Draw one frame. `view`: {scr, focal, eye, k} from this frame's projection; arrowheads are refilled when arrowsFor
196
+ * allows them at the effective zoom `k`. */
197
+ render(sc, view) {
198
+ // edge opacity follows the links DRAWN in view (the projection, stride 3), never the node count (WIKI-GRAPH-DECLUTTER-1)
199
+ if (sc && view?.scr) edgeOpacity(edgeAlphaFor(linksInView(view.scr, sc.visPairs, sc.visCount, ID_VIEW, box.w, box.h, 3), box.w, box.h));
200
+ if (nodes && sc && view && arrowsFor(sc.visibleN ?? sc.n, view.k, sc.big)) {
201
+ arrows.count = fillArrows3d(sc, view.scr, view.focal, box.w, box.h, view.eye, arrows.instanceMatrix.array, ARROW_MARGIN);
202
+ arrows.instanceMatrix.needsUpdate = arrows.count > 0;
203
+ } else {
204
+ arrows.count = 0;
205
+ }
206
+ renderer.render(scene, camera);
207
+ return true;
208
+ },
209
+ destroy() {
210
+ controls?.dispose();
211
+ nodes?.dispose();
212
+ shells?.dispose();
213
+ halos?.dispose();
214
+ shellGeo?.dispose();
215
+ haloGeo?.dispose();
216
+ shellMat.dispose();
217
+ haloMat.dispose();
218
+ nodeGeo?.dispose();
219
+ lines?.geometry.dispose();
220
+ arrows.dispose();
221
+ cone.dispose();
222
+ nodeMat.dispose();
223
+ for (const seg of thick) seg?.geometry.dispose();
224
+ for (const m of thickMats) m?.dispose();
225
+ lineMat.dispose();
226
+ arrowMat.dispose();
227
+ renderer.dispose();
228
+ renderer.forceContextLoss();
229
+ },
230
+ };
231
+ }
@@ -0,0 +1,11 @@
1
+ import { useEffect, useState } from "react";
2
+
3
+ /** `value`, settled for `ms`: it follows `value` once `value` has stopped changing for `ms` milliseconds. */
4
+ export default function useDebounced(value, ms) {
5
+ const [settled, setSettled] = useState(value);
6
+ useEffect(() => {
7
+ const t = setTimeout(() => setSettled(value), ms);
8
+ return () => clearTimeout(t);
9
+ }, [value, ms]);
10
+ return settled;
11
+ }