@trokster/l-cursor 0.0.1
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/LICENSE +21 -0
- package/README.md +49 -0
- package/dist/core/camera.d.ts +30 -0
- package/dist/core/camera.js +80 -0
- package/dist/core/engine.d.ts +67 -0
- package/dist/core/engine.js +492 -0
- package/dist/core/interaction.d.ts +13 -0
- package/dist/core/interaction.js +232 -0
- package/dist/core/rebase.d.ts +35 -0
- package/dist/core/rebase.js +41 -0
- package/dist/core/tree.d.ts +32 -0
- package/dist/core/tree.js +121 -0
- package/dist/data/deepTree.d.ts +19 -0
- package/dist/data/deepTree.js +60 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +17 -0
- package/dist/layouts/radial.d.ts +6 -0
- package/dist/layouts/radial.js +63 -0
- package/dist/viz/ZoomScene.svelte +625 -0
- package/dist/viz/ZoomScene.svelte.d.ts +39 -0
- package/package.json +90 -0
|
@@ -0,0 +1,492 @@
|
|
|
1
|
+
// The generic zoom engine (THEORY.md §4–6).
|
|
2
|
+
//
|
|
3
|
+
// Display-agnostic: hand it a `layout()` function (radial / pack / treemap /
|
|
4
|
+
// network) and it owns everything else — bounded-scale camera, referential
|
|
5
|
+
// rebasing for infinite zoom, level-of-detail, and viewport culling. The only
|
|
6
|
+
// thing that varies per display is the layout and the SVG shape drawn for each
|
|
7
|
+
// item; the engine never knows or cares which.
|
|
8
|
+
//
|
|
9
|
+
// Per frame it returns ONLY the items that are on-screen and pixel-meaningful
|
|
10
|
+
// (THEORY §5–6), so render cost is O(visible), independent of total tree size.
|
|
11
|
+
|
|
12
|
+
import { rebaseCamera, nodeBox } from './rebase.js';
|
|
13
|
+
import { ascend } from './tree.js';
|
|
14
|
+
|
|
15
|
+
export function createEngine({ index, layout, camera, config = {} }) {
|
|
16
|
+
const cfg = {
|
|
17
|
+
rootSize: 500, // canonical root half-extent (S0)
|
|
18
|
+
maxDepth: 6, // how many generations to lay out below the referential
|
|
19
|
+
contextUp: 0, // lay out from this many levels ABOVE the referential, so the
|
|
20
|
+
// parent ring and siblings stay visible around you for orientation
|
|
21
|
+
treeLinks: false, // emit parent->child links for rendered nodes
|
|
22
|
+
descendAt: 0.82, // re-root inward when a child's half-extent > this * halfMin(px)
|
|
23
|
+
ascendAt: 0.34, // re-root outward when the root's half-extent < this * halfMin(px)
|
|
24
|
+
minPx: 7, // cull nodes whose half-extent is below this many px
|
|
25
|
+
openPx: 58, // a node reveals its children only once it is this big on screen
|
|
26
|
+
childPreview: false, // closed nodes carry ghost geometry of their children
|
|
27
|
+
previewPx: 16, // ...but only once the node is at least this big on screen
|
|
28
|
+
previewMax: 40, // cap on ghost children per node
|
|
29
|
+
labelPx: 13, // only draw labels above this on-screen size
|
|
30
|
+
maxScale: 100, // hard safety cap (rebasing keeps us well under this)
|
|
31
|
+
minScale: 0.008, // hard safety floor
|
|
32
|
+
layoutOpts: {},
|
|
33
|
+
...config
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
let viewport = { w: 1, h: 1 };
|
|
37
|
+
let rootId = config.rootId ?? index.roots[0] ?? index.byId.keys().next().value;
|
|
38
|
+
let laid = relayout(rootId);
|
|
39
|
+
|
|
40
|
+
// Absolute depth in the DATA tree (distance from the true root), cached.
|
|
41
|
+
// Layout depth shifts every time the referential rebases — anything visual
|
|
42
|
+
// keyed on it (colors!) would flip scene-wide at each re-root. Absolute depth
|
|
43
|
+
// is stable forever.
|
|
44
|
+
const absDepths = new Map();
|
|
45
|
+
function absDepthOf(id) {
|
|
46
|
+
if (absDepths.has(id)) return absDepths.get(id);
|
|
47
|
+
const chain = [];
|
|
48
|
+
let cur = id;
|
|
49
|
+
while (cur != null && !absDepths.has(cur)) {
|
|
50
|
+
chain.push(cur);
|
|
51
|
+
cur = index.parent.get(cur);
|
|
52
|
+
}
|
|
53
|
+
let d = cur != null ? absDepths.get(cur) : -1;
|
|
54
|
+
for (let i = chain.length - 1; i >= 0; i--) absDepths.set(chain[i], ++d);
|
|
55
|
+
return absDepths.get(id);
|
|
56
|
+
}
|
|
57
|
+
// Anchor = the screen point the user is zooming toward (cursor / pinch
|
|
58
|
+
// centroid). Rebasing dives into whatever sits under it. Null => viewport
|
|
59
|
+
// center (used for pan / programmatic moves).
|
|
60
|
+
let anchor = null;
|
|
61
|
+
function setAnchor(x, y) {
|
|
62
|
+
anchor = { x, y };
|
|
63
|
+
}
|
|
64
|
+
function clearAnchor() {
|
|
65
|
+
anchor = null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function relayout(id) {
|
|
69
|
+
// Lay out from `contextUp` levels above the referential: the referential's
|
|
70
|
+
// parent and siblings then exist in the layout and stay on screen at the
|
|
71
|
+
// edges. Rebasing is unaffected — it pivots on any node alive in both
|
|
72
|
+
// frames, and the referential always is. Depth extends by the same amount
|
|
73
|
+
// so the subtree below the referential keeps its full budget.
|
|
74
|
+
const layoutRoot = ascend(index, id, cfg.contextUp);
|
|
75
|
+
return layout(index, layoutRoot, {
|
|
76
|
+
rootSize: cfg.rootSize,
|
|
77
|
+
maxDepth: cfg.maxDepth + cfg.contextUp,
|
|
78
|
+
...cfg.layoutOpts
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function setViewport(w, h) {
|
|
83
|
+
viewport = { w: Math.max(1, w), h: Math.max(1, h) };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Camera that places the current root at ~`frac` of the viewport, centered.
|
|
87
|
+
// Rects fit per-axis (the cell fills the viewport, anisotropic — the treemap
|
|
88
|
+
// zoom feel); circles fit uniformly on the smaller viewport dimension. A
|
|
89
|
+
// uniform fit on a rect's short side would overflow the long axis past the
|
|
90
|
+
// descend threshold and chain-descend through dominant children.
|
|
91
|
+
function fitCamera(frac = 0.82) {
|
|
92
|
+
const root = laid.nodes.get(rootId);
|
|
93
|
+
const b = nodeBox(root);
|
|
94
|
+
let sx, sy;
|
|
95
|
+
if (root.kind === 'rect') {
|
|
96
|
+
sx = (frac * (viewport.w / 2)) / b.hx;
|
|
97
|
+
sy = (frac * (viewport.h / 2)) / b.hy;
|
|
98
|
+
} else {
|
|
99
|
+
const half = 0.5 * Math.min(viewport.w, viewport.h);
|
|
100
|
+
sx = sy = (frac * half) / (Math.min(b.hx, b.hy) || cfg.rootSize);
|
|
101
|
+
}
|
|
102
|
+
return { sx, sy, x: viewport.w / 2 - b.cx * sx, y: viewport.h / 2 - b.cy * sy };
|
|
103
|
+
}
|
|
104
|
+
function fit(frac = 0.82) {
|
|
105
|
+
camera.set(fitCamera(frac));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Direct child of the current root whose screen box contains (px, py).
|
|
109
|
+
// Children are disjoint within a parent, so the first hit is THE child on the
|
|
110
|
+
// path toward whatever the user is diving into.
|
|
111
|
+
function childUnder(px, py, cam) {
|
|
112
|
+
for (const cid of index.children.get(rootId) ?? []) {
|
|
113
|
+
const v = laid.nodes.get(cid);
|
|
114
|
+
if (!v) continue;
|
|
115
|
+
const b = nodeBox(v);
|
|
116
|
+
const sx = b.cx * cam.sx + cam.x;
|
|
117
|
+
const sy = b.cy * cam.sy + cam.y;
|
|
118
|
+
const rx = b.hx * cam.sx;
|
|
119
|
+
const ry = b.hy * cam.sy;
|
|
120
|
+
if (px >= sx - rx && px <= sx + rx && py >= sy - ry && py <= sy + ry) return v;
|
|
121
|
+
}
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Child of the current root nearest to (px, py) on screen. Fallback when the
|
|
126
|
+
// anchor sits over EMPTY interior (radial cores, packing gaps): without it,
|
|
127
|
+
// zooming into a void pegs at maxScale with nothing to descend into — a
|
|
128
|
+
// deadlock. At the cap we tunnel toward the nearest child instead — preferring
|
|
129
|
+
// children that HAVE children, since the point of tunneling is to keep
|
|
130
|
+
// unlocking levels (a leaf would just re-peg at the cap).
|
|
131
|
+
function nearestChild(px, py, cam) {
|
|
132
|
+
let best = null;
|
|
133
|
+
let bestD = Infinity;
|
|
134
|
+
let bestDeep = null;
|
|
135
|
+
let bestDeepD = Infinity;
|
|
136
|
+
for (const cid of index.children.get(rootId) ?? []) {
|
|
137
|
+
const v = laid.nodes.get(cid);
|
|
138
|
+
if (!v) continue;
|
|
139
|
+
const b = nodeBox(v);
|
|
140
|
+
const d = Math.hypot(b.cx * cam.sx + cam.x - px, b.cy * cam.sy + cam.y - py);
|
|
141
|
+
if (d < bestD) {
|
|
142
|
+
bestD = d;
|
|
143
|
+
best = v;
|
|
144
|
+
}
|
|
145
|
+
if ((index.children.get(cid) ?? []).length > 0 && d < bestDeepD) {
|
|
146
|
+
bestDeepD = d;
|
|
147
|
+
bestDeep = v;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return bestDeep ?? best;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Deepest laid-out node whose screen box contains point (px, py).
|
|
154
|
+
function focusAt(px, py, cam) {
|
|
155
|
+
let best = null;
|
|
156
|
+
let bestDepth = -1;
|
|
157
|
+
for (const v of laid.nodes.values()) {
|
|
158
|
+
const b = nodeBox(v);
|
|
159
|
+
const sx = b.cx * cam.sx + cam.x;
|
|
160
|
+
const sy = b.cy * cam.sy + cam.y;
|
|
161
|
+
const rx = b.hx * cam.sx;
|
|
162
|
+
const ry = b.hy * cam.sy;
|
|
163
|
+
if (px >= sx - rx && px <= sx + rx && py >= sy - ry && py <= sy + ry) {
|
|
164
|
+
if (v.depth > bestDepth) {
|
|
165
|
+
bestDepth = v.depth;
|
|
166
|
+
best = v;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return best;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Re-root inward onto `target`, preserving the on-screen image (pixel-exact).
|
|
174
|
+
function descendTo(target, cam) {
|
|
175
|
+
const oldBox = nodeBox(laid.nodes.get(target));
|
|
176
|
+
const next = relayout(target);
|
|
177
|
+
const pivot = next.nodes.get(target);
|
|
178
|
+
if (!pivot) return null;
|
|
179
|
+
const newCam = rebaseCamera(cam, oldBox, nodeBox(pivot));
|
|
180
|
+
rootId = target;
|
|
181
|
+
laid = next;
|
|
182
|
+
return newCam;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Re-root outward onto the parent, pivoting on a node alive in both frames.
|
|
186
|
+
function ascendTo(parentId, cam, pivotId) {
|
|
187
|
+
const rootView = laid.nodes.get(rootId);
|
|
188
|
+
const oldBox = nodeBox(laid.nodes.get(pivotId) ?? rootView);
|
|
189
|
+
const next = relayout(parentId);
|
|
190
|
+
const newPivot = next.nodes.get(pivotId) ?? next.nodes.get(rootId);
|
|
191
|
+
if (!newPivot) return null;
|
|
192
|
+
const usedOldBox = next.nodes.get(pivotId) ? oldBox : nodeBox(rootView);
|
|
193
|
+
const newCam = rebaseCamera(cam, usedOldBox, nodeBox(newPivot));
|
|
194
|
+
rootId = parentId;
|
|
195
|
+
laid = next;
|
|
196
|
+
return newCam;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// How much of the viewport a node covers, 0..1. Circles: min half-extent vs
|
|
200
|
+
// the smaller viewport half. Rects: per-axis — the smaller of width-coverage
|
|
201
|
+
// and height-coverage — so a wide-but-short cell (common in treemaps, where a
|
|
202
|
+
// dominant child can span ~90% of one parent axis) doesn't trip thresholds
|
|
203
|
+
// while plenty of its surroundings are still visible.
|
|
204
|
+
function coverage(v, b, cam) {
|
|
205
|
+
if (v.kind === 'rect') {
|
|
206
|
+
return Math.min((b.hx * cam.sx) / (viewport.w / 2), (b.hy * cam.sy) / (viewport.h / 2));
|
|
207
|
+
}
|
|
208
|
+
const half = 0.5 * Math.min(viewport.w, viewport.h);
|
|
209
|
+
return Math.min(b.hx * cam.sx, b.hy * cam.sy) / half;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Check descend/ascend thresholds and rebase if needed (THEORY §4). Loops so a
|
|
213
|
+
// fast zoom that jumps several levels settles in a single frame. Returns the
|
|
214
|
+
// camera to render with.
|
|
215
|
+
function maybeRebase(cam) {
|
|
216
|
+
const camIn = cam; // rebases/clamps create new objects; identity = unchanged
|
|
217
|
+
const half = 0.5 * Math.min(viewport.w, viewport.h);
|
|
218
|
+
const ax = anchor?.x ?? viewport.w / 2;
|
|
219
|
+
const ay = anchor?.y ?? viewport.h / 2;
|
|
220
|
+
|
|
221
|
+
for (let iter = 0; iter < 12; iter++) {
|
|
222
|
+
let changed = false;
|
|
223
|
+
|
|
224
|
+
// --- Descend one level: re-root to the child of the current root that
|
|
225
|
+
// sits under the anchor, once that child has grown to dominate. Going a
|
|
226
|
+
// single level at a time keeps the scale band tight; the loop lets a fast
|
|
227
|
+
// multi-level zoom settle in one frame. ---
|
|
228
|
+
const overCap = cam.sx > cfg.maxScale || cam.sy > cfg.maxScale;
|
|
229
|
+
const child = childUnder(ax, ay, cam);
|
|
230
|
+
if (child) {
|
|
231
|
+
const cb = nodeBox(child);
|
|
232
|
+
if (coverage(child, cb, cam) >= cfg.descendAt || overCap) {
|
|
233
|
+
const nc = descendTo(child.id, cam);
|
|
234
|
+
if (nc) {
|
|
235
|
+
cam = nc;
|
|
236
|
+
changed = true;
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// --- Ascend one level: re-root to the parent once the root has shrunk ---
|
|
243
|
+
const parentId = index.parent.get(rootId);
|
|
244
|
+
if (parentId != null) {
|
|
245
|
+
const rootView = laid.nodes.get(rootId);
|
|
246
|
+
const rb = nodeBox(rootView);
|
|
247
|
+
const underFloor = cam.sx < cfg.minScale || cam.sy < cfg.minScale;
|
|
248
|
+
if (coverage(rootView, rb, cam) < cfg.ascendAt || underFloor) {
|
|
249
|
+
const focus = focusAt(ax, ay, cam);
|
|
250
|
+
const pivotId = focus && laid.nodes.has(focus.id) ? focus.id : rootId;
|
|
251
|
+
const nc = ascendTo(parentId, cam, pivotId);
|
|
252
|
+
if (nc) {
|
|
253
|
+
cam = nc;
|
|
254
|
+
changed = true;
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (!changed) break;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Floor: at the absolute root there's nothing to ascend into, so stop the
|
|
264
|
+
// zoom-out before the root shrinks to a dot (keep it >= ~28% of viewport).
|
|
265
|
+
let floor = cfg.minScale;
|
|
266
|
+
if (index.parent.get(rootId) == null) {
|
|
267
|
+
const rb = nodeBox(laid.nodes.get(rootId));
|
|
268
|
+
const rootHalf = Math.min(rb.hx, rb.hy) || cfg.rootSize;
|
|
269
|
+
floor = Math.max(cfg.minScale, (0.28 * half) / rootHalf);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Hard clamp as a final safety net (keeps the anchor fixed while clamping).
|
|
273
|
+
if (cam.sx > cfg.maxScale || cam.sx < floor) {
|
|
274
|
+
const hitFloor = cam.sx < floor;
|
|
275
|
+
const clamped = Math.max(floor, Math.min(cfg.maxScale, cam.sx));
|
|
276
|
+
const k = clamped / cam.sx;
|
|
277
|
+
const wx = (ax - cam.x) / cam.sx;
|
|
278
|
+
const wy = (ay - cam.y) / cam.sy;
|
|
279
|
+
cam = { sx: clamped, sy: cam.sy * k, x: ax - wx * clamped, y: ay - wy * cam.sy * k };
|
|
280
|
+
// Pinned at the zoom-out floor: also pull the root back toward centre,
|
|
281
|
+
// or fully-zoomed-out could leave the whole scene OFFSCREEN (blank view
|
|
282
|
+
// with nothing to grab). Each further zoom-out notch glides it home.
|
|
283
|
+
if (hitFloor && index.parent.get(rootId) == null) {
|
|
284
|
+
const rb = nodeBox(laid.nodes.get(rootId));
|
|
285
|
+
const tx = viewport.w / 2 - rb.cx * cam.sx;
|
|
286
|
+
const ty = viewport.h / 2 - rb.cy * cam.sy;
|
|
287
|
+
cam = { ...cam, x: cam.x + (tx - cam.x) * 0.25, y: cam.y + (ty - cam.y) * 0.25 };
|
|
288
|
+
} else if (!hitFloor) {
|
|
289
|
+
// Pinned at the zoom-in cap: the anchor sits over empty interior (a
|
|
290
|
+
// hollow core, a packing gap) or a bare leaf — zooming further would
|
|
291
|
+
// just be a blank void. Each further notch pulls the nearest content
|
|
292
|
+
// (preferring subtrees that continue) toward the anchor; once it
|
|
293
|
+
// arrives under the cursor the natural descend takes over and the
|
|
294
|
+
// dive continues VISIBLY instead of through featureless white.
|
|
295
|
+
const tgt = nearestChild(ax, ay, cam) ?? laid.nodes.get(rootId);
|
|
296
|
+
if (tgt) {
|
|
297
|
+
const b = nodeBox(tgt);
|
|
298
|
+
const tx = ax - b.cx * cam.sx;
|
|
299
|
+
const ty = ay - b.cy * cam.sy;
|
|
300
|
+
cam = { ...cam, x: cam.x + (tx - cam.x) * 0.2, y: cam.y + (ty - cam.y) * 0.2 };
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// write back only if a rebase or clamp actually changed the camera —
|
|
306
|
+
// otherwise the store subscription would re-dirty the renderer every frame
|
|
307
|
+
if (cam !== camIn) camera.set(cam);
|
|
308
|
+
return cam;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// Compute the visible, pixel-meaningful draw items in SCREEN coords.
|
|
312
|
+
// Single pass over `order` (parents before children) with subtree pruning:
|
|
313
|
+
// a node is culled if its parent was culled, or it is off-screen, or too
|
|
314
|
+
// small — and a culled node's descendants are therefore skipped too.
|
|
315
|
+
function frame() {
|
|
316
|
+
let cam = maybeRebase(camera.get());
|
|
317
|
+
const { w: W, h: H } = viewport;
|
|
318
|
+
const culled = new Set(); // node + subtree not rendered
|
|
319
|
+
const closed = new Set(); // rendered as a summary; children gated off
|
|
320
|
+
const extById = new Map(); // on-screen half-extent
|
|
321
|
+
const openById = new Map(); // continuous 0..1 openness, drives morph + fades
|
|
322
|
+
const items = [];
|
|
323
|
+
|
|
324
|
+
for (const id of laid.order) {
|
|
325
|
+
const v = laid.nodes.get(id);
|
|
326
|
+
const p = v.parentId;
|
|
327
|
+
const isRoot = id === rootId;
|
|
328
|
+
// children only exist when the parent is rendered AND open (LOD gate).
|
|
329
|
+
// The root is exempt: with contextUp its layout-parent (the context
|
|
330
|
+
// level) may be culled, but the root must always anchor the frame.
|
|
331
|
+
if (!isRoot && p != null && (culled.has(p) || closed.has(p))) {
|
|
332
|
+
culled.add(id);
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
335
|
+
const b = nodeBox(v);
|
|
336
|
+
const sx = b.cx * cam.sx + cam.x;
|
|
337
|
+
const sy = b.cy * cam.sy + cam.y;
|
|
338
|
+
const rx = b.hx * cam.sx;
|
|
339
|
+
const ry = b.hy * cam.sy;
|
|
340
|
+
const ext = Math.min(rx, ry);
|
|
341
|
+
|
|
342
|
+
// too small -> prune subtree (descendants are strictly smaller)
|
|
343
|
+
if (!isRoot && ext < cfg.minPx) {
|
|
344
|
+
culled.add(id);
|
|
345
|
+
continue;
|
|
346
|
+
}
|
|
347
|
+
// off-screen -> prune subtree (children nest within this box). The root
|
|
348
|
+
// is never culled here: it anchors the view, and culling it would take
|
|
349
|
+
// its whole subtree with it and leave a blank frame with nothing to grab.
|
|
350
|
+
if (!isRoot && (sx + rx < 0 || sx - rx > W || sy + ry < 0 || sy - ry > H)) {
|
|
351
|
+
culled.add(id);
|
|
352
|
+
continue;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
extById.set(id, ext);
|
|
356
|
+
const childCount = (index.children.get(id) ?? []).length;
|
|
357
|
+
// LOD: a node "opens" (reveals children) once big enough; until then it
|
|
358
|
+
// is a summary blob standing in for its whole subtree. `openness` is the
|
|
359
|
+
// CONTINUOUS version (0=solid blob, 1=container) so the summary→container
|
|
360
|
+
// morph and the children's fade-in are smooth, never a pop.
|
|
361
|
+
const hasKids = childCount > 0;
|
|
362
|
+
const open = isRoot || ext >= cfg.openPx; // gate: do children exist this frame
|
|
363
|
+
if (hasKids && !open) closed.add(id);
|
|
364
|
+
const openness = !hasKids ? 0 : isRoot ? 1 : clamp01((ext - cfg.openPx) / (cfg.openPx * 0.3));
|
|
365
|
+
openById.set(id, openness);
|
|
366
|
+
|
|
367
|
+
// children fade in with their parent's openness
|
|
368
|
+
const alpha = p != null ? (openById.get(p) ?? 1) : 1;
|
|
369
|
+
|
|
370
|
+
const item = {
|
|
371
|
+
id,
|
|
372
|
+
parentId: p,
|
|
373
|
+
kind: v.kind,
|
|
374
|
+
depth: v.depth,
|
|
375
|
+
absDepth: absDepthOf(id),
|
|
376
|
+
childCount,
|
|
377
|
+
data: index.byId.get(id),
|
|
378
|
+
open: hasKids && open, // children present this frame
|
|
379
|
+
openness, // 0..1 morph driver
|
|
380
|
+
leaf: childCount === 0,
|
|
381
|
+
alpha,
|
|
382
|
+
showLabel: ext >= cfg.labelPx
|
|
383
|
+
};
|
|
384
|
+
// WORLD coordinates (renderer applies the camera as one <g> transform, so
|
|
385
|
+
// node geometry is static and only the group transform animates).
|
|
386
|
+
item.cx = b.cx;
|
|
387
|
+
item.cy = b.cy;
|
|
388
|
+
item.ext = ext; // on-screen half-extent (px), for label sizing
|
|
389
|
+
if (v.kind === 'rect') {
|
|
390
|
+
item.x = b.cx - b.hx;
|
|
391
|
+
item.y = b.cy - b.hy;
|
|
392
|
+
item.w = 2 * b.hx;
|
|
393
|
+
item.h = 2 * b.hy;
|
|
394
|
+
} else {
|
|
395
|
+
item.r = b.hx;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// LOD preview: while a node is not (fully) open, carry its immediate
|
|
399
|
+
// children as ghost geometry — generic info about the unexpanded subtree
|
|
400
|
+
// (count, relative sizes, arrangement) at a glance. The layout already
|
|
401
|
+
// computed them; the renderer cross-fades ghosts into the real children
|
|
402
|
+
// as `openness` ramps to 1, so opening is continuous, never a pop.
|
|
403
|
+
if (cfg.childPreview && hasKids && openness < 1 && ext >= cfg.previewPx) {
|
|
404
|
+
const preview = [];
|
|
405
|
+
for (const kid of index.children.get(id) ?? []) {
|
|
406
|
+
const kv = laid.nodes.get(kid);
|
|
407
|
+
if (!kv) continue;
|
|
408
|
+
const kb = nodeBox(kv);
|
|
409
|
+
if (kv.kind === 'rect') {
|
|
410
|
+
preview.push({
|
|
411
|
+
id: kid,
|
|
412
|
+
kind: 'rect',
|
|
413
|
+
x: kb.cx - kb.hx,
|
|
414
|
+
y: kb.cy - kb.hy,
|
|
415
|
+
w: 2 * kb.hx,
|
|
416
|
+
h: 2 * kb.hy
|
|
417
|
+
});
|
|
418
|
+
} else {
|
|
419
|
+
preview.push({ id: kid, kind: 'circle', cx: kb.cx, cy: kb.cy, r: kb.hx });
|
|
420
|
+
}
|
|
421
|
+
if (preview.length >= cfg.previewMax) break;
|
|
422
|
+
}
|
|
423
|
+
if (preview.length) item.preview = preview;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
items.push(item);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// Optional edge layer (network graph): the engine stays generic — it just
|
|
430
|
+
// hands the rendered, on-screen items to a display-supplied builder that
|
|
431
|
+
// aggregates the raw graph edges to the current level of detail.
|
|
432
|
+
const edges = cfg.edgeBuilder
|
|
433
|
+
? cfg.edgeBuilder(items, cam, { rootId, index })
|
|
434
|
+
: (laid.edges ?? null);
|
|
435
|
+
|
|
436
|
+
// Optional tree-link layer (the original radial had parent->child lines):
|
|
437
|
+
// one link per rendered node whose parent is also rendered, fading in with
|
|
438
|
+
// the child. World coords, drawn inside the camera group.
|
|
439
|
+
let links = null;
|
|
440
|
+
if (cfg.treeLinks) {
|
|
441
|
+
const itemById = new Map();
|
|
442
|
+
for (const it of items) itemById.set(it.id, it);
|
|
443
|
+
links = [];
|
|
444
|
+
for (const it of items) {
|
|
445
|
+
const pit = it.parentId != null ? itemById.get(it.parentId) : null;
|
|
446
|
+
if (!pit) continue;
|
|
447
|
+
links.push({
|
|
448
|
+
id: pit.id + '->' + it.id,
|
|
449
|
+
x1: pit.cx,
|
|
450
|
+
y1: pit.cy,
|
|
451
|
+
x2: it.cx,
|
|
452
|
+
y2: it.cy,
|
|
453
|
+
r1: pit.r ?? pit.ext,
|
|
454
|
+
r2: it.r ?? it.ext,
|
|
455
|
+
alpha: it.alpha
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
return { items, rootId, cam, edges, links };
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function reset(id) {
|
|
464
|
+
rootId = id ?? index.roots[0] ?? rootId;
|
|
465
|
+
laid = relayout(rootId);
|
|
466
|
+
fit();
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
return {
|
|
470
|
+
setViewport,
|
|
471
|
+
fit,
|
|
472
|
+
fitCamera,
|
|
473
|
+
frame,
|
|
474
|
+
reset,
|
|
475
|
+
setAnchor,
|
|
476
|
+
clearAnchor,
|
|
477
|
+
pick: (px, py) => focusAt(px, py, camera.get()),
|
|
478
|
+
get rootId() {
|
|
479
|
+
return rootId;
|
|
480
|
+
},
|
|
481
|
+
get config() {
|
|
482
|
+
return cfg;
|
|
483
|
+
},
|
|
484
|
+
get layout() {
|
|
485
|
+
return laid;
|
|
486
|
+
}
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
function clamp01(v) {
|
|
491
|
+
return v < 0 ? 0 : v > 1 ? 1 : v;
|
|
492
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export function createInteraction({ camera, svgElRef, onChange, onGesture, onAnchor }: {
|
|
2
|
+
camera: any;
|
|
3
|
+
svgElRef: any;
|
|
4
|
+
onChange: any;
|
|
5
|
+
onGesture: any;
|
|
6
|
+
onAnchor: any;
|
|
7
|
+
}): {
|
|
8
|
+
onPointerDown: (e: any) => void;
|
|
9
|
+
onPointerMove: (e: any) => void;
|
|
10
|
+
onPointerUp: (e: any) => void;
|
|
11
|
+
onWheel: (e: any) => void;
|
|
12
|
+
destroy: () => void;
|
|
13
|
+
};
|