@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,625 @@
|
|
|
1
|
+
<script>
|
|
2
|
+
import { onMount } from 'svelte';
|
|
3
|
+
import { createCamera } from '../core/camera.js';
|
|
4
|
+
import { createEngine } from '../core/engine.js';
|
|
5
|
+
import { createInteraction } from '../core/interaction.js';
|
|
6
|
+
|
|
7
|
+
// Generic, display-agnostic zoom surface. Nodes are drawn ONCE in world
|
|
8
|
+
// coordinates inside a single <g transform>; only that group transform animates
|
|
9
|
+
// per frame (GPU-composited), so pan/zoom stay smooth no matter the node count.
|
|
10
|
+
// LOD, culling and referential rebasing live in the engine.
|
|
11
|
+
let {
|
|
12
|
+
index,
|
|
13
|
+
layout,
|
|
14
|
+
config = {},
|
|
15
|
+
palette = depthColor,
|
|
16
|
+
showLabels = true,
|
|
17
|
+
showHud = true,
|
|
18
|
+
title = '',
|
|
19
|
+
circleFill = 'white', // 'depth' fills nested circles; else light containers
|
|
20
|
+
nodeStyle = 'rings', // 'rings' = the radial fractal (hairline ring + core)
|
|
21
|
+
homeHref = '/',
|
|
22
|
+
accent = '#0f172a',
|
|
23
|
+
// Portal: a snippet rendered inside the square inscribed in each circle's
|
|
24
|
+
// core. It lays out in a fixed 256x256 design space scaled to fit the node,
|
|
25
|
+
// and receives { item, px } — px is its live on-screen side in CSS pixels,
|
|
26
|
+
// so the component can adapt its own detail to the resolution it has.
|
|
27
|
+
portal = null,
|
|
28
|
+
portalMinPx = 26, // don't mount portals below this on-screen size
|
|
29
|
+
portalMaxPx = 1000, // ...or above it: the card hands off to its children's
|
|
30
|
+
onframe = null
|
|
31
|
+
} = $props();
|
|
32
|
+
|
|
33
|
+
let svgEl;
|
|
34
|
+
let W = 0;
|
|
35
|
+
let H = 0;
|
|
36
|
+
let view = $state({ items: [], edges: null, rootId: null, cam: { x: 0, y: 0, sx: 1, sy: 1 } });
|
|
37
|
+
let crumbs = $state([]);
|
|
38
|
+
let hoverId = $state(null);
|
|
39
|
+
|
|
40
|
+
const camera = createCamera({ x: 0, y: 0, scale: 1 });
|
|
41
|
+
let engine;
|
|
42
|
+
let interaction;
|
|
43
|
+
let raf = 0;
|
|
44
|
+
let dirty = true;
|
|
45
|
+
let lastT = 0;
|
|
46
|
+
let flyId = null; // node we are springing toward (dive / breadcrumb)
|
|
47
|
+
let lastRoot = null;
|
|
48
|
+
// Enter-fade: anything appearing for the first time (esp. the surroundings
|
|
49
|
+
// revealed by an ascend re-root) eases in instead of popping — "the world
|
|
50
|
+
// fades in around you", never a full-scene redraw.
|
|
51
|
+
const enterAt = new Map();
|
|
52
|
+
const edgeEnterAt = new Map();
|
|
53
|
+
let fadeActive = false;
|
|
54
|
+
|
|
55
|
+
function depthColor(depth) {
|
|
56
|
+
const hues = [210, 160, 35, 280, 0, 130];
|
|
57
|
+
const hue = hues[depth % hues.length];
|
|
58
|
+
return `hsl(${hue} 70% ${Math.max(45, 88 - depth * 8)}%)`;
|
|
59
|
+
}
|
|
60
|
+
const lerp = (a, b, t) => a + (b - a) * t;
|
|
61
|
+
const clamp = (v, a, b) => (v < a ? a : v > b ? b : v);
|
|
62
|
+
|
|
63
|
+
// ---------- visual language (only the generic 'blob' fallback uses these) ----------
|
|
64
|
+
const OPEN_FILL = () => (circleFill === 'depth' ? 0.2 : 0.07);
|
|
65
|
+
function fillOpacity(it) {
|
|
66
|
+
return it.alpha * lerp(0.84, OPEN_FILL(), it.openness);
|
|
67
|
+
}
|
|
68
|
+
function strokeOpacity(it) {
|
|
69
|
+
return it.alpha * lerp(0.7, 0.95, it.openness);
|
|
70
|
+
}
|
|
71
|
+
function labelOpacity(it) {
|
|
72
|
+
// parent label clears out quickly once children start appearing, so the
|
|
73
|
+
// two never sit overlapped at rest
|
|
74
|
+
return it.alpha * clamp(1 - it.openness * 2.4, 0, 1);
|
|
75
|
+
}
|
|
76
|
+
function shortLabel(id) {
|
|
77
|
+
const s = String(id);
|
|
78
|
+
return s.length > 14 ? s.slice(0, 6) + '…' + s.slice(-5) : s;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Portal geometry: the square inscribed in the node's core circle (core is
|
|
82
|
+
// 0.5R, inscribed side = 0.5R·√2 ≈ 0.707R). Portals live in an HTML overlay
|
|
83
|
+
// ABOVE the svg, positioned per frame in screen pixels — components lay out
|
|
84
|
+
// at native resolution (crisp text, real CSS), no foreignObject rasters.
|
|
85
|
+
function portalSidePx(it) {
|
|
86
|
+
return it.r != null ? 0.7071 * it.ext : 0;
|
|
87
|
+
}
|
|
88
|
+
function hasPortal(it) {
|
|
89
|
+
const s = portalSidePx(it);
|
|
90
|
+
// above max the card is an unreadable giant — its children's cards carry
|
|
91
|
+
// the content from there (the semantic-zoom handoff)
|
|
92
|
+
return portal != null && it.r != null && s >= portalMinPx && s <= portalMaxPx;
|
|
93
|
+
}
|
|
94
|
+
// screen-space geometry for the visible portals of the current frame
|
|
95
|
+
let portalItems = $derived(
|
|
96
|
+
portal == null
|
|
97
|
+
? []
|
|
98
|
+
: view.items.filter(hasPortal).map((it) => {
|
|
99
|
+
const side = portalSidePx(it);
|
|
100
|
+
const fadeIn = clamp((side - portalMinPx) / (portalMinPx * 0.6), 0, 1);
|
|
101
|
+
const fadeOut = clamp((portalMaxPx - side) / (portalMaxPx * 0.25), 0, 1);
|
|
102
|
+
return {
|
|
103
|
+
id: it.id,
|
|
104
|
+
item: it,
|
|
105
|
+
x: it.cx * view.cam.sx + view.cam.x - side / 2,
|
|
106
|
+
y: it.cy * view.cam.sy + view.cam.y - side / 2,
|
|
107
|
+
side,
|
|
108
|
+
px: Math.round(side / 8) * 8, // quantized: content re-renders on meaningful changes
|
|
109
|
+
alpha: it.alpha * fadeIn * fadeOut
|
|
110
|
+
};
|
|
111
|
+
})
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
function transformStr(cam) {
|
|
115
|
+
return `translate(${cam.x} ${cam.y}) scale(${cam.sx} ${cam.sy})`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ---------- the render loop ----------
|
|
119
|
+
const FADE_MS = 260;
|
|
120
|
+
function applyEnterFades(f, now) {
|
|
121
|
+
let fading = false;
|
|
122
|
+
const seen = new Set();
|
|
123
|
+
for (const it of f.items) {
|
|
124
|
+
seen.add(it.id);
|
|
125
|
+
let t0 = enterAt.get(it.id);
|
|
126
|
+
if (t0 == null) {
|
|
127
|
+
t0 = now;
|
|
128
|
+
enterAt.set(it.id, t0);
|
|
129
|
+
}
|
|
130
|
+
const env = Math.min(1, (now - t0) / FADE_MS);
|
|
131
|
+
if (env < 1) {
|
|
132
|
+
fading = true;
|
|
133
|
+
it.alpha *= env * env;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
for (const id of enterAt.keys()) if (!seen.has(id)) enterAt.delete(id);
|
|
137
|
+
if (f.links) {
|
|
138
|
+
for (const ln of f.links) {
|
|
139
|
+
const cid = ln.id.slice(ln.id.indexOf('->') + 2);
|
|
140
|
+
const t0 = enterAt.get(cid);
|
|
141
|
+
if (t0 != null) {
|
|
142
|
+
const env = Math.min(1, (now - t0) / FADE_MS);
|
|
143
|
+
if (env < 1) ln.alpha *= env * env;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
if (f.edges) {
|
|
148
|
+
const seenE = new Set();
|
|
149
|
+
for (const ed of f.edges) {
|
|
150
|
+
seenE.add(ed.id);
|
|
151
|
+
let t0 = edgeEnterAt.get(ed.id);
|
|
152
|
+
if (t0 == null) {
|
|
153
|
+
t0 = now;
|
|
154
|
+
edgeEnterAt.set(ed.id, t0);
|
|
155
|
+
}
|
|
156
|
+
const env = Math.min(1, (now - t0) / FADE_MS);
|
|
157
|
+
if (env < 1) {
|
|
158
|
+
fading = true;
|
|
159
|
+
ed.opacity *= env * env;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
for (const id of edgeEnterAt.keys()) if (!seenE.has(id)) edgeEnterAt.delete(id);
|
|
163
|
+
}
|
|
164
|
+
return fading;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function loop(now) {
|
|
168
|
+
raf = requestAnimationFrame(loop);
|
|
169
|
+
const dt = lastT ? (now - lastT) / 1000 : 0;
|
|
170
|
+
lastT = now;
|
|
171
|
+
if (flyId != null) stepFly(dt);
|
|
172
|
+
if (!dirty && flyId == null && !fadeActive) return;
|
|
173
|
+
dirty = false;
|
|
174
|
+
if (!engine) return;
|
|
175
|
+
W = svgEl?.clientWidth || W;
|
|
176
|
+
H = svgEl?.clientHeight || H;
|
|
177
|
+
engine.setViewport(W, H);
|
|
178
|
+
const f = engine.frame();
|
|
179
|
+
fadeActive = applyEnterFades(f, now);
|
|
180
|
+
view = f;
|
|
181
|
+
onframe?.(f);
|
|
182
|
+
if (f.rootId !== lastRoot) {
|
|
183
|
+
lastRoot = f.rootId;
|
|
184
|
+
crumbs = ancestorsOf(f.rootId);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
onMount(() => {
|
|
189
|
+
W = svgEl?.clientWidth || 0;
|
|
190
|
+
H = svgEl?.clientHeight || 0;
|
|
191
|
+
const aspect = W && H ? W / H : 1;
|
|
192
|
+
const mergedConfig = { ...config, layoutOpts: { aspect, ...(config.layoutOpts || {}) } };
|
|
193
|
+
engine = createEngine({ index, layout, camera, config: mergedConfig });
|
|
194
|
+
if (typeof window !== 'undefined') {
|
|
195
|
+
window.__zoom = {
|
|
196
|
+
engine,
|
|
197
|
+
camera,
|
|
198
|
+
frame: () => engine.frame(),
|
|
199
|
+
state: () => ({ cam: camera.get(), rootId: engine.rootId, drawn: view.items.length })
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
engine.setViewport(W, H);
|
|
203
|
+
// gentle entrance: settle in from slightly zoomed-out
|
|
204
|
+
engine.fit();
|
|
205
|
+
const f0 = camera.get();
|
|
206
|
+
camera.set({
|
|
207
|
+
sx: f0.sx * 0.72,
|
|
208
|
+
sy: f0.sy * 0.72,
|
|
209
|
+
x: W / 2 - ((W / 2 - f0.x) / f0.sx) * f0.sx * 0.72,
|
|
210
|
+
y: H / 2 - ((H / 2 - f0.y) / f0.sy) * f0.sy * 0.72
|
|
211
|
+
});
|
|
212
|
+
flyId = engine.rootId;
|
|
213
|
+
lastRoot = engine.rootId;
|
|
214
|
+
crumbs = ancestorsOf(engine.rootId);
|
|
215
|
+
|
|
216
|
+
const ro = new ResizeObserver(() => {
|
|
217
|
+
W = svgEl?.clientWidth || W;
|
|
218
|
+
H = svgEl?.clientHeight || H;
|
|
219
|
+
dirty = true;
|
|
220
|
+
});
|
|
221
|
+
if (svgEl) ro.observe(svgEl);
|
|
222
|
+
const unsub = camera.state.subscribe(() => (dirty = true));
|
|
223
|
+
interaction = createInteraction({
|
|
224
|
+
camera,
|
|
225
|
+
svgElRef: () => svgEl,
|
|
226
|
+
onChange: () => (dirty = true),
|
|
227
|
+
onAnchor: (x, y) => engine.setAnchor(x, y)
|
|
228
|
+
});
|
|
229
|
+
raf = requestAnimationFrame(loop);
|
|
230
|
+
return () => {
|
|
231
|
+
ro.disconnect();
|
|
232
|
+
unsub();
|
|
233
|
+
interaction?.destroy?.();
|
|
234
|
+
cancelAnimationFrame(raf);
|
|
235
|
+
};
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
// ---------- fly-to spring (dive in / rise out), robust to mid-flight rebasing ----------
|
|
239
|
+
function desiredCam(id) {
|
|
240
|
+
const cam = camera.get();
|
|
241
|
+
if (engine.rootId === id) return engine.fitCamera(0.82);
|
|
242
|
+
const v = engine.layout.nodes.get(id);
|
|
243
|
+
if (v) {
|
|
244
|
+
// descendant in the current layout → zoom in and centre it
|
|
245
|
+
const ext = 0.94 * 0.5 * Math.min(W, H); // past the descend threshold
|
|
246
|
+
const s = ext / v.size;
|
|
247
|
+
return { sx: s, sy: s, x: W / 2 - v.cx * s, y: H / 2 - v.cy * s };
|
|
248
|
+
}
|
|
249
|
+
// ancestor above the current root → zoom out, keep the viewport centre fixed
|
|
250
|
+
const f = 0.5;
|
|
251
|
+
const wx = (W / 2 - cam.x) / cam.sx;
|
|
252
|
+
const wy = (H / 2 - cam.y) / cam.sy;
|
|
253
|
+
return {
|
|
254
|
+
sx: cam.sx * f,
|
|
255
|
+
sy: cam.sy * f,
|
|
256
|
+
x: W / 2 - wx * cam.sx * f,
|
|
257
|
+
y: H / 2 - wy * cam.sy * f
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
function stepFly(dt) {
|
|
261
|
+
// If a DIVE has passed below its target (single-child chains can legally
|
|
262
|
+
// chain-descend during the fit), it is done — stop steering. A RISE to an
|
|
263
|
+
// ancestor must NOT trip this: there the target being an ancestor of the
|
|
264
|
+
// root is the whole point, and we keep flying out until we reach it.
|
|
265
|
+
if (flyMode === 'dive' && engine.rootId !== flyId && !engine.layout.nodes.get(flyId)) {
|
|
266
|
+
let cur = engine.rootId;
|
|
267
|
+
let guard = 0;
|
|
268
|
+
while (cur != null && guard++ < 64) {
|
|
269
|
+
cur = index.parent.get(cur);
|
|
270
|
+
if (cur === flyId) {
|
|
271
|
+
flyId = null;
|
|
272
|
+
engine.clearAnchor();
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
const des = desiredCam(flyId);
|
|
278
|
+
if (!des) return (flyId = null);
|
|
279
|
+
const cam = camera.get();
|
|
280
|
+
const k = 1 - Math.exp(-Math.min(dt, 0.05) / 0.13);
|
|
281
|
+
const v = engine.layout.nodes.get(flyId);
|
|
282
|
+
if (v) {
|
|
283
|
+
// Steer relative to the LIVE node, not in camera-parameter space: zoom
|
|
284
|
+
// anchored at the node's current screen position (so it stays put while
|
|
285
|
+
// scaling — it can never shoot offscreen mid-flight) and glide it toward
|
|
286
|
+
// the viewport centre. Re-reading the node each frame keeps this exact
|
|
287
|
+
// across mid-flight rebases.
|
|
288
|
+
const nx = v.cx * cam.sx + cam.x;
|
|
289
|
+
const ny = v.cy * cam.sy + cam.y;
|
|
290
|
+
engine.setAnchor(nx, ny); // descend follows the dive target, not the centre
|
|
291
|
+
const fx = Math.exp(Math.log(des.sx / cam.sx) * k);
|
|
292
|
+
const fy = Math.exp(Math.log(des.sy / cam.sy) * k);
|
|
293
|
+
camera.zoomAtXY(fx, fy, nx, ny);
|
|
294
|
+
const c2 = camera.get();
|
|
295
|
+
const mx = v.cx * c2.sx + c2.x;
|
|
296
|
+
const my = v.cy * c2.sy + c2.y;
|
|
297
|
+
camera.pan((W / 2 - mx) * k, (H / 2 - my) * k);
|
|
298
|
+
if (engine.rootId === flyId) {
|
|
299
|
+
const c3 = camera.get();
|
|
300
|
+
const settled =
|
|
301
|
+
Math.abs(Math.log(des.sx / c3.sx)) < 0.02 &&
|
|
302
|
+
Math.hypot(W / 2 - (v.cx * c3.sx + c3.x), H / 2 - (v.cy * c3.sy + c3.y)) < 1.5;
|
|
303
|
+
if (settled) {
|
|
304
|
+
camera.set(des);
|
|
305
|
+
engine.clearAnchor();
|
|
306
|
+
flyId = null;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
} else {
|
|
310
|
+
// target is an ancestor above the current layout: ease outward at the
|
|
311
|
+
// centre until the ascend re-roots bring it into the layout
|
|
312
|
+
engine.clearAnchor();
|
|
313
|
+
const f = Math.exp(Math.log(0.5) * k);
|
|
314
|
+
camera.zoomAt(f, W / 2, H / 2);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
let flyMode = 'dive';
|
|
318
|
+
function flyTo(id) {
|
|
319
|
+
if (id == null) return;
|
|
320
|
+
// rising if the target is the current root or one of its ancestors
|
|
321
|
+
let mode = id === engine.rootId ? 'rise' : 'dive';
|
|
322
|
+
if (mode === 'dive') {
|
|
323
|
+
let cur = engine.rootId;
|
|
324
|
+
let guard = 0;
|
|
325
|
+
while (cur != null && guard++ < 64) {
|
|
326
|
+
cur = index.parent.get(cur);
|
|
327
|
+
if (cur === id) {
|
|
328
|
+
mode = 'rise';
|
|
329
|
+
break;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
flyMode = mode;
|
|
334
|
+
flyId = id;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function ancestorsOf(id) {
|
|
338
|
+
const out = [];
|
|
339
|
+
let cur = id;
|
|
340
|
+
let guard = 0;
|
|
341
|
+
while (cur != null && guard++ < 64) {
|
|
342
|
+
out.push(cur);
|
|
343
|
+
cur = index.parent.get(cur);
|
|
344
|
+
}
|
|
345
|
+
return out.reverse();
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// ---------- picking / hover / click ----------
|
|
349
|
+
function localXY(e) {
|
|
350
|
+
const r = svgEl.getBoundingClientRect();
|
|
351
|
+
return [e.clientX - r.left, e.clientY - r.top];
|
|
352
|
+
}
|
|
353
|
+
function pickAt(px, py) {
|
|
354
|
+
const cam = camera.get();
|
|
355
|
+
const wx = (px - cam.x) / cam.sx;
|
|
356
|
+
const wy = (py - cam.y) / cam.sy;
|
|
357
|
+
let best = null;
|
|
358
|
+
for (const it of view.items) {
|
|
359
|
+
// only pick what the user can actually SEE — children mid fade-in
|
|
360
|
+
// (low alpha) must not steal the click from their parent
|
|
361
|
+
if (it.alpha < 0.5) continue;
|
|
362
|
+
const inside =
|
|
363
|
+
it.r != null
|
|
364
|
+
? (wx - it.cx) ** 2 + (wy - it.cy) ** 2 <= it.r * it.r
|
|
365
|
+
: wx >= it.x && wx <= it.x + it.w && wy >= it.y && wy <= it.y + it.h;
|
|
366
|
+
if (inside && (!best || it.depth > best.depth)) best = it;
|
|
367
|
+
}
|
|
368
|
+
return best;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
let downAt = null;
|
|
372
|
+
let pointerActive = false;
|
|
373
|
+
function onPD(e) {
|
|
374
|
+
if (flyId != null) {
|
|
375
|
+
flyId = null;
|
|
376
|
+
engine?.clearAnchor();
|
|
377
|
+
}
|
|
378
|
+
downAt = { x: e.clientX, y: e.clientY, t: performance.now() };
|
|
379
|
+
pointerActive = true;
|
|
380
|
+
interaction?.onPointerDown(e);
|
|
381
|
+
}
|
|
382
|
+
function onPM(e) {
|
|
383
|
+
interaction?.onPointerMove(e);
|
|
384
|
+
if (!pointerActive && svgEl) {
|
|
385
|
+
const [x, y] = localXY(e);
|
|
386
|
+
const it = pickAt(x, y);
|
|
387
|
+
const id = it?.id ?? null;
|
|
388
|
+
if (id !== hoverId) {
|
|
389
|
+
hoverId = id;
|
|
390
|
+
dirty = true;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
function onPU(e) {
|
|
395
|
+
interaction?.onPointerUp(e);
|
|
396
|
+
pointerActive = false;
|
|
397
|
+
if (downAt) {
|
|
398
|
+
const moved = Math.hypot(e.clientX - downAt.x, e.clientY - downAt.y);
|
|
399
|
+
const dt = performance.now() - downAt.t;
|
|
400
|
+
if (moved < 6 && dt < 350 && svgEl) {
|
|
401
|
+
const [x, y] = localXY(e);
|
|
402
|
+
const it = pickAt(x, y);
|
|
403
|
+
if (it) flyTo(it.id);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
downAt = null;
|
|
407
|
+
}
|
|
408
|
+
function onWheel(e) {
|
|
409
|
+
flyId = null;
|
|
410
|
+
interaction?.onWheel(e);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// hovered item (for the highlight ring), resolved from the current frame
|
|
414
|
+
let hoverItem = $derived(
|
|
415
|
+
hoverId == null ? null : view.items.find((i) => i.id === hoverId) || null
|
|
416
|
+
);
|
|
417
|
+
// label screen position helper
|
|
418
|
+
function sx(it) {
|
|
419
|
+
return it.cx * view.cam.sx + view.cam.x;
|
|
420
|
+
}
|
|
421
|
+
function sy(it) {
|
|
422
|
+
return it.cy * view.cam.sy + view.cam.y;
|
|
423
|
+
}
|
|
424
|
+
function labelSize(it) {
|
|
425
|
+
return clamp(it.ext / 3.2, 8, 15);
|
|
426
|
+
}
|
|
427
|
+
</script>
|
|
428
|
+
|
|
429
|
+
<div class="relative h-full w-full overflow-hidden bg-slate-50">
|
|
430
|
+
<svg
|
|
431
|
+
bind:this={svgEl}
|
|
432
|
+
class="block h-full w-full touch-none select-none"
|
|
433
|
+
role="img"
|
|
434
|
+
aria-label={title || 'Zoom scene'}
|
|
435
|
+
style={hoverItem ? 'cursor:pointer' : 'cursor:grab'}
|
|
436
|
+
onpointerdown={onPD}
|
|
437
|
+
onpointermove={onPM}
|
|
438
|
+
onpointerup={onPU}
|
|
439
|
+
onpointercancel={onPU}
|
|
440
|
+
onpointerleave={(e) => {
|
|
441
|
+
onPU(e);
|
|
442
|
+
hoverId = null;
|
|
443
|
+
}}
|
|
444
|
+
onwheel={onWheel}
|
|
445
|
+
>
|
|
446
|
+
<g transform={transformStr(view.cam)}>
|
|
447
|
+
<!-- tree links (parent -> child), under everything -->
|
|
448
|
+
{#if view.links}
|
|
449
|
+
<g aria-hidden="true">
|
|
450
|
+
{#each view.links as ln (ln.id)}
|
|
451
|
+
<line
|
|
452
|
+
x1={ln.x1}
|
|
453
|
+
y1={ln.y1}
|
|
454
|
+
x2={ln.x2}
|
|
455
|
+
y2={ln.y2}
|
|
456
|
+
stroke="#94a3b8"
|
|
457
|
+
stroke-opacity={0.65 * ln.alpha}
|
|
458
|
+
stroke-width="1"
|
|
459
|
+
vector-effect="non-scaling-stroke"
|
|
460
|
+
/>
|
|
461
|
+
{/each}
|
|
462
|
+
</g>
|
|
463
|
+
{/if}
|
|
464
|
+
|
|
465
|
+
{#each view.items as it (it.id)}
|
|
466
|
+
{#if nodeStyle === 'rings'}
|
|
467
|
+
<!-- the radial fractal, pared back: a hairline outer ring and the
|
|
468
|
+
filled core (where links stop and the label sits) -->
|
|
469
|
+
<g opacity={it.alpha} fill="none" vector-effect="non-scaling-stroke">
|
|
470
|
+
<circle
|
|
471
|
+
cx={it.cx}
|
|
472
|
+
cy={it.cy}
|
|
473
|
+
r={it.r}
|
|
474
|
+
stroke="#cbd5e1"
|
|
475
|
+
stroke-width="0.5"
|
|
476
|
+
vector-effect="non-scaling-stroke"
|
|
477
|
+
/>
|
|
478
|
+
<!-- core filled like the original so links stop at the core edge -->
|
|
479
|
+
<circle
|
|
480
|
+
cx={it.cx}
|
|
481
|
+
cy={it.cy}
|
|
482
|
+
r={0.5 * it.r}
|
|
483
|
+
fill="#ffffff"
|
|
484
|
+
fill-opacity="0.92"
|
|
485
|
+
stroke="#475569"
|
|
486
|
+
stroke-width={it.id === view.rootId ? 1.6 : 1}
|
|
487
|
+
vector-effect="non-scaling-stroke"
|
|
488
|
+
/>
|
|
489
|
+
</g>
|
|
490
|
+
{:else}
|
|
491
|
+
<circle
|
|
492
|
+
cx={it.cx}
|
|
493
|
+
cy={it.cy}
|
|
494
|
+
r={it.r}
|
|
495
|
+
fill={palette(it.absDepth)}
|
|
496
|
+
fill-opacity={fillOpacity(it)}
|
|
497
|
+
stroke={palette(it.absDepth)}
|
|
498
|
+
stroke-opacity={strokeOpacity(it)}
|
|
499
|
+
stroke-width={1 + it.openness * 0.7}
|
|
500
|
+
vector-effect="non-scaling-stroke"
|
|
501
|
+
/>
|
|
502
|
+
{/if}
|
|
503
|
+
|
|
504
|
+
<!-- LOD ghost preview: faint outlines of the children inside a not-yet-
|
|
505
|
+
opened node — what's in there, how many, how big — cross-fading
|
|
506
|
+
into the real children as the node opens -->
|
|
507
|
+
{#if it.preview}
|
|
508
|
+
<g opacity={(1 - it.openness) * it.alpha * 0.55} aria-hidden="true">
|
|
509
|
+
{#each it.preview as pv (pv.id)}
|
|
510
|
+
<circle
|
|
511
|
+
cx={pv.cx}
|
|
512
|
+
cy={pv.cy}
|
|
513
|
+
r={pv.r}
|
|
514
|
+
fill="none"
|
|
515
|
+
stroke="#ffffff"
|
|
516
|
+
stroke-width="1"
|
|
517
|
+
vector-effect="non-scaling-stroke"
|
|
518
|
+
/>
|
|
519
|
+
{/each}
|
|
520
|
+
</g>
|
|
521
|
+
{/if}
|
|
522
|
+
{/each}
|
|
523
|
+
|
|
524
|
+
{#if hoverItem}
|
|
525
|
+
{#if nodeStyle === 'rings'}
|
|
526
|
+
<!-- hover = the same hairline outer ring, only a touch darker -->
|
|
527
|
+
<circle
|
|
528
|
+
cx={hoverItem.cx}
|
|
529
|
+
cy={hoverItem.cy}
|
|
530
|
+
r={hoverItem.r}
|
|
531
|
+
fill="none"
|
|
532
|
+
stroke="#94a3b8"
|
|
533
|
+
stroke-width="0.5"
|
|
534
|
+
vector-effect="non-scaling-stroke"
|
|
535
|
+
/>
|
|
536
|
+
{:else}
|
|
537
|
+
<circle
|
|
538
|
+
cx={hoverItem.cx}
|
|
539
|
+
cy={hoverItem.cy}
|
|
540
|
+
r={hoverItem.r}
|
|
541
|
+
fill="none"
|
|
542
|
+
stroke={accent}
|
|
543
|
+
stroke-width="2.5"
|
|
544
|
+
vector-effect="non-scaling-stroke"
|
|
545
|
+
/>
|
|
546
|
+
{/if}
|
|
547
|
+
{/if}
|
|
548
|
+
</g>
|
|
549
|
+
|
|
550
|
+
<!-- labels: screen space, constant size, no distortion -->
|
|
551
|
+
{#if showLabels}
|
|
552
|
+
<g class="pointer-events-none">
|
|
553
|
+
{#each view.items as it (it.id)}
|
|
554
|
+
{#if it.showLabel && (nodeStyle === 'rings' || it.openness < 0.7) && !hasPortal(it)}
|
|
555
|
+
<text
|
|
556
|
+
x={sx(it)}
|
|
557
|
+
y={sy(it)}
|
|
558
|
+
text-anchor="middle"
|
|
559
|
+
dominant-baseline="middle"
|
|
560
|
+
class="select-none"
|
|
561
|
+
fill={nodeStyle === 'rings' ? '#374151' : it.leaf ? '#0f172a' : '#ffffff'}
|
|
562
|
+
fill-opacity={nodeStyle === 'rings' ? it.alpha : labelOpacity(it)}
|
|
563
|
+
style={`font-size:${nodeStyle === 'rings' ? clamp(it.ext / 4.2, 7.5, 26) : labelSize(it)}px;font-weight:500`}
|
|
564
|
+
>
|
|
565
|
+
<tspan x={sx(it)} dy={it.childCount ? '-0.25em' : '0'}>{shortLabel(it.id)}</tspan>
|
|
566
|
+
{#if it.childCount}
|
|
567
|
+
<tspan x={sx(it)} dy="1.15em" fill-opacity="0.7">[{it.childCount}]</tspan>
|
|
568
|
+
{/if}
|
|
569
|
+
</text>
|
|
570
|
+
{/if}
|
|
571
|
+
{/each}
|
|
572
|
+
</g>
|
|
573
|
+
{/if}
|
|
574
|
+
</svg>
|
|
575
|
+
|
|
576
|
+
<!-- Portal overlay: real HTML components in the square inscribed in each
|
|
577
|
+
visible core, positioned per frame in SCREEN pixels — native-resolution
|
|
578
|
+
layout, crisp text, true CSS. Non-interactive so pan/zoom/dive pass
|
|
579
|
+
through to the svg beneath. -->
|
|
580
|
+
{#if portal}
|
|
581
|
+
<div class="pointer-events-none absolute inset-0 overflow-hidden" aria-hidden="true">
|
|
582
|
+
{#each portalItems as p (p.id)}
|
|
583
|
+
<div
|
|
584
|
+
style={`position:absolute;left:0;top:0;transform:translate(${p.x}px,${p.y}px);width:${p.side}px;height:${p.side}px;opacity:${p.alpha};display:flex;align-items:center;justify-content:center;overflow:hidden;`}
|
|
585
|
+
>
|
|
586
|
+
{@render portal({ item: p.item, px: p.px })}
|
|
587
|
+
</div>
|
|
588
|
+
{/each}
|
|
589
|
+
</div>
|
|
590
|
+
{/if}
|
|
591
|
+
|
|
592
|
+
<!-- breadcrumb -->
|
|
593
|
+
<div
|
|
594
|
+
class="absolute top-2 left-1/2 z-10 flex max-w-[70%] -translate-x-1/2 items-center gap-1 overflow-hidden rounded-full border bg-white/90 px-2 py-1 text-xs shadow backdrop-blur"
|
|
595
|
+
>
|
|
596
|
+
{#each crumbs as c, i (c)}
|
|
597
|
+
{#if i > 0}<span class="text-slate-300">/</span>{/if}
|
|
598
|
+
<button
|
|
599
|
+
class="truncate rounded px-1.5 py-0.5 {i === crumbs.length - 1
|
|
600
|
+
? 'font-semibold text-slate-800'
|
|
601
|
+
: 'text-slate-500 hover:bg-slate-100 hover:text-slate-700'}"
|
|
602
|
+
onclick={() => flyTo(c)}>{shortLabel(c)}</button
|
|
603
|
+
>
|
|
604
|
+
{/each}
|
|
605
|
+
</div>
|
|
606
|
+
|
|
607
|
+
<a
|
|
608
|
+
href={homeHref}
|
|
609
|
+
class="absolute top-2 left-2 z-10 rounded border bg-white/90 px-2 py-1 text-xs text-slate-600 no-underline shadow backdrop-blur hover:bg-white"
|
|
610
|
+
>← demos</a
|
|
611
|
+
>
|
|
612
|
+
|
|
613
|
+
{#if showHud}
|
|
614
|
+
<div
|
|
615
|
+
class="pointer-events-none absolute top-2 right-2 z-10 rounded border bg-white/90 px-2 py-1 text-xs text-slate-600 shadow backdrop-blur"
|
|
616
|
+
>
|
|
617
|
+
{title ? title + ' · ' : ''}{view.items.length} drawn · ×{view.cam.sx.toFixed(2)}
|
|
618
|
+
</div>
|
|
619
|
+
<div
|
|
620
|
+
class="pointer-events-none absolute bottom-2 left-1/2 z-10 -translate-x-1/2 rounded-full border bg-white/80 px-3 py-1 text-xs text-slate-500 shadow backdrop-blur"
|
|
621
|
+
>
|
|
622
|
+
scroll / pinch to zoom · drag to pan · click to dive · breadcrumb to rise
|
|
623
|
+
</div>
|
|
624
|
+
{/if}
|
|
625
|
+
</div>
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
export default ZoomScene;
|
|
2
|
+
type ZoomScene = {
|
|
3
|
+
$on?(type: string, callback: (e: any) => void): () => void;
|
|
4
|
+
$set?(props: Partial<$$ComponentProps>): void;
|
|
5
|
+
};
|
|
6
|
+
declare const ZoomScene: import("svelte").Component<{
|
|
7
|
+
index: any;
|
|
8
|
+
layout: any;
|
|
9
|
+
config?: Record<string, any>;
|
|
10
|
+
palette?: (depth: any) => string;
|
|
11
|
+
showLabels?: boolean;
|
|
12
|
+
showHud?: boolean;
|
|
13
|
+
title?: string;
|
|
14
|
+
circleFill?: string;
|
|
15
|
+
nodeStyle?: string;
|
|
16
|
+
homeHref?: string;
|
|
17
|
+
accent?: string;
|
|
18
|
+
portal?: any;
|
|
19
|
+
portalMinPx?: number;
|
|
20
|
+
portalMaxPx?: number;
|
|
21
|
+
onframe?: any;
|
|
22
|
+
}, {}, "">;
|
|
23
|
+
type $$ComponentProps = {
|
|
24
|
+
index: any;
|
|
25
|
+
layout: any;
|
|
26
|
+
config?: Record<string, any>;
|
|
27
|
+
palette?: (depth: any) => string;
|
|
28
|
+
showLabels?: boolean;
|
|
29
|
+
showHud?: boolean;
|
|
30
|
+
title?: string;
|
|
31
|
+
circleFill?: string;
|
|
32
|
+
nodeStyle?: string;
|
|
33
|
+
homeHref?: string;
|
|
34
|
+
accent?: string;
|
|
35
|
+
portal?: any;
|
|
36
|
+
portalMinPx?: number;
|
|
37
|
+
portalMaxPx?: number;
|
|
38
|
+
onframe?: any;
|
|
39
|
+
};
|