@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.
@@ -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
+ };