@smartmemory/wiki-components 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +23 -3
- package/package.json +26 -4
- package/src/Ledger.jsx +47 -0
- package/src/data-client.d.ts +40 -1
- package/src/data-client.jsx +5 -0
- package/src/graph/components/wiki/DocCard.jsx +46 -0
- package/src/graph/components/wiki/GraphBreadcrumb.jsx +23 -0
- package/src/graph/components/wiki/GraphCanvas.jsx +663 -0
- package/src/graph/components/wiki/GraphCanvas3D.jsx +1366 -0
- package/src/graph/components/wiki/GraphDocPane.jsx +56 -0
- package/src/graph/components/wiki/GraphLegend.jsx +119 -0
- package/src/graph/components/wiki/GraphLevelPicker.jsx +35 -0
- package/src/graph/components/wiki/graph/FilterChips.jsx +45 -0
- package/src/graph/components/wiki/graph/Omnibox.jsx +74 -0
- package/src/graph/components/wiki/graph/badges.js +46 -0
- package/src/graph/components/wiki/graph/bubbles3d.js +95 -0
- package/src/graph/components/wiki/graph/canvasBase.js +138 -0
- package/src/graph/components/wiki/graph/containment.js +49 -0
- package/src/graph/components/wiki/graph/overlay.js +134 -0
- package/src/graph/components/wiki/graph/pixiBase.js +210 -0
- package/src/graph/components/wiki/graph/threeBase.js +231 -0
- package/src/graph/hooks/useDebounced.js +11 -0
- package/src/graph/host.jsx +56 -0
- package/src/graph/index.d.ts +26 -0
- package/src/graph/index.js +4 -0
- package/src/graph/lib/areaFamilies.js +32 -0
- package/src/graph/lib/camFollow.js +30 -0
- package/src/graph/lib/canvasPort.js +93 -0
- package/src/graph/lib/cardVisibility.js +58 -0
- package/src/graph/lib/docCard.js +28 -0
- package/src/graph/lib/forceWorker.js +123 -0
- package/src/graph/lib/forceWorker3d.js +136 -0
- package/src/graph/lib/graph3d.js +330 -0
- package/src/graph/lib/graphArrows.js +108 -0
- package/src/graph/lib/graphExplore.js +546 -0
- package/src/graph/lib/graphFind.js +46 -0
- package/src/graph/lib/graphLasso.js +44 -0
- package/src/graph/lib/graphLevels.js +367 -0
- package/src/graph/lib/graphOrient.js +145 -0
- package/src/graph/lib/graphPath.js +86 -0
- package/src/graph/lib/graphPin.js +48 -0
- package/src/graph/lib/graphPress.js +27 -0
- package/src/graph/lib/graphShell.js +296 -0
- package/src/graph/lib/graphTheme.js +50 -0
- package/src/graph/lib/graphView.js +343 -0
- package/src/graph/lib/graphViews.js +103 -0
- package/src/graph/lib/groupBubbles.js +128 -0
- package/src/graph/lib/hubLines.js +51 -0
- package/src/graph/lib/idleTurn.js +31 -0
- package/src/graph/lib/labelDepth.js +59 -0
- package/src/graph/lib/layoutClient.js +69 -0
- package/src/graph/lib/layoutEngine.js +184 -0
- package/src/graph/lib/linkWeight.js +67 -0
- package/src/graph/lib/orphanPull.js +10 -0
- package/src/graph/lib/perf.js +14 -0
- package/src/graph/lib/reducedMotion.js +36 -0
- package/src/graph/lib/robustFit.js +62 -0
- package/src/graph/lib/wikiGraph.js +298 -0
- package/src/graph/pages/GraphPage.jsx +703 -0
- package/src/index.d.ts +11 -2
- package/src/index.js +4 -1
- package/src/lib.js +3 -0
- package/src/parts/LedgerTimeline.jsx +20 -0
- package/src/parts/Markdown.jsx +22 -0
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
// WIKI-GRAPH-3D-1: the 3D graph view's pure helpers (no three.js, no DOM, no React), shared by GraphCanvas3D.jsx, its
|
|
2
|
+
// three.js base (components/wiki/graph/threeBase.js), the Graph page and the tests: which surface draws, 3D seeds, node
|
|
3
|
+
// radii, bounding sphere and camera distance, the screen projection that picking, labels and arrowheads all read, the
|
|
4
|
+
// instance matrices, the link segments and the arrowhead cones.
|
|
5
|
+
//
|
|
6
|
+
// Matrices are three.js layout (column-major Float32Array(16)): `vp` = projection x view. A projected node is
|
|
7
|
+
// [sx, sy, cw] in CSS px plus `cw`, its clip w (the distance in front of the camera); cw <= 0 is behind the camera.
|
|
8
|
+
import { CORE, LOOK, folderRadius, levelSize } from "./graphView";
|
|
9
|
+
import { containPairs } from "../components/wiki/graph/containment";
|
|
10
|
+
import { springPairs } from "./hubLines";
|
|
11
|
+
import { serverFits } from "./wikiGraph";
|
|
12
|
+
import { ARROW, ARROW_CAP, MUTUAL, TO_SOURCE, TO_TARGET, headOn, hotHeads } from "./graphArrows";
|
|
13
|
+
|
|
14
|
+
export const FOV = 50; // vertical field of view, degrees
|
|
15
|
+
export const PICK_MIN_PX = 6; // the 2D quadtree's tolerance: a pick lands within max(6 px, the drawn radius)
|
|
16
|
+
// Sphere smoothness by node count (WIKI-GRAPH-3D-COLOR-SPHERES-1): three's IcosahedronGeometry(1, d) has 20*(d+1)^2 triangles with
|
|
17
|
+
// smooth normals, so the "polygon" look is only the silhouette. One row per size class; the thresholds are set by the frame
|
|
18
|
+
// harness (report), the folder shell is always SHELL_DETAIL (few of them, drawn big).
|
|
19
|
+
export const SPHERE_ROWS = [[2000, 6], [8000, 3], [20000, 2]]; // [up to this many nodes, detail]; above the last row: SPHERE_FLOOR
|
|
20
|
+
export const SPHERE_FLOOR = 1;
|
|
21
|
+
export const SHELL_DETAIL = 8;
|
|
22
|
+
export const sphereDetail = (n) => (SPHERE_ROWS.find(([max]) => n <= max) || [0, SPHERE_FLOOR])[1];
|
|
23
|
+
export const OFF = -1e6; // where a node behind the camera "projects": off every screen, so labels and rings skip it
|
|
24
|
+
const GOLDEN = Math.PI * (3 - Math.sqrt(5));
|
|
25
|
+
|
|
26
|
+
/** Which surface draws the graph: "3d" by default when the browser has WebGL2, "2d" for `?view=2d`, for the Canvas2D
|
|
27
|
+
* override (`?renderer=canvas`) and without WebGL2. */
|
|
28
|
+
export function chooseView({ param = null, webgl2 = false, forced = null }) {
|
|
29
|
+
if (param === "2d" || forced === "canvas") return "2d";
|
|
30
|
+
return webgl2 ? "3d" : "2d";
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Point `i` of `n` spread evenly over the unit sphere (Fibonacci lattice). */
|
|
34
|
+
export function fibonacciSphere(i, n) {
|
|
35
|
+
const y = n <= 1 ? 0 : 1 - (2 * (i + 0.5)) / n, r = Math.sqrt(Math.max(0, 1 - y * y)), a = i * GOLDEN;
|
|
36
|
+
return [Math.cos(a) * r, y, Math.sin(a) * r];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Deterministic per-index uniform in [0, 1) (the same hash as seedPositions' 2D disc), so a reload lays out the same way.
|
|
40
|
+
const rnd = (i) => {
|
|
41
|
+
let h = Math.imul(i + 1, 0x9e3779b1) ^ 0x85ebca6b;
|
|
42
|
+
h = Math.imul(h ^ (h >>> 15), 0x2c1b3c6d);
|
|
43
|
+
h = Math.imul(h ^ (h >>> 12), 0x297a2d39);
|
|
44
|
+
return ((h ^ (h >>> 15)) >>> 0) / 4294967296;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/** The radius of the seed ball (and of the ring of group centres) for `n` docs. */
|
|
48
|
+
export const seedRadius = (n) => Math.max(80, 14 * Math.cbrt(n));
|
|
49
|
+
|
|
50
|
+
/** Starting positions for `nodes` in 3D, without touching the nodes: {xyz, targets, warm}. A node with a cached position
|
|
51
|
+
* (`cache`: id -> [x, y, z], this surface's earlier layout) starts there. A level-view child whose `parent` folder is
|
|
52
|
+
* cached starts at the folder, fanned out a little, so opening a folder grows out of it. Everything else: clustered, group
|
|
53
|
+
* centres on a Fibonacci sphere and each group's docs in a small ball round it (targets = the centre); not clustered, a
|
|
54
|
+
* deterministic point in one ball (targets 0, 0, 0). A node with neither, in a set that is the whole graph the host served a 3D
|
|
55
|
+
* layout for (`nd.warm3`, wikiGraph serveLayout and serverFits), starts at its served position: a settled start, the first
|
|
56
|
+
* visit's wait gone (WIKI-GRAPH-WARMSTART-3D-1). `warm`: any node was placed from the cache or the served layout. */
|
|
57
|
+
export function seed3d(nodes, { cluster = false, cache = null } = {}) {
|
|
58
|
+
const n = nodes.length, xyz = new Float32Array(Math.max(3, 3 * n)), targets = new Float32Array(3 * n);
|
|
59
|
+
const R = seedRadius(n);
|
|
60
|
+
const groups = [...new Set(nodes.map((nd) => nd.group))];
|
|
61
|
+
const centre = new Map(groups.map((g, i) => {
|
|
62
|
+
if (!cluster || groups.length === 1) return [g, [0, 0, 0]];
|
|
63
|
+
const [x, y, z] = fibonacciSphere(i, groups.length);
|
|
64
|
+
return [g, [x * R, y * R, z * R]];
|
|
65
|
+
}));
|
|
66
|
+
const seen = new Map(), born = new Map();
|
|
67
|
+
const served = serverFits(nodes, 3);
|
|
68
|
+
let warm = false;
|
|
69
|
+
for (let i = 0; i < n; i++) {
|
|
70
|
+
const nd = nodes[i], c = centre.get(nd.group);
|
|
71
|
+
targets[3 * i] = c[0]; targets[3 * i + 1] = c[1]; targets[3 * i + 2] = c[2];
|
|
72
|
+
let p = cache?.get(nd.id);
|
|
73
|
+
if (!p && nd.parent && cache?.has(nd.parent)) { // a folder was just opened: its children start where it was
|
|
74
|
+
const q = cache.get(nd.parent), j = (born.get(nd.parent) || 0) + 1, [ux, uy, uz] = fibonacciSphere(j, j + 8), r = 6 * Math.cbrt(j);
|
|
75
|
+
born.set(nd.parent, j);
|
|
76
|
+
p = [q[0] + ux * r, q[1] + uy * r, q[2] + uz * r];
|
|
77
|
+
}
|
|
78
|
+
if (!p && served) p = nd.warm3;
|
|
79
|
+
if (p) { xyz[3 * i] = p[0]; xyz[3 * i + 1] = p[1]; xyz[3 * i + 2] = p[2]; warm = true; continue; }
|
|
80
|
+
if (cluster) {
|
|
81
|
+
const k = seen.get(nd.group) || 0, [ux, uy, uz] = fibonacciSphere(k, k + 16), r = 6 * Math.cbrt(k + 0.5);
|
|
82
|
+
seen.set(nd.group, k + 1);
|
|
83
|
+
xyz[3 * i] = c[0] + ux * r; xyz[3 * i + 1] = c[1] + uy * r; xyz[3 * i + 2] = c[2] + uz * r;
|
|
84
|
+
} else {
|
|
85
|
+
const r = R * Math.cbrt(rnd(3 * i)), y = 1 - 2 * rnd(3 * i + 1), s = Math.sqrt(1 - y * y), a = 2 * Math.PI * rnd(3 * i + 2);
|
|
86
|
+
xyz[3 * i] = Math.cos(a) * s * r; xyz[3 * i + 1] = y * r; xyz[3 * i + 2] = Math.sin(a) * s * r;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return { xyz, targets, warm };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export { folderRadius }; // WIKI-GRAPH-P0-LEVEL0-ZOOM-1: log-scaled and capped, shared with 2D (graphView.js)
|
|
93
|
+
|
|
94
|
+
/** A node's world radius: half its level-view diameter (graphView levelSize: a folder by its docs, a doc by its links);
|
|
95
|
+
* all the smallest size with "Size by links" off. */
|
|
96
|
+
export function nodeRadius(node, sizeByDegree = true) {
|
|
97
|
+
return levelSize(node, sizeByDegree) / 2;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Bounding sphere of the nodes at indices `idx`: centroid and the largest distance from it, to the far side of each
|
|
101
|
+
* node's sphere when `radius` (per node index) is given, so a fit shows big spheres whole. Null when `idx` is empty. */
|
|
102
|
+
export function fitSphere(xyz, idx, radius = null) {
|
|
103
|
+
let cx = 0, cy = 0, cz = 0, c = 0;
|
|
104
|
+
for (const i of idx) { cx += xyz[3 * i]; cy += xyz[3 * i + 1]; cz += xyz[3 * i + 2]; c++; }
|
|
105
|
+
if (!c) return null;
|
|
106
|
+
cx /= c; cy /= c; cz /= c;
|
|
107
|
+
let far = 0;
|
|
108
|
+
for (const i of idx) {
|
|
109
|
+
const dx = xyz[3 * i] - cx, dy = xyz[3 * i + 1] - cy, dz = xyz[3 * i + 2] - cz;
|
|
110
|
+
const d = Math.sqrt(dx * dx + dy * dy + dz * dz) + (radius ? radius[i] : 0);
|
|
111
|
+
if (d > far) far = d;
|
|
112
|
+
}
|
|
113
|
+
return { x: cx, y: cy, z: cz, r: far };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Camera distance that shows a sphere of radius `r` whole, with `pad` (1.1 = 10% margin), at vertical FOV `fov` degrees
|
|
117
|
+
* and `aspect` w/h (the narrower of the two FOVs decides). */
|
|
118
|
+
export function fitDistance(r, fov = FOV, aspect = 1, pad = 1.1) {
|
|
119
|
+
const v = (fov * Math.PI) / 360, hz = Math.atan(Math.tan(v) * aspect), half = Math.min(v, hz);
|
|
120
|
+
return (Math.max(r, 1) * pad) / Math.sin(half);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Project nodes 0..n-1 through `vp` onto a w x h CSS px screen: `scr` [sx, sy, cw] per node, `rpx` the drawn radius in
|
|
124
|
+
* px (`focal` = projection[5] * h / 2, px per world unit at distance 1). A node behind the camera gets cw <= 0 and OFF. */
|
|
125
|
+
export function project(xyz, radius, n, vp, focal, w, h, scr, rpx) {
|
|
126
|
+
const e = vp;
|
|
127
|
+
for (let i = 0; i < n; i++) {
|
|
128
|
+
const x = xyz[3 * i], y = xyz[3 * i + 1], z = xyz[3 * i + 2];
|
|
129
|
+
const cw = e[3] * x + e[7] * y + e[11] * z + e[15];
|
|
130
|
+
if (cw <= 1e-6) { scr[3 * i] = OFF; scr[3 * i + 1] = OFF; scr[3 * i + 2] = cw; rpx[i] = 0; continue; }
|
|
131
|
+
scr[3 * i] = ((e[0] * x + e[4] * y + e[8] * z + e[12]) / cw + 1) * w / 2;
|
|
132
|
+
scr[3 * i + 1] = (1 - (e[1] * x + e[5] * y + e[9] * z + e[13]) / cw) * h / 2;
|
|
133
|
+
scr[3 * i + 2] = cw;
|
|
134
|
+
rpx[i] = (radius[i] * focal) / cw;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** The node under screen point (x, y), or -1: among the visible indices `vis`, those in front of the camera whose
|
|
139
|
+
* projected centre is within max(PICK_MIN_PX, drawn radius), the front-most (smallest cw). */
|
|
140
|
+
export function pickProjected(scr, rpx, vis, x, y) {
|
|
141
|
+
let best = -1, bz = Infinity;
|
|
142
|
+
for (const i of vis) {
|
|
143
|
+
const cw = scr[3 * i + 2];
|
|
144
|
+
if (cw <= 1e-6 || cw >= bz) continue;
|
|
145
|
+
const r = Math.max(PICK_MIN_PX, rpx[i]), dx = scr[3 * i] - x, dy = scr[3 * i + 1] - y;
|
|
146
|
+
if (dx * dx + dy * dy <= r * r) { bz = cw; best = i; }
|
|
147
|
+
}
|
|
148
|
+
return best;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Every node under screen point (x, y), front to back (WIKI-GRAPH-3D-PORT-1: the Alt-click depth cycle and the hover card's
|
|
152
|
+
* "+k behind"): the same hit test as pickProjected, so its first entry is pickProjected's answer. */
|
|
153
|
+
export function pickStack(scr, rpx, vis, x, y) {
|
|
154
|
+
const hits = [];
|
|
155
|
+
for (const i of vis) {
|
|
156
|
+
const cw = scr[3 * i + 2];
|
|
157
|
+
if (cw <= 1e-6) continue;
|
|
158
|
+
const r = Math.max(PICK_MIN_PX, rpx[i]), dx = scr[3 * i] - x, dy = scr[3 * i + 1] - y;
|
|
159
|
+
if (dx * dx + dy * dy <= r * r) hits.push(i);
|
|
160
|
+
}
|
|
161
|
+
return hits.sort((a, b) => scr[3 * a + 2] - scr[3 * b + 2] || a - b);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Instance matrices (16 floats each, column-major) for nodes 0..n-1: uniform scale = radius, translation = position.
|
|
165
|
+
* A node hidden by `mask` (0) gets scale 0, so it draws nothing and the instance count never changes. `looks` (optional):
|
|
166
|
+
* a folder's solid core is CORE x its radius (its shell is drawn by writeSubset). */
|
|
167
|
+
export function writeInstances(mat, xyz, radius, mask, n, looks = null) {
|
|
168
|
+
for (let i = 0; i < n; i++) {
|
|
169
|
+
const b = 16 * i, r = !mask || mask[i] ? radius[i] * (looks && looks[i] === LOOK.FOLDER ? CORE : 1) : 0;
|
|
170
|
+
mat[b] = r; mat[b + 1] = 0; mat[b + 2] = 0; mat[b + 3] = 0;
|
|
171
|
+
mat[b + 4] = 0; mat[b + 5] = r; mat[b + 6] = 0; mat[b + 7] = 0;
|
|
172
|
+
mat[b + 8] = 0; mat[b + 9] = 0; mat[b + 10] = r; mat[b + 11] = 0;
|
|
173
|
+
mat[b + 12] = xyz[3 * i]; mat[b + 13] = xyz[3 * i + 1]; mat[b + 14] = xyz[3 * i + 2]; mat[b + 15] = 1;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Instance matrices for the nodes `idx` only (a folder's shell, a hub's halo): instance j is node idx[j], scaled by
|
|
178
|
+
* radius x `factor`; 0 when masked out. */
|
|
179
|
+
export function writeSubset(mat, xyz, radius, mask, idx, factor = 1) {
|
|
180
|
+
for (let j = 0; j < idx.length; j++) {
|
|
181
|
+
const i = idx[j], b = 16 * j, r = !mask || mask[i] ? radius[i] * factor : 0;
|
|
182
|
+
mat[b] = r; mat[b + 1] = 0; mat[b + 2] = 0; mat[b + 3] = 0;
|
|
183
|
+
mat[b + 4] = 0; mat[b + 5] = r; mat[b + 6] = 0; mat[b + 7] = 0;
|
|
184
|
+
mat[b + 8] = 0; mat[b + 9] = 0; mat[b + 10] = r; mat[b + 11] = 0;
|
|
185
|
+
mat[b + 12] = xyz[3 * i]; mat[b + 13] = xyz[3 * i + 1]; mat[b + 14] = xyz[3 * i + 2]; mat[b + 15] = 1;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Line segment ends (6 floats a link) for the first `count` index pairs of `pairs` into `out`. Returns `count`. */
|
|
190
|
+
export function writeLinkPositions(out, xyz, pairs, count) {
|
|
191
|
+
for (let e = 0; e < count; e++) {
|
|
192
|
+
const a = 3 * pairs[2 * e], b = 3 * pairs[2 * e + 1], o = 6 * e;
|
|
193
|
+
out[o] = xyz[a]; out[o + 1] = xyz[a + 1]; out[o + 2] = xyz[a + 2];
|
|
194
|
+
out[o + 3] = xyz[b]; out[o + 4] = xyz[b + 1]; out[o + 5] = xyz[b + 2];
|
|
195
|
+
}
|
|
196
|
+
return count;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Line segment ends (6 floats a link) into `out` for the links `idx[from..to)` (link numbers, a linkWeight.js bucket), in that
|
|
200
|
+
* order. Returns `out`. */
|
|
201
|
+
export function writeLinkPositionsBy(out, xyz, pairs, idx, from, to) {
|
|
202
|
+
for (let j = from; j < to; j++) {
|
|
203
|
+
const e = 2 * idx[j], a = 3 * pairs[e], b = 3 * pairs[e + 1], o = 6 * (j - from);
|
|
204
|
+
out[o] = xyz[a]; out[o + 1] = xyz[a + 1]; out[o + 2] = xyz[a + 2];
|
|
205
|
+
out[o + 3] = xyz[b]; out[o + 4] = xyz[b + 1]; out[o + 5] = xyz[b + 2];
|
|
206
|
+
}
|
|
207
|
+
return out;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** One cone arrowhead from node a to node b as an instance matrix written at `mat[o..o+15]`: the cone's apex sits on b's
|
|
211
|
+
* sphere (`rb` world units from its centre), it points along a -> b and is ARROW.len x 2*ARROW.half screen px at b's
|
|
212
|
+
* depth (`pxW` = world units per px there). `side` (+1, 0) moves a mutual link's head off the shared line, sideways on
|
|
213
|
+
* screen (`eye`: the camera position), so the two heads of a pair never overlap. The cone geometry has its apex at the
|
|
214
|
+
* origin and its base at y = -1, radius 1. False when there is no room between the spheres for a head. */
|
|
215
|
+
export function arrowMatrix(xyz, a, b, ra, rb, pxW, side, eye, mat, o) {
|
|
216
|
+
const ax = xyz[3 * a], ay = xyz[3 * a + 1], az = xyz[3 * a + 2], bx = xyz[3 * b], by = xyz[3 * b + 1], bz = xyz[3 * b + 2];
|
|
217
|
+
let ux = bx - ax, uy = by - ay, uz = bz - az;
|
|
218
|
+
const d = Math.hypot(ux, uy, uz), len = ARROW.len * pxW, half = ARROW.half * pxW;
|
|
219
|
+
if (!(d - ra - rb > len)) return false;
|
|
220
|
+
ux /= d; uy /= d; uz /= d;
|
|
221
|
+
// p: any unit vector across the link; q = p x u completes a right-handed frame (p, u, q), so the cone's faces keep their
|
|
222
|
+
// winding (a mirrored matrix would show its back faces). The cone is round, so which p does not matter.
|
|
223
|
+
let px = Math.abs(ux) < 0.9 ? 0 : -uz, py = Math.abs(ux) < 0.9 ? uz : 0, pz = Math.abs(ux) < 0.9 ? -uy : ux; // u x (1,0,0) or u x (0,1,0)
|
|
224
|
+
const pl = Math.hypot(px, py, pz); px /= pl; py /= pl; pz /= pl;
|
|
225
|
+
const qx = py * uz - pz * uy, qy = pz * ux - px * uz, qz = px * uy - py * ux;
|
|
226
|
+
let tx = bx - ux * rb, ty = by - uy * rb, tz = bz - uz * rb;
|
|
227
|
+
if (side) { // sideways on screen: across both the link and the line of sight
|
|
228
|
+
const vx = tx - eye[0], vy = ty - eye[1], vz = tz - eye[2];
|
|
229
|
+
let sx = uy * vz - uz * vy, sy = uz * vx - ux * vz, sz = ux * vy - uy * vx;
|
|
230
|
+
const sl = Math.hypot(sx, sy, sz);
|
|
231
|
+
if (sl > 0) { const f = (side * ARROW.offset * pxW) / sl; sx *= f; sy *= f; sz *= f; tx += sx; ty += sy; tz += sz; }
|
|
232
|
+
}
|
|
233
|
+
mat[o] = px * half; mat[o + 1] = py * half; mat[o + 2] = pz * half; mat[o + 3] = 0;
|
|
234
|
+
mat[o + 4] = ux * len; mat[o + 5] = uy * len; mat[o + 6] = uz * len; mat[o + 7] = 0;
|
|
235
|
+
mat[o + 8] = qx * half; mat[o + 9] = qy * half; mat[o + 10] = qz * half; mat[o + 11] = 0;
|
|
236
|
+
mat[o + 12] = tx; mat[o + 13] = ty; mat[o + 14] = tz; mat[o + 15] = 1;
|
|
237
|
+
return true;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** The arrowheads of one frame as cone instance matrices into `mat` (room for `cap`). The 2D rules, applied to the
|
|
241
|
+
* projection (`scr`, from project()): a link gets heads only when both ends are in front of the camera, it touches the
|
|
242
|
+
* screen grown by `margin` px, and it is at least ARROW.minLink px long on screen; heads at the target end (TO_TARGET), at
|
|
243
|
+
* the source end too for a level link with counts both ways (TO_SOURCE), offset when MUTUAL, never on a hub (headOn,
|
|
244
|
+
* WIKI-GRAPH-DECLUTTER-1). `sc`: xyz, radius, visPairs, visDirs, visCount, degree (optional). `focal`: px per world unit at distance 1. Returns how many heads were written. */
|
|
245
|
+
export function fillArrows3d(sc, scr, focal, w, h, eye, mat, margin = 0, cap = ARROW_CAP) {
|
|
246
|
+
const { xyz, radius, visPairs, visDirs, visCount, degree } = sc;
|
|
247
|
+
const x0 = -margin, y0 = -margin, x1 = w + margin, y1 = h + margin;
|
|
248
|
+
let t = 0;
|
|
249
|
+
for (let e = 0; e < visCount && t < cap; e++) {
|
|
250
|
+
const a = visPairs[2 * e], b = visPairs[2 * e + 1];
|
|
251
|
+
const wa = scr[3 * a + 2], wb = scr[3 * b + 2];
|
|
252
|
+
if (wa <= 1e-6 || wb <= 1e-6) continue;
|
|
253
|
+
const sax = scr[3 * a], say = scr[3 * a + 1], sbx = scr[3 * b], sby = scr[3 * b + 1];
|
|
254
|
+
if ((sax < x0 && sbx < x0) || (sax > x1 && sbx > x1) || (say < y0 && sby < y0) || (say > y1 && sby > y1)) continue;
|
|
255
|
+
if (!(Math.hypot(sbx - sax, sby - say) >= ARROW.minLink)) continue;
|
|
256
|
+
const f = visDirs ? visDirs[e] : TO_TARGET, side = f & MUTUAL ? 1 : 0;
|
|
257
|
+
if ((f & TO_TARGET) && (!degree || headOn(degree[b])) && arrowMatrix(xyz, a, b, radius[a], radius[b], wb / focal, side, eye, mat, 16 * t)) t++;
|
|
258
|
+
if (t < cap && (f & TO_SOURCE) && (!degree || headOn(degree[a])) && arrowMatrix(xyz, b, a, radius[b], radius[a], wa / focal, side, eye, mat, 16 * t)) t++;
|
|
259
|
+
}
|
|
260
|
+
return t;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const endOf = (end) => (typeof end === "object" && end ? end.id : end);
|
|
264
|
+
|
|
265
|
+
/** Does `graph` hold the same node ids (in order) and the same drawn index pairs (in order) as the scene (`ids`, `index`,
|
|
266
|
+
* `pairs`)? Only then may a new graph object recolour in place; any change to the nodes or links rebuilds (the 2D rule,
|
|
267
|
+
* WIKI-GRAPH-REFRESH-FU-1). */
|
|
268
|
+
export function sameTopology(ids, index, pairs, graph) {
|
|
269
|
+
const { nodes, links } = graph;
|
|
270
|
+
if (nodes.length !== ids.length || !nodes.every((n, i) => n.id === ids[i])) return false;
|
|
271
|
+
let k = 0;
|
|
272
|
+
for (const l of links) {
|
|
273
|
+
const a = index.get(endOf(l.source)), b = index.get(endOf(l.target));
|
|
274
|
+
if (a === undefined || b === undefined) continue;
|
|
275
|
+
if (pairs[k++] !== a || pairs[k++] !== b) return false;
|
|
276
|
+
}
|
|
277
|
+
return k === pairs.length;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** The link arrays the scene draws for `links` over nodes indexed by `index` (n nodes): index `pairs`, direction flags
|
|
281
|
+
* `dirs` (graphArrows dirOf, plus MUTUAL when the reverse link exists) and the incidence lists (CSR `off`/`adj`) that make
|
|
282
|
+
* a node's neighbours a slice, and `weights` (one per link, 1 for a flat link). Links with an end outside the set are skipped. */
|
|
283
|
+
export function linkArrays(links, index, n, dirOf) {
|
|
284
|
+
const pairs = new Uint32Array(2 * links.length), count = new Uint32Array(n + 1), dirs = new Uint8Array(links.length);
|
|
285
|
+
const weights = new Float32Array(links.length).fill(1);
|
|
286
|
+
const seen = new Set();
|
|
287
|
+
let k = 0;
|
|
288
|
+
for (const l of links) {
|
|
289
|
+
const a = index.get(endOf(l.source)), b = index.get(endOf(l.target));
|
|
290
|
+
if (a === undefined || b === undefined) continue;
|
|
291
|
+
dirs[k / 2] = dirOf(l);
|
|
292
|
+
if (l.weight > 1) weights[k / 2] = l.weight; // an aggregated folder link's crossings (linkWeight.js); a flat link is 1
|
|
293
|
+
seen.add(a * n + b);
|
|
294
|
+
pairs[k++] = a; pairs[k++] = b;
|
|
295
|
+
count[a + 1]++; count[b + 1]++;
|
|
296
|
+
}
|
|
297
|
+
for (let e = 0; e < k; e += 2) if (seen.has(pairs[e + 1] * n + pairs[e])) dirs[e / 2] |= MUTUAL;
|
|
298
|
+
const used = pairs.subarray(0, k);
|
|
299
|
+
for (let i = 0; i < n; i++) count[i + 1] += count[i];
|
|
300
|
+
const off = count.slice(), adj = new Uint32Array(k), fill = count.slice(0, n);
|
|
301
|
+
for (let e = 0; e < k; e += 2) { adj[fill[used[e]]++] = used[e + 1]; adj[fill[used[e + 1]]++] = used[e]; }
|
|
302
|
+
return { pairs: used, dirs: dirs.subarray(0, k / 2), weights: weights.subarray(0, k / 2), off, adj };
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** WIKI-GRAPH-RENDER-FU-A: a same-topology refresh can change only the containment pairs (`graph.contains`, a hub's folder members), which
|
|
306
|
+
* `sameTopology` ignores. Store the new `cpairs` (the dashed lines draw from `s.cpairs`) and, when they differ, send the layout its
|
|
307
|
+
* new containment springs after the `s.pairs` links, no reheat (like the weights). Returns whether they changed. */
|
|
308
|
+
export function refreshContain(s, cpairs) {
|
|
309
|
+
const old = s.cpairs, same = !!old && old.length === cpairs.length && old.every((v, i) => v === cpairs[i]);
|
|
310
|
+
s.cpairs = cpairs; // the dashed lines redraw from this
|
|
311
|
+
if (!same) s.layout?.contain(springPairs(cpairs), s.pairs.length / 2); // and the layout's containment springs follow (a giant hub has none, hubLines), no reheat
|
|
312
|
+
return !same;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** WIKI-GRAPH-3D-ARROW-REFRESH-FU-1 (both canvases): the state a same-topology refresh (a new graph object, same node ids and
|
|
316
|
+
* same drawn pairs) must rebuild because the new graph can change it without changing the layout key: the per-link direction
|
|
317
|
+
* flags (a level link's counts each way, so one head becomes two), the per-link weights (WIKI-GRAPH-LEVEL-WEIGHTS-FU-1: width
|
|
318
|
+
* and layout strength; the caller re-masks for the drawn widths and tells the layout), the heads of the focused or hovered doc derived from
|
|
319
|
+
* them, the containment pairs (refreshContain), and the cached label widths of any doc whose title changed. `s`: ids/index/pairs/titles/labelW/focus/hover scene
|
|
320
|
+
* state; the caller re-masks afterwards, which copies `s.dirs` into the drawn flags. */
|
|
321
|
+
export function refreshSameTopology(s, graph, dirOf) {
|
|
322
|
+
const { nodes, links } = graph;
|
|
323
|
+
const arrays = linkArrays(links, s.index, nodes.length, dirOf);
|
|
324
|
+
s.dirs = arrays.dirs;
|
|
325
|
+
s.weights = arrays.weights;
|
|
326
|
+
refreshContain(s, containPairs(graph.contains, s.index));
|
|
327
|
+
for (let i = 0; i < nodes.length; i++) if (nodes[i].title !== s.titles[i]) s.labelW[i] = NaN;
|
|
328
|
+
s.titles = nodes.map((nd) => nd.title);
|
|
329
|
+
s.hotHeads = s.focus >= 0 || s.hover >= 0 ? hotHeads(s.pairs, s.dirs, [s.focus, s.hover]) : null;
|
|
330
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// WIKI-GRAPH-ARROWS-1: arrowheads on graph links, as pure geometry shared by both base layers (pixiBase.js on the GPU,
|
|
2
|
+
// canvasBase.js on Canvas2D). Links are directed (`LINKS_TO`, source -> target); a level view's folder -> folder link is one
|
|
3
|
+
// line per pair carrying a count each way, drawn with a head at each end that has one. Heads are a fixed size on screen, sit
|
|
4
|
+
// at the node's drawn radius, are hidden below a zoom threshold (like labels) and culled to the view with a per-frame cap.
|
|
5
|
+
import { LABEL_ALL } from "./wikiGraph";
|
|
6
|
+
import { drawRadius } from "./graphView";
|
|
7
|
+
|
|
8
|
+
/** Head size and spacing, screen px. `minLink`: a link drawn shorter than this on screen gets no head. `offset`: how far a
|
|
9
|
+
* mutual link's heads move off the shared line (to opposite sides) so they never sit on top of each other. */
|
|
10
|
+
export const ARROW = { len: 8, half: 3.5, minLink: 16, offset: 3 };
|
|
11
|
+
/** Never more heads than this in one frame (like LABEL_CAP for labels). */
|
|
12
|
+
export const ARROW_CAP = 3000;
|
|
13
|
+
|
|
14
|
+
/** Per-link flags: head at the target end, head at the source end, a mutual flat link (offset its head). */
|
|
15
|
+
export const TO_TARGET = 1, TO_SOURCE = 2, MUTUAL = 4;
|
|
16
|
+
|
|
17
|
+
/** WIKI-GRAPH-DECLUTTER-1: no head is drawn on a node with more links than this (a hub), where heads would stack into a grey
|
|
18
|
+
* clump; the overlay draws the heads of the focused or hovered doc's links instead (hotHeads, fillHotArrows). */
|
|
19
|
+
export const ARROW_HUB_DEGREE = 8;
|
|
20
|
+
|
|
21
|
+
/** May the base layer put a head on node `tip` (degree `tipDegree`)? Not on a hub. `lit`: the link touches the focus or the
|
|
22
|
+
* hover, which always gets its heads. */
|
|
23
|
+
export const headOn = (tipDegree, lit = false) => lit || !(tipDegree > ARROW_HUB_DEGREE);
|
|
24
|
+
|
|
25
|
+
/** Do heads draw at all for `count` nodes at zoom `k`? Few nodes: always; more: from the same zoom labels turn on at
|
|
26
|
+
* (1, or 2 on a big graph, lodFor in wikiGraph.js), because below that the links are too short and dense to read a head on. */
|
|
27
|
+
export function arrowsFor(count, k, big) {
|
|
28
|
+
return count <= LABEL_ALL || k >= (big ? 2 : 1);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The flags of one link: `l.ab` / `l.ba` (a level view's counts each way) when present, else a flat link: head at its target. */
|
|
32
|
+
export function dirOf(l) {
|
|
33
|
+
if (l.ab === undefined && l.ba === undefined) return TO_TARGET;
|
|
34
|
+
return (l.ab > 0 ? TO_TARGET : 0) | (l.ba > 0 ? TO_SOURCE : 0);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** One head pointing from (ax,ay) to (bx,by), its tip at node b's edge (`rb` world units from b's centre), written as 3
|
|
38
|
+
* points (6 floats) into `out` at `o`. `side` (+1/-1/0) moves it off the line. False when the link is too short on screen
|
|
39
|
+
* (< ARROW.minLink) or has no room between the two nodes (`ra`, `rb`) for a head. */
|
|
40
|
+
export function arrowTriangle(ax, ay, bx, by, ra, rb, k, side, out, o) {
|
|
41
|
+
const dx = bx - ax, dy = by - ay, d = Math.hypot(dx, dy);
|
|
42
|
+
if (!(d * k >= ARROW.minLink) || d - ra - rb <= ARROW.len / k) return false;
|
|
43
|
+
const ux = dx / d, uy = dy / d, px = -uy, py = ux, off = (side * ARROW.offset) / k;
|
|
44
|
+
const tx = bx - ux * rb + px * off, ty = by - uy * rb + py * off;
|
|
45
|
+
const cx = tx - (ux * ARROW.len) / k, cy = ty - (uy * ARROW.len) / k, h = ARROW.half / k;
|
|
46
|
+
out[o] = tx; out[o + 1] = ty;
|
|
47
|
+
out[o + 2] = cx + px * h; out[o + 3] = cy + py * h;
|
|
48
|
+
out[o + 4] = cx - px * h; out[o + 5] = cy - py * h;
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The heads of the drawn links that touch the view, as triangles (6 floats each) in world units into `out` (room for
|
|
53
|
+
* ARROW_CAP), never on a hub (headOn). `sc`: pos, sizes, big, visPairs, visDirs, visCount, degree (optional); `view` {x, y, k}; the view box is w x h screen px grown by
|
|
54
|
+
* `margin` px each side. Returns how many triangles were written. */
|
|
55
|
+
export function fillArrows(sc, view, w, h, out, margin = 0, cap = ARROW_CAP) {
|
|
56
|
+
const { pos, sizes, big, visPairs, visDirs, visCount, degree } = sc;
|
|
57
|
+
const k = view.k;
|
|
58
|
+
const x0 = (-margin - view.x) / k, y0 = (-margin - view.y) / k, x1 = (w + margin - view.x) / k, y1 = (h + margin - view.y) / k;
|
|
59
|
+
let t = 0;
|
|
60
|
+
for (let e = 0; e < visCount && t < cap; e++) {
|
|
61
|
+
const a = visPairs[2 * e], b = visPairs[2 * e + 1];
|
|
62
|
+
const ax = pos[2 * a], ay = pos[2 * a + 1], bx = pos[2 * b], by = pos[2 * b + 1];
|
|
63
|
+
if ((ax < x0 && bx < x0) || (ax > x1 && bx > x1) || (ay < y0 && by < y0) || (ay > y1 && by > y1)) continue;
|
|
64
|
+
const f = visDirs ? visDirs[e] : TO_TARGET, side = f & MUTUAL ? 1 : 0;
|
|
65
|
+
const ra = drawRadius(sizes[a], k, big), rb = drawRadius(sizes[b], k, big);
|
|
66
|
+
if ((f & TO_TARGET) && (!degree || headOn(degree[b])) && arrowTriangle(ax, ay, bx, by, ra, rb, k, side, out, 6 * t)) t++;
|
|
67
|
+
if (t < cap && (f & TO_SOURCE) && (!degree || headOn(degree[a])) && arrowTriangle(bx, by, ax, ay, rb, ra, k, side, out, 6 * t)) t++;
|
|
68
|
+
}
|
|
69
|
+
return t;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The heads of the links touching the docs in `hot` (the focus and the hover; -1 is skipped), as [from, to, side]
|
|
73
|
+
* triples, one per head: a flat link's head points at its target, a level link's at each end with a count. `pairs` and
|
|
74
|
+
* `dirs`: every link of the scene (the overlay checks the legend mask when it draws). Computed when the lit set changes. */
|
|
75
|
+
export function hotHeads(pairs, dirs, hot) {
|
|
76
|
+
const out = [], m = pairs.length / 2;
|
|
77
|
+
const isHot = (i) => hot.includes(i);
|
|
78
|
+
for (let e = 0; e < m; e++) {
|
|
79
|
+
const a = pairs[2 * e], b = pairs[2 * e + 1];
|
|
80
|
+
if (!isHot(a) && !isHot(b)) continue;
|
|
81
|
+
const f = dirs ? dirs[e] : TO_TARGET, side = f & MUTUAL ? 1 : 0;
|
|
82
|
+
if (f & TO_TARGET) out.push(a, b, side);
|
|
83
|
+
if (f & TO_SOURCE) out.push(b, a, side);
|
|
84
|
+
}
|
|
85
|
+
return out;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Screen-space triangles (6 floats each, px) into `out` for the `heads` triples: `at(i)` gives [sx, sy, rPx] (rPx = the
|
|
89
|
+
* drawn radius, so a head's tip touches the ring the overlay draws). A head whose ends are hidden (`mask`), or that would
|
|
90
|
+
* sit within one head's width of another head on the same node, is skipped, so heads never stack even on a hub. Returns
|
|
91
|
+
* how many were written (at most `cap`). */
|
|
92
|
+
export function fillHotArrows(heads, at, mask, out, cap = ARROW_CAP) {
|
|
93
|
+
const seen = new Map(); // tip -> angles of the heads already on it
|
|
94
|
+
let t = 0;
|
|
95
|
+
for (let h = 0; h < heads.length && t < cap; h += 3) {
|
|
96
|
+
const a = heads[h], b = heads[h + 1], side = heads[h + 2];
|
|
97
|
+
if (mask && (!mask[a] || !mask[b])) continue;
|
|
98
|
+
const [ax, ay, ra] = at(a), [bx, by, rb] = at(b);
|
|
99
|
+
if (!(Math.abs(ax) < 1e5 && Math.abs(bx) < 1e5 && Math.abs(ay) < 1e5 && Math.abs(by) < 1e5)) continue; // an end behind the 3D camera
|
|
100
|
+
const ang = Math.atan2(ay - by, ax - bx), gap = 2 * Math.asin(Math.min(1, ARROW.half / (rb + ARROW.len)));
|
|
101
|
+
const on = seen.get(b);
|
|
102
|
+
if (on && on.some((o) => { const d = Math.abs(o - ang) % (2 * Math.PI); return Math.min(d, 2 * Math.PI - d) < gap; })) continue;
|
|
103
|
+
if (!arrowTriangle(ax, ay, bx, by, ra, rb, 1, side, out, 6 * t)) continue;
|
|
104
|
+
if (on) on.push(ang); else seen.set(b, [ang]);
|
|
105
|
+
t++;
|
|
106
|
+
}
|
|
107
|
+
return t;
|
|
108
|
+
}
|