@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,232 @@
1
+ // Pointer / pinch / wheel interaction with inertia. Framework-agnostic: it
2
+ // mutates the camera (always through the store, so a single subscriber can drive
3
+ // re-rendering) and calls optional lifecycle hooks, targeting the affine camera
4
+ // (uniform zoom).
5
+
6
+ export function createInteraction({ camera, svgElRef, onChange, onGesture, onAnchor }) {
7
+ let dragging = false;
8
+ let lastX = 0;
9
+ let lastY = 0;
10
+ let rafId = 0;
11
+ let lastTick = 0;
12
+ let panVx = 0;
13
+ let panVy = 0;
14
+ let zoomV = 0;
15
+ let zoomAnchorX = 0;
16
+ let zoomAnchorY = 0;
17
+ let lastMoveTS = 0;
18
+ let lastWheelTS = 0;
19
+ const PAN_FRICTION = 5.0;
20
+ const ZOOM_FRICTION = 6.0;
21
+ const PAN_EPS = 5;
22
+ const ZOOM_EPS = 1e-4;
23
+ const activePointers = new Map();
24
+ let pinchActive = false;
25
+ let pinchData = null;
26
+
27
+ const notify = () => onChange?.();
28
+
29
+ function stopInertia() {
30
+ if (rafId) cancelAnimationFrame(rafId);
31
+ rafId = 0;
32
+ lastTick = 0;
33
+ panVx = panVy = zoomV = 0;
34
+ }
35
+ function ensureLoop() {
36
+ if (!rafId) rafId = requestAnimationFrame(tickInertia);
37
+ }
38
+
39
+ function tickInertia(ts) {
40
+ const now = ts || performance.now();
41
+ if (!lastTick) {
42
+ lastTick = now;
43
+ rafId = requestAnimationFrame(tickInertia);
44
+ return;
45
+ }
46
+ const dt = Math.max(0, (now - lastTick) / 1000);
47
+ lastTick = now;
48
+ let active = false;
49
+
50
+ if (Math.abs(zoomV) > ZOOM_EPS) {
51
+ camera.zoomAt(Math.exp(zoomV * dt), zoomAnchorX, zoomAnchorY);
52
+ zoomV *= Math.exp(-ZOOM_FRICTION * dt);
53
+ if (Math.abs(zoomV) > ZOOM_EPS) active = true;
54
+ else zoomV = 0;
55
+ }
56
+ if (Math.hypot(panVx, panVy) > PAN_EPS) {
57
+ camera.pan(panVx * dt, panVy * dt);
58
+ const decay = Math.exp(-PAN_FRICTION * dt);
59
+ panVx *= decay;
60
+ panVy *= decay;
61
+ if (Math.hypot(panVx, panVy) > PAN_EPS) active = true;
62
+ else panVx = panVy = 0;
63
+ }
64
+ notify();
65
+ if (active) rafId = requestAnimationFrame(tickInertia);
66
+ else {
67
+ rafId = 0;
68
+ lastTick = 0;
69
+ }
70
+ }
71
+
72
+ function pointerToSvg(clientX, clientY) {
73
+ const el = svgElRef?.();
74
+ if (!el) return { x: clientX, y: clientY };
75
+ try {
76
+ if (el.createSVGPoint && el.getScreenCTM) {
77
+ const pt = el.createSVGPoint();
78
+ pt.x = clientX;
79
+ pt.y = clientY;
80
+ const m = el.getScreenCTM();
81
+ if (m) {
82
+ const loc = pt.matrixTransform(m.inverse());
83
+ return { x: loc.x, y: loc.y };
84
+ }
85
+ }
86
+ } catch {}
87
+ const rect = el.getBoundingClientRect?.();
88
+ return rect ? { x: clientX - rect.left, y: clientY - rect.top } : { x: clientX, y: clientY };
89
+ }
90
+
91
+ function cachePointer(e, force = false) {
92
+ if (!force && !activePointers.has(e.pointerId)) return;
93
+ const p = pointerToSvg(e.clientX, e.clientY);
94
+ const c = activePointers.get(e.pointerId) ?? {};
95
+ c.clientX = e.clientX;
96
+ c.clientY = e.clientY;
97
+ c.svgX = p.x;
98
+ c.svgY = p.y;
99
+ activePointers.set(e.pointerId, c);
100
+ }
101
+
102
+ function startPinch() {
103
+ if (pinchActive || activePointers.size < 2) return;
104
+ const ids = Array.from(activePointers.keys()).slice(0, 2);
105
+ const p1 = activePointers.get(ids[0]);
106
+ const p2 = activePointers.get(ids[1]);
107
+ if (!p1 || !p2) return;
108
+ const cam = camera.get();
109
+ const w1 = { x: (p1.svgX - cam.x) / cam.sx, y: (p1.svgY - cam.y) / cam.sy };
110
+ const w2 = { x: (p2.svgX - cam.x) / cam.sx, y: (p2.svgY - cam.y) / cam.sy };
111
+ const dw = Math.hypot(w2.x - w1.x, w2.y - w1.y);
112
+ if (!isFinite(dw) || dw === 0) return;
113
+ pinchActive = true;
114
+ pinchData = { ids, world1: w1, world2: w2, worldDistance: dw };
115
+ dragging = false;
116
+ panVx = panVy = zoomV = 0;
117
+ onGesture?.('start');
118
+ }
119
+
120
+ function applyPinch() {
121
+ if (!pinchActive || !pinchData) return;
122
+ const p1 = activePointers.get(pinchData.ids[0]);
123
+ const p2 = activePointers.get(pinchData.ids[1]);
124
+ if (!p1 || !p2) return;
125
+ const ds = Math.hypot(p2.svgX - p1.svgX, p2.svgY - p1.svgY);
126
+ if (!isFinite(ds) || ds === 0) return;
127
+ const scale = ds / pinchData.worldDistance;
128
+ const tx1 = p1.svgX - pinchData.world1.x * scale;
129
+ const ty1 = p1.svgY - pinchData.world1.y * scale;
130
+ const tx2 = p2.svgX - pinchData.world2.x * scale;
131
+ const ty2 = p2.svgY - pinchData.world2.y * scale;
132
+ onAnchor?.((p1.svgX + p2.svgX) / 2, (p1.svgY + p2.svgY) / 2);
133
+ camera.set({ x: (tx1 + tx2) / 2, y: (ty1 + ty2) / 2, scale });
134
+ notify();
135
+ }
136
+
137
+ function onPointerDown(e) {
138
+ if (e.button !== undefined && e.button !== 0) return;
139
+ stopInertia();
140
+ cachePointer(e, true);
141
+ try {
142
+ svgElRef?.()?.setPointerCapture?.(e.pointerId);
143
+ } catch {}
144
+ startPinch();
145
+ if (pinchActive || activePointers.size > 1) return;
146
+ dragging = true;
147
+ lastX = e.clientX;
148
+ lastY = e.clientY;
149
+ lastMoveTS = e.timeStamp || performance.now();
150
+ }
151
+
152
+ function onPointerMove(e) {
153
+ cachePointer(e);
154
+ if (pinchActive) {
155
+ applyPinch();
156
+ return;
157
+ }
158
+ if (activePointers.size >= 2) {
159
+ startPinch();
160
+ if (pinchActive) {
161
+ applyPinch();
162
+ return;
163
+ }
164
+ }
165
+ if (!dragging) return;
166
+ const dx = e.clientX - lastX;
167
+ const dy = e.clientY - lastY;
168
+ camera.pan(dx, dy);
169
+ lastX = e.clientX;
170
+ lastY = e.clientY;
171
+ const now = e.timeStamp || performance.now();
172
+ const dt = Math.max(1 / 240, (now - lastMoveTS) / 1000);
173
+ panVx = dx / dt;
174
+ panVy = dy / dt;
175
+ lastMoveTS = now;
176
+ notify();
177
+ }
178
+
179
+ function onPointerUp(e) {
180
+ const wasDragging = dragging && !pinchActive;
181
+ activePointers.delete(e.pointerId);
182
+ try {
183
+ svgElRef?.()?.releasePointerCapture?.(e.pointerId);
184
+ } catch {}
185
+ if (pinchActive) {
186
+ const stillValid = pinchData?.ids?.every((id) => activePointers.has(id));
187
+ if (!stillValid) {
188
+ pinchActive = false;
189
+ pinchData = null;
190
+ panVx = panVy = zoomV = 0;
191
+ onGesture?.('end');
192
+ } else return;
193
+ }
194
+ dragging = false;
195
+ if (wasDragging && Math.hypot(panVx, panVy) > PAN_EPS) ensureLoop();
196
+ // promote a remaining pointer to a drag
197
+ if (!pinchActive && activePointers.size === 1) {
198
+ const it = activePointers.entries().next();
199
+ if (!it.done) {
200
+ const [, info] = it.value;
201
+ dragging = true;
202
+ lastX = info.clientX;
203
+ lastY = info.clientY;
204
+ lastMoveTS = e.timeStamp || performance.now();
205
+ }
206
+ }
207
+ }
208
+
209
+ function onWheel(e) {
210
+ e.preventDefault();
211
+ const p = pointerToSvg(e.clientX, e.clientY);
212
+ const factor = e.deltaY < 0 ? 1.1 : 1 / 1.1;
213
+ onAnchor?.(p.x, p.y);
214
+ camera.zoomAt(factor, p.x, p.y);
215
+ zoomAnchorX = p.x;
216
+ zoomAnchorY = p.y;
217
+ const now = e.timeStamp || performance.now();
218
+ const dt = Math.max(1 / 240, lastWheelTS ? (now - lastWheelTS) / 1000 : 1 / 60);
219
+ lastWheelTS = now;
220
+ zoomV += Math.log(factor) / dt;
221
+ notify();
222
+ ensureLoop();
223
+ }
224
+
225
+ return {
226
+ onPointerDown,
227
+ onPointerMove,
228
+ onPointerUp,
229
+ onWheel,
230
+ destroy: stopInertia
231
+ };
232
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * @param {{x:number,y:number,sx:number,sy:number}} cam current camera
3
+ * @param {{cx:number,cy:number,hx:number,hy:number}} oldBox F in the old frame
4
+ * @param {{cx:number,cy:number,hx:number,hy:number}} newBox F in the new frame
5
+ * @returns {{x:number,y:number,sx:number,sy:number}} new camera
6
+ */
7
+ export function rebaseCamera(cam: {
8
+ x: number;
9
+ y: number;
10
+ sx: number;
11
+ sy: number;
12
+ }, oldBox: {
13
+ cx: number;
14
+ cy: number;
15
+ hx: number;
16
+ hy: number;
17
+ }, newBox: {
18
+ cx: number;
19
+ cy: number;
20
+ hx: number;
21
+ hy: number;
22
+ }): {
23
+ x: number;
24
+ y: number;
25
+ sx: number;
26
+ sy: number;
27
+ };
28
+ /** Box of a layout node view (circle: size=r; rect: w,h present). */
29
+ export function nodeBox(view: any): {
30
+ cx: any;
31
+ cy: any;
32
+ hx: any;
33
+ hy: any;
34
+ };
35
+ export function screenExtent(view: any, cam: any): number;
@@ -0,0 +1,41 @@
1
+ // Referential rebasing — the heart of infinite zoom (THEORY.md §4).
2
+ //
3
+ // We have one node F that exists in both the OLD frame and the NEW (re-rooted)
4
+ // frame. Given F's axis-aligned box in each frame and the current camera, we
5
+ // produce the new camera so the on-screen image is pixel-identical. The rebase
6
+ // is therefore invisible: the scale readout jumps, the picture does not.
7
+ //
8
+ // A "box" is { cx, cy, hx, hy } — center and half-extents. Circles use
9
+ // hx = hy = radius; rects use hx = w/2, hy = h/2. Per-axis math handles both the
10
+ // uniform (circle/pack/network) and anisotropic (treemap) cases with one path.
11
+
12
+ /**
13
+ * @param {{x:number,y:number,sx:number,sy:number}} cam current camera
14
+ * @param {{cx:number,cy:number,hx:number,hy:number}} oldBox F in the old frame
15
+ * @param {{cx:number,cy:number,hx:number,hy:number}} newBox F in the new frame
16
+ * @returns {{x:number,y:number,sx:number,sy:number}} new camera
17
+ */
18
+ export function rebaseCamera(cam, oldBox, newBox) {
19
+ // scale_new = scale_old * (oldExtent / newExtent) [THEORY §4]
20
+ const sx = cam.sx * (oldBox.hx / newBox.hx);
21
+ const sy = cam.sy * (oldBox.hy / newBox.hy);
22
+ // t_new = t_old + center_old * scale_old - center_new * scale_new
23
+ const x = cam.x + oldBox.cx * cam.sx - newBox.cx * sx;
24
+ const y = cam.y + oldBox.cy * cam.sy - newBox.cy * sy;
25
+ return { x, y, sx, sy };
26
+ }
27
+
28
+ /** Box of a layout node view (circle: size=r; rect: w,h present). */
29
+ export function nodeBox(view) {
30
+ if (view.w != null && view.h != null) {
31
+ return { cx: view.cx, cy: view.cy, hx: view.w / 2, hy: view.h / 2 };
32
+ }
33
+ return { cx: view.cx, cy: view.cy, hx: view.size, hy: view.size };
34
+ }
35
+
36
+ // On-screen size (px) of a node's smaller extent under a camera — the quantity
37
+ // the rebase thresholds (descend / ascend) are measured against (THEORY.md §4).
38
+ export function screenExtent(view, cam) {
39
+ const box = nodeBox(view);
40
+ return Math.min(box.hx * cam.sx, box.hy * cam.sy);
41
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * @param {{nodes: Array<{id:string, parent?:string, children?:string[], value?:number}>}} data
3
+ * @returns {{
4
+ * byId: Map<string, any>,
5
+ * children: Map<string, string[]>,
6
+ * parent: Map<string, string|null>,
7
+ * roots: string[],
8
+ * value: Map<string, number>, // leaf value (data.value ?? 1) summed up the tree
9
+ * count: Map<string, number>, // number of leaves under (incl. self if leaf)
10
+ * height: Map<string, number> // longest path to a leaf
11
+ * }}
12
+ */
13
+ export function indexTree(data: {
14
+ nodes: Array<{
15
+ id: string;
16
+ parent?: string;
17
+ children?: string[];
18
+ value?: number;
19
+ }>;
20
+ }): {
21
+ byId: Map<string, any>;
22
+ children: Map<string, string[]>;
23
+ parent: Map<string, string | null>;
24
+ roots: string[];
25
+ value: Map<string, number>;
26
+ count: Map<string, number>;
27
+ height: Map<string, number>;
28
+ };
29
+ /** Walk from id up to its ancestor `steps` levels above (clamped at the root). */
30
+ export function ascend(index: any, id: any, steps: any): any;
31
+ /** Full ancestor chain [id, parent, ..., root]. */
32
+ export function ancestors(index: any, id: any): any[];
@@ -0,0 +1,121 @@
1
+ // Shared tree/graph indexing for every layout.
2
+ //
3
+ // Accepts the loose `{ nodes: [{ id, parent?, children?, value? }] }` shape and
4
+ // produces a fast, immutable index: id -> node, parent/child maps, roots, plus
5
+ // rolled-up `value` and `count` per subtree (needed for pack/treemap sizing) and
6
+ // a stable child order (needed so layouts are deterministic across re-roots —
7
+ // the self-similarity invariant from THEORY.md §2).
8
+
9
+ /**
10
+ * @param {{nodes: Array<{id:string, parent?:string, children?:string[], value?:number}>}} data
11
+ * @returns {{
12
+ * byId: Map<string, any>,
13
+ * children: Map<string, string[]>,
14
+ * parent: Map<string, string|null>,
15
+ * roots: string[],
16
+ * value: Map<string, number>, // leaf value (data.value ?? 1) summed up the tree
17
+ * count: Map<string, number>, // number of leaves under (incl. self if leaf)
18
+ * height: Map<string, number> // longest path to a leaf
19
+ * }}
20
+ */
21
+ export function indexTree(data) {
22
+ const nodes = data?.nodes ?? [];
23
+ const byId = new Map();
24
+ const children = new Map();
25
+ const parent = new Map();
26
+
27
+ for (const n of nodes) {
28
+ byId.set(n.id, n);
29
+ if (!children.has(n.id)) children.set(n.id, []);
30
+ if (!parent.has(n.id)) parent.set(n.id, null);
31
+ }
32
+
33
+ const linkParentChild = (p, c) => {
34
+ if (!children.has(p)) children.set(p, []);
35
+ const arr = children.get(p);
36
+ if (!arr.includes(c)) arr.push(c);
37
+ // last writer wins for parent; trees have one parent anyway
38
+ parent.set(c, p);
39
+ };
40
+
41
+ for (const n of nodes) {
42
+ for (const c of n.children ?? []) {
43
+ if (byId.has(c)) linkParentChild(n.id, c);
44
+ }
45
+ if (n.parent != null && byId.has(n.parent)) linkParentChild(n.parent, n.id);
46
+ }
47
+
48
+ const roots = [];
49
+ for (const id of byId.keys()) {
50
+ if (parent.get(id) == null) roots.push(id);
51
+ }
52
+
53
+ // Roll up value / count / height with an explicit post-order DFS (no recursion
54
+ // limits on deep trees).
55
+ const value = new Map();
56
+ const count = new Map();
57
+ const height = new Map();
58
+ const visit = (start) => {
59
+ const stack = [[start, false]];
60
+ while (stack.length) {
61
+ const [id, processed] = stack.pop();
62
+ const kids = children.get(id) ?? [];
63
+ if (!processed) {
64
+ stack.push([id, true]);
65
+ for (const k of kids) stack.push([k, false]);
66
+ } else {
67
+ if (kids.length === 0) {
68
+ const v = byId.get(id)?.value;
69
+ value.set(id, typeof v === 'number' && v > 0 ? v : 1);
70
+ count.set(id, 1);
71
+ height.set(id, 0);
72
+ } else {
73
+ let sv = 0;
74
+ let sc = 0;
75
+ let h = 0;
76
+ for (const k of kids) {
77
+ sv += value.get(k) ?? 1;
78
+ sc += count.get(k) ?? 1;
79
+ h = Math.max(h, (height.get(k) ?? 0) + 1);
80
+ }
81
+ // internal nodes may also carry their own value contribution
82
+ const own = byId.get(id)?.value;
83
+ value.set(id, sv + (typeof own === 'number' && own > 0 ? own : 0));
84
+ count.set(id, sc);
85
+ height.set(id, h);
86
+ }
87
+ }
88
+ }
89
+ };
90
+ for (const r of roots) visit(r);
91
+ // guard: any node not reached (cycles / detached) gets defaults
92
+ for (const id of byId.keys()) {
93
+ if (!value.has(id)) value.set(id, 1);
94
+ if (!count.has(id)) count.set(id, 1);
95
+ if (!height.has(id)) height.set(id, 0);
96
+ }
97
+
98
+ return { byId, children, parent, roots, value, count, height };
99
+ }
100
+
101
+ /** Walk from id up to its ancestor `steps` levels above (clamped at the root). */
102
+ export function ascend(index, id, steps) {
103
+ let cur = id;
104
+ for (let i = 0; i < steps; i++) {
105
+ const p = index.parent.get(cur);
106
+ if (p == null) break;
107
+ cur = p;
108
+ }
109
+ return cur;
110
+ }
111
+
112
+ /** Full ancestor chain [id, parent, ..., root]. */
113
+ export function ancestors(index, id) {
114
+ const out = [];
115
+ let cur = id;
116
+ while (cur != null) {
117
+ out.push(cur);
118
+ cur = index.parent.get(cur);
119
+ }
120
+ return out;
121
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * @param {{depth?:number, seed?:number, maxNodes?:number}} opts
3
+ * @returns {{nodes: Array<{id:string, children:string[]}>, stats:{nodes:number, depth:number, leaves:number}}}
4
+ */
5
+ export function generateDeepTree({ depth, seed, maxNodes }?: {
6
+ depth?: number;
7
+ seed?: number;
8
+ maxNodes?: number;
9
+ }): {
10
+ nodes: Array<{
11
+ id: string;
12
+ children: string[];
13
+ }>;
14
+ stats: {
15
+ nodes: number;
16
+ depth: number;
17
+ leaves: number;
18
+ };
19
+ };
@@ -0,0 +1,60 @@
1
+ // Deterministic deep-tree generator: a hierarchy that actually GOES DOWN —
2
+ // guaranteed veins reaching `depth` generations, with bushy shallows and
3
+ // thinning depths so per-referential layouts stay cheap. The point: give the
4
+ // infinite-zoom engine real depth to fall into (the engine's maxDepth is only
5
+ // the per-referential layout window; total depth is explored by rebasing).
6
+
7
+ function seeded(seed) {
8
+ let s = seed >>> 0;
9
+ return () => {
10
+ s = (s * 1664525 + 1013904223) >>> 0;
11
+ return s / 0x100000000;
12
+ };
13
+ }
14
+
15
+ /**
16
+ * @param {{depth?:number, seed?:number, maxNodes?:number}} opts
17
+ * @returns {{nodes: Array<{id:string, children:string[]}>, stats:{nodes:number, depth:number, leaves:number}}}
18
+ */
19
+ export function generateDeepTree({ depth = 16, seed = 42, maxNodes = 9000 } = {}) {
20
+ const rand = seeded(seed);
21
+ const nodes = [];
22
+ let count = 0;
23
+ let maxDepthSeen = 0;
24
+ let leaves = 0;
25
+
26
+ // children per node: bushy near the top, thinning with depth; `onVein`
27
+ // guarantees at least one child so the branch reaches full depth
28
+ function childCount(d, onVein) {
29
+ if (d >= depth) return 0;
30
+ let n;
31
+ if (d < 2)
32
+ n = 4 + Math.floor(rand() * 5); // 4-8
33
+ else if (d < 6)
34
+ n = Math.floor(rand() * 6); // 0-5
35
+ else if (d < 11)
36
+ n = Math.floor(rand() * 4); // 0-3
37
+ else n = Math.floor(rand() * 3); // 0-2
38
+ if (onVein) n = Math.max(1, n);
39
+ return n;
40
+ }
41
+
42
+ function build(id, d, onVein) {
43
+ count++;
44
+ maxDepthSeen = Math.max(maxDepthSeen, d);
45
+ const n = count < maxNodes ? childCount(d, onVein) : 0;
46
+ const children = [];
47
+ if (n === 0) leaves++;
48
+ // one random child inherits the vein so SOME path always reaches `depth`
49
+ const veinIdx = Math.floor(rand() * Math.max(1, n));
50
+ for (let i = 0; i < n; i++) {
51
+ const cid = `${id}-${i + 1}`;
52
+ children.push(cid);
53
+ build(cid, d + 1, onVein && i === veinIdx);
54
+ }
55
+ nodes.push({ id, children });
56
+ }
57
+
58
+ build('n0', 0, true);
59
+ return { nodes, stats: { nodes: count, depth: maxDepthSeen, leaves } };
60
+ }
@@ -0,0 +1,7 @@
1
+ export { default as ZoomScene } from "./viz/ZoomScene.svelte";
2
+ export { createEngine } from "./core/engine.js";
3
+ export { createCamera } from "./core/camera.js";
4
+ export { createInteraction } from "./core/interaction.js";
5
+ export { radialLayout } from "./layouts/radial.js";
6
+ export { rebaseCamera, nodeBox, screenExtent } from "./core/rebase.js";
7
+ export { indexTree, ascend, ancestors } from "./core/tree.js";
package/dist/index.js ADDED
@@ -0,0 +1,17 @@
1
+ // Infinite-zoom visualization library.
2
+ //
3
+ // One generic engine (camera + referential rebasing + LOD + culling) driving a
4
+ // radial fractal layout. See THEORY.md for the math, LLM.md to integrate.
5
+
6
+ // Generic renderer
7
+ export { default as ZoomScene } from './viz/ZoomScene.svelte';
8
+
9
+ // Engine core
10
+ export { createEngine } from './core/engine.js';
11
+ export { createCamera } from './core/camera.js';
12
+ export { createInteraction } from './core/interaction.js';
13
+ export { rebaseCamera, nodeBox, screenExtent } from './core/rebase.js';
14
+ export { indexTree, ascend, ancestors } from './core/tree.js';
15
+
16
+ // Layout (conforms to layout(index, rootId, opts))
17
+ export { radialLayout } from './layouts/radial.js';
@@ -0,0 +1,6 @@
1
+ export function radialLayout(index: any, rootId: any, opts?: {}): {
2
+ rootId: any;
3
+ nodes: Map<any, any>;
4
+ order: any[];
5
+ };
6
+ export const RADIAL_KIND: "circle";
@@ -0,0 +1,63 @@
1
+ // Radial fractal layout (THEORY.md §7.1).
2
+ //
3
+ // Node = circle of radius R. Children sit on a ring at 0.75R; each child radius
4
+ // is min(0.15R, 0.75R·sin(π/n)) so n children tile the ring without overlap.
5
+ // Self-similar: a node's child arrangement depends only on its child count, so
6
+ // re-rooting is an exact similarity (rebasing is pixel-perfect).
7
+ //
8
+ // Layout contract (shared by every layout):
9
+ // layout(index, rootId, opts) -> {
10
+ // rootId,
11
+ // nodes: Map<id, view>, view = { id, parentId, depth, cx, cy, size, kind }
12
+ // order: string[] parents before children (draw order)
13
+ // }
14
+ // For circles, `size` is the radius; the engine reads cx/cy/size generically.
15
+
16
+ export const RADIAL_KIND = 'circle';
17
+
18
+ export function radialLayout(index, rootId, opts = {}) {
19
+ const R0 = opts.rootSize ?? 500;
20
+ const maxDepth = opts.maxDepth ?? 6;
21
+ const distribute = !!opts.distribute;
22
+ const startAngle = opts.startAngle ?? -Math.PI / 2;
23
+
24
+ const nodes = new Map();
25
+ const order = [];
26
+
27
+ const place = (id, cx, cy, R, depth, parentId) => {
28
+ nodes.set(id, { id, parentId, depth, cx, cy, size: R, kind: 'circle' });
29
+ order.push(id);
30
+ if (depth >= maxDepth) return;
31
+ const kids = index.children.get(id) ?? [];
32
+ const n = kids.length;
33
+ if (n === 0) return;
34
+
35
+ const redR = 0.75 * R;
36
+ const bandLimit = (opts.childBand ?? 0.15) * R;
37
+ let childR;
38
+ let step;
39
+ let start;
40
+ if (opts.spread != null) {
41
+ // fan: children on an arc of `spread` radians centred on `facing`
42
+ // (default east) — the tree reads left → right. Same locality and
43
+ // self-similarity as the full ring, just a restricted arc.
44
+ const facing = opts.facing ?? 0;
45
+ step = n > 1 ? opts.spread / (n - 1) : 0;
46
+ childR = n > 1 ? Math.min(bandLimit, redR * Math.sin(step / 2)) : bandLimit;
47
+ start = facing - (step * (n - 1)) / 2;
48
+ } else {
49
+ if (n <= 1) childR = bandLimit;
50
+ else childR = Math.min(bandLimit, redR * Math.sin(Math.PI / n));
51
+ step = distribute && n > 1 ? (2 * Math.PI) / n : 2 * Math.asin(Math.min(1, childR / redR));
52
+ start = startAngle;
53
+ }
54
+
55
+ for (let i = 0; i < n; i++) {
56
+ const a = start + i * step;
57
+ place(kids[i], cx + redR * Math.cos(a), cy + redR * Math.sin(a), childR, depth + 1, id);
58
+ }
59
+ };
60
+
61
+ place(rootId, 0, 0, R0, 0, index.parent.get(rootId) ?? null);
62
+ return { rootId, nodes, order };
63
+ }