archivision 0.0.0-stage → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1253 @@
1
+ FIGS3D.push({ id: 'az', name: '__NAME__', mod: (() => {
2
+ const AZ = __ATLAS__; // three levels deep: blocks, parts, pieces (parsed symbols in a repo, named pieces in an architecture)
3
+ const SCENE = __SCENE__; // where each block sits on the plate
4
+ const REPO = AZ.repo || null, ARCH = !REPO;
5
+ // the code roots, test runners and data store the scan found; the defaults are what older pages assumed
6
+ const ROOTS = AZ.roots || ['src'], ROOTS_TXT = ROOTS.map(r => r === '.' ? 'the repo' : r).join(', '), RUN = AZ.runner || { unit: 'Vitest', e2e: 'Playwright' }, STORE = AZ.store || { label: 'Prisma', what: 'models' };
7
+ const TEST_RE = new RegExp(`^(${[...ROOTS.map(r => r === '.' ? '__tests__' : `${r}/__tests__`), ...(AZ.testRoots || ['tests', 'e2e'])].map(x => x.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')})/`);
8
+ const RL = (AZ.rail && AZ.rail.label) || 'database', RLC = RL[0].toUpperCase() + RL.slice(1), WHOLE = REPO ? 'Whole repo' : 'Whole map';
9
+ const STILL = /[?&]still\b/.test(location.search);
10
+ const REDUCE = matchMedia('(prefers-reduced-motion: reduce)').matches;
11
+
12
+ /* ---------- helpers (as in the flat version) ---------- */
13
+ const clamp = v => Math.max(0, Math.min(1, v));
14
+ const segT = (v, a, b) => clamp((v - a) / (b - a));
15
+ const ez = t => t < .5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
16
+ const back = t => t <= 0 ? 0 : t >= 1 ? 1 : 1 + 2.2 * Math.pow(t - 1, 3) + 1.2 * Math.pow(t - 1, 2);
17
+ const CW = .61; // monospace advance, in em
18
+ const words = s => String(s).replace(/ ·/g, ' ·').split(' ');
19
+ const wrap = (s, n) => {
20
+ const o = []; let c = '';
21
+ const add = (w, sep) => { if (!c) c = w; else if ((c + sep + w).length <= n) c += sep + w; else { o.push(c); c = w; } };
22
+ for (const w of words(s)) {
23
+ if (w.length <= n) add(w, ' ');
24
+ else w.split(/(?<=[_(\/.-])|(?<=[a-z0-9])(?=[A-Z])/).forEach((q, i) => add(q, i ? '' : ' ')); // long identifiers break after _ ( / . - and between camelCase words
25
+ }
26
+ if (c) o.push(c); return o;
27
+ };
28
+ const fit = (s, width, hmax, fmax, lines, fmin = 3.5, step = .5) => {
29
+ for (let f = fmax; f >= fmin; f -= step) {
30
+ const n = Math.floor(width / (CW * f)); if (n < 1) continue;
31
+ const L = wrap(s, n);
32
+ if (L.length <= lines && L.every(l => l.length <= n) && L.length * f * 1.18 <= hmax) return { f, L };
33
+ }
34
+ return null;
35
+ };
36
+ const num = i => String(i + 1).padStart(2, '0');
37
+ const isPort = s => /^P\d+$/.test(s);
38
+ const portsIn = label => (String(label).match(/P(\d+)/g) || []).map(s => +s.slice(1));
39
+ const esc = s => String(s ?? '').replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c]);
40
+ const KIND = {
41
+ ui: { h: 14, label: 'surface the user sees' }, endpoint: { h: 12, label: 'entry point' }, agent: { h: 24, label: 'agent' },
42
+ model: { h: 15, label: 'model' }, process: { h: 19, label: 'process' }, gate: { h: 12, label: 'check or decision' },
43
+ queue: { h: 15, label: 'queue' }, store: { h: 22, label: 'data kept' }, external: { h: 12, label: 'outside service' },
44
+ config: { h: 10, label: 'configuration' },
45
+ };
46
+
47
+ /* ---------- the blocks and what is inside them (the flat version's layout, unchanged) ---------- */
48
+ const MOD = Object.fromEntries(AZ.modules.map(m => [m.id, m]));
49
+ const LAYER = Object.fromEntries(AZ.layers.map(l => [l.id, l]));
50
+ const PL = SCENE.place;
51
+ const LIST = AZ.layers.flatMap(l => AZ.modules.filter(m => m.layer === l.id && (PL[m.id] || m.id === 'dock')));
52
+ const PORT = Object.fromEntries(SCENE.ports.map(p => [p.n, p]));
53
+ const DOCKSETS = MOD.dock ? MOD.dock.parts.map(p => p.ports) : [];
54
+ const GROUP = Object.fromEntries(AZ.groups.map(g => [g.n, g]));
55
+ const portsOf = f => f.ports || portsIn(f.label);
56
+ const NODE = {};
57
+ function layoutKids(n, items, layout, zones, links, level) {
58
+ const L2 = level === 2, s = Math.min(n.w, n.d), hasZ = !!(zones && zones.length);
59
+ const wide = L2 && Math.max(0, ...items.map(it => (it.col || 0) + (it.cspan || 1))) >= 6;
60
+ const gx = L2 ? (wide ? 14 : 20) : Math.max(2.6, s * .08), gy = L2 ? (hasZ ? 26 : 20) : gx * .9;
61
+ const padX = L2 ? 12 : Math.max(3, s * .09), padT = L2 ? (hasZ ? 26 : 14) : Math.max(3.4, s * .11), padB = L2 ? 14 : Math.max(3, s * .09);
62
+ const iw = n.w - 2 * padX, id = n.d - padT - padB;
63
+ const room = (c, r, nItems) => Math.min((iw - (c - 1) * gx) / c / 1.5, (id - (r - 1) * gy) / r) - (c * r - nItems) * s * .01;
64
+ const cand = [];
65
+ if (items.every(it => Number.isInteger(it.col) && Number.isInteger(it.row))) {
66
+ const cells = items.map(it => [it.col, it.row, it.cspan || 1, it.rspan || 1]);
67
+ const C = Math.max(...cells.map(c => c[0] + c[2])), R = Math.max(...cells.map(c => c[1] + c[3]));
68
+ cand.push({ C, R, cells, score: room(C, R, items.length) + (L2 ? 1e6 : 0) });
69
+ if (!L2) cand.push({ C: R, R: C, cells: cells.map(([c, r, cs, rs]) => [r, c, rs, cs]), score: room(R, C, items.length) });
70
+ }
71
+ if (!L2 || !cand.length) for (let c = 1; c <= items.length; c++) {
72
+ const r = Math.ceil(items.length / c);
73
+ cand.push({ C: c, R: r, cells: items.map((_, i) => [i % c, Math.floor(i / c), 1, 1]), score: room(c, r, items.length) });
74
+ }
75
+ const best = cand.reduce((a, b) => b.score > a.score * 1.08 ? b : a);
76
+ const { C, R, cells } = best;
77
+ const cw = (iw - (C - 1) * gx) / C, ch = (id - (R - 1) * gy) / R;
78
+ const sc = L2 ? 1 : Math.max(.22, Math.min(1, s / 110));
79
+ items.forEach((it, i) => {
80
+ const [c, r, cs, rs] = cells[i], kind = KIND[it.kind] ? it.kind : 'process';
81
+ const k = { id: `${n.id}/${it.id || i}`, level, item: it, idx: i, parent: n, name: it.name, tag: it.tag || it.name, tip: it.tip, what: it.what, kind,
82
+ x: n.x + padX + c * (cw + gx), y: n.y + padT + r * (ch + gy), w: cs * cw + (cs - 1) * gx, d: rs * ch + (rs - 1) * gy, kids: [], links: [], zones: [] };
83
+ if (kind === 'model' && k.d > k.w * .8) { const nd = k.w * .8; k.y += (k.d - nd) / 2; k.d = nd; }
84
+ k.h = (KIND[kind].h + (it.children && it.children.length ? 5 : 0)) * sc;
85
+ NODE[k.id] = k; n.kids.push(k);
86
+ });
87
+ const byId = Object.fromEntries(n.kids.map(k => [k.item.id, k]));
88
+ n.links = (links || []).map(l => ({ a: byId[l.from], b: byId[l.to], label: l.label, status: l.status })).filter(l => l.a && l.b && l.a !== l.b);
89
+ n.zones = (zones || []).map((z, zi) => {
90
+ const ks = (z.parts || []).map(p => byId[p]).filter(Boolean); if (!ks.length) return null;
91
+ const x0 = Math.min(...ks.map(k => k.x)), y0 = Math.min(...ks.map(k => k.y)), x1 = Math.max(...ks.map(k => k.x + k.w)), y1 = Math.max(...ks.map(k => k.y + k.d));
92
+ return { id: `${n.id}#z${zi}`, kind: 'zone', parent: n, name: z.name, tip: z.tip, x: x0 - 7, y: y0 - 15, w: x1 - x0 + 14, d: y1 - y0 + 22 };
93
+ }).filter(Boolean);
94
+ for (const z of n.zones) NODE[z.id] = z;
95
+ if (L2) for (const k of n.kids) if (k.item.children && k.item.children.length) layoutKids(k, k.item.children, null, null, k.item.links, 3);
96
+ }
97
+ for (const m of LIST) {
98
+ const n = NODE[m.id] = { id: m.id, level: 1, m, name: m.name, tag: m.short, tip: m.tip, what: m.role, kind: m.id === 'dock' ? 'rail' : 'block', parent: null, kids: [], links: [], zones: [] };
99
+ if (!PL[m.id]) continue;
100
+ [n.x, n.y, n.w, n.d, n.h] = PL[m.id];
101
+ layoutKids(n, m.parts, m.layout, m.zones, m.links, 2);
102
+ }
103
+ const openable = n => !!n && n.kind !== 'zone' && (n.level === 1 || (n.level === 2 && n.kids.length > 0));
104
+ const rootOf = n => { while (n.parent) n = n.parent; return n; };
105
+ const stagger = (n, i) => { const k = Math.min(.06, .3 / n.kids.length); return [.34 + i * k, .64 + i * k]; };
106
+ const liftOf = n => n.level === 1 ? 170 : Math.max(16, Math.min(n.w, n.d) * 1.1);
107
+ const PX = SCENE.px, PY = SCENE.py, R = SCENE.rail, ZM = Math.max(...Object.values(PL).map(p => p[4]));
108
+
109
+ /* ---------- the cables between parts, as arcs from roof edge to roof edge ---------- */
110
+ const ends = (a, b, ha, hb) => {
111
+ const g = [b.x - a.x - a.w, a.x - b.x - b.w, b.y - a.y - a.d, a.y - b.y - b.d], k = g.indexOf(Math.max(...g));
112
+ const ov = (a0, a1, b0, b1) => { const lo = Math.max(a0, b0), hi = Math.min(a1, b1); return lo < hi ? (lo + hi) / 2 : null; };
113
+ if (k < 2) { const yc = ov(a.y, a.y + a.d, b.y, b.y + b.d), ya = yc ?? a.y + a.d / 2, yb = yc ?? b.y + b.d / 2;
114
+ return k === 0 ? [[a.x + a.w, ya, ha], [b.x, yb, hb]] : [[a.x, ya, ha], [b.x + b.w, yb, hb]]; }
115
+ const xc = ov(a.x, a.x + a.w, b.x, b.x + b.w), xa = xc ?? a.x + a.w / 2, xb = xc ?? b.x + b.w / 2;
116
+ return k === 2 ? [[xa, a.y + a.d, ha], [xb, b.y, hb]] : [[xa, a.y, ha], [xb, b.y + b.d, hb]];
117
+ };
118
+ const curve = (A, B, lift0, k) => {
119
+ const dist = Math.hypot(B[0] - A[0], B[1] - A[1]), c = [(A[0] + B[0]) / 2, (A[1] + B[1]) / 2, Math.max(A[2], B[2]) + lift0 + dist * k], pts = [];
120
+ for (let i = 0; i <= 24; i++) { const t = i / 24, u = 1 - t; pts.push([0, 1, 2].map(j => u * u * A[j] + 2 * u * t * c[j] + t * t * B[j])); }
121
+ return pts;
122
+ };
123
+
124
+ /* ---------- 3D: per-element materials, so anything can dim, fade away or light up on its own ---------- */
125
+ const g0 = new THREE.Group(); g0.visible = false; scene.add(g0);
126
+ const OUT_FS = 'uniform vec3 color; uniform float opacity; void main() { gl_FragColor = vec4(color, opacity); }';
127
+ const INKHI = new THREE.Color(), DETAIL = new THREE.Color(), LINK0 = new THREE.Color(), GLASSON = new THREE.Color(), TMPC = new THREE.Color();
128
+ let PALC = null; // canvas colours for the current theme and accent
129
+ function palette() {
130
+ const C = pal3(), light = theme === 'light', hi = TMPC.copy(accent).lerp(new THREE.Color(light ? 0x000000 : 0xffffff), light ? .3 : .42);
131
+ PALC = { ...C, acc: '#' + accent.getHexString(), accHi: '#' + hi.getHexString(), light };
132
+ INKHI.set(C.inkHi); DETAIL.set(light ? '#c3c6c8' : '#34373a'); GLASSON.set(C.glassOn);
133
+ }
134
+ palette();
135
+ const setOp = (m, o) => { m.opacity = o; const t = o < .999; if (m.transparent !== t) m.transparent = t; };
136
+ function lmat(o = {}) {
137
+ const m = new LineMaterial({ color: 0x777777, linewidth: 1, resolution: devRes(), polygonOffset: true, polygonOffsetFactor: -2, polygonOffsetUnits: -6, ...o });
138
+ m.uniforms.resolution = lineMat.uniforms.resolution; // follows the shell's resize
139
+ return m;
140
+ }
141
+ function mats() {
142
+ const out = new THREE.ShaderMaterial({ uniforms: { color: { value: new THREE.Color() }, thickness: outlineMat.uniforms.thickness, res: outlineMat.uniforms.res, opacity: { value: 1 } },
143
+ vertexShader: outlineMat.vertexShader, fragmentShader: OUT_FS, side: THREE.BackSide, polygonOffset: true, polygonOffsetFactor: 6, polygonOffsetUnits: 24 });
144
+ return { body: bodyMat.clone(), out, line: lmat(), major: lmat(), labs: [] };
145
+ }
146
+ // vis: 1 normal → 0 gone into the background; hot: 0..1 toward the accent; hov: 0..1 brighter; op: lines and print; opB: the solid
147
+ function paint(M, vis, hot, hov, op, opB) {
148
+ const mix = (dst, base) => { dst.copy(base); if (hov > .01) dst.lerp(INKHI, hov * .5); if (hot > .01) dst.lerp(accent, hot); if (vis < .999) dst.lerp(BG_COL, 1 - vis); };
149
+ mix(M.line.color, lineMat.color); mix(M.major.color, majorMat.color); mix(M.out.uniforms.color.value, outlineMat.uniforms.color.value);
150
+ M.line.linewidth = M.major.linewidth = lineMat.linewidth * (1 + .5 * hot);
151
+ M.body.color.copy(bodyMat.color); if (vis < .999) M.body.color.lerp(BG_COL, (1 - vis) * .6);
152
+ setOp(M.body, opB); setOp(M.line, op); setOp(M.major, op);
153
+ M.out.uniforms.opacity.value = op; if (M.out.transparent !== op < .999) M.out.transparent = op < .999;
154
+ const lo = op * (.1 + .9 * vis); for (const m of M.labs) m.opacity = lo;
155
+ }
156
+ const lineCol = (m, base, hot, vis) => { m.color.copy(base); if (hot > .01) m.color.lerp(accent, hot); if (vis < .999) m.color.lerp(BG_COL, 1 - vis); };
157
+ const noRay = o => { o.raycast = () => {}; return o; };
158
+ // a box rounded in plan, with its own silhouette and crease hairlines (the shell's rbox, on this element's materials)
159
+ function solid(x, y, z, w, d, h, r, M, parent, ud) {
160
+ r = Math.max(.01, Math.min(r, w / 2 - .01, d / 2 - .01));
161
+ const sh = new THREE.Shape(); roundRect(sh, x, -(y + d), w, d, r);
162
+ const g = new THREE.ExtrudeGeometry(sh, { depth: h, bevelEnabled: false, curveSegments: r > 3 ? 10 : 4 });
163
+ g.rotateX(-Math.PI / 2); g.translate(0, z, 0); g.scale(TS, TS, TS);
164
+ const m = new THREE.Mesh(g, M.body); m.userData = ud || {};
165
+ m.add(noRay(new THREE.Mesh(g, M.out)));
166
+ for (const [thr, mat] of [[30, M.line], [60, M.major]]) { const a = creaseLines(g, thr); if (a.length) { const l = noRay(fat(a, mat)); if (mat === M.major) l.renderOrder = 1; m.add(l); } }
167
+ parent.add(m); return m;
168
+ }
169
+ const loopAt = (x, y, w, d, r, z, mat, parent, n = 8) => { const sh = new THREE.Shape(); roundRect(sh, x, -(y + d), w, d, Math.max(.01, Math.min(r, w / 2 - .01, d / 2 - .01)));
170
+ const l = noRay(loopL(sh.getPoints(n).map(p => W(p.x, -p.y, z)), mat)); parent.add(l); return l; };
171
+
172
+ /* ---------- print: canvases lying on faces, drawn in the flat version's units ---------- */
173
+ const TXT = [];
174
+ const font = (x, f, s, wt = 400) => { x.font = `${wt} ${(f * s).toFixed(2)}px ${MONO3}`; };
175
+ function tplane(w, h, P, draw, parent, M, node) {
176
+ const s = Math.max(.6, Math.min(P, 1024 / w, 1024 / h)), cw = Math.max(2, Math.ceil(w * s)), ch = Math.max(2, Math.ceil(h * s));
177
+ const c = document.createElement('canvas'); c.width = cw; c.height = ch;
178
+ const tex = new THREE.CanvasTexture(c); tex.colorSpace = THREE.SRGBColorSpace; tex.anisotropy = 8;
179
+ const mat = new THREE.MeshBasicMaterial({ map: tex, transparent: true, depthWrite: false, toneMapped: false, polygonOffset: true, polygonOffsetFactor: -4, polygonOffsetUnits: -12 });
180
+ const mesh = noRay(new THREE.Mesh(new THREE.PlaneGeometry(w * TS, h * TS), mat)); mesh.renderOrder = 3;
181
+ const t = { c, x: c.getContext('2d'), tex, s, w, h, mesh, mat, fonts: [], node, gone: false };
182
+ t.redraw = () => { if (c.width !== cw || c.height !== ch) { c.width = cw; c.height = ch; } const x = t.x; x.setTransform(1, 0, 0, 1, 0, 0); x.clearRect(0, 0, cw, ch); t.fonts = []; draw(x, s, PALC, t); t.gone = false; tex.needsUpdate = true; };
183
+ tex.onUpdate = () => { c.width = c.height = 1; }; // once on the GPU the canvas is not needed until the next redraw
184
+ mesh.onBeforeRender = () => { if (t.gone) t.redraw(); };
185
+ mesh.userData.tp = t;
186
+ t.redraw(); TXT.push(t); parent.add(mesh); if (M) M.labs.push(mat); return t;
187
+ }
188
+ // a box that closed: its print leaves the GPU and is drawn again the next time it is on screen
189
+ const release = g => g.traverse(o => { const t = o.userData && o.userData.tp; if (t && !t.gone) { t.tex.dispose(); t.c.width = t.c.height = 1; t.gone = true; } });
190
+ const roofAt = (t, x, y, z) => { t.mesh.rotation.x = -Math.PI / 2; t.mesh.position.copy(W(x + t.w / 2, y + t.h / 2, z + .14)); t.face = 'top'; return t; };
191
+ const frontAt = (t, x, y, ztop) => { t.mesh.position.copy(W(x + t.w / 2, y + .14, ztop - t.h / 2)); t.face = 'front'; return t; };
192
+ function lines(x, s, F, X, Y, color, maxW, t, wt) {
193
+ font(x, F.f, s, wt); x.fillStyle = color; x.textAlign = 'left'; x.textBaseline = 'alphabetic';
194
+ F.L.forEach((ln, i) => x.fillText(ln, X * s, (Y + F.f * (.82 + i * 1.18)) * s, maxW * s)); if (t) t.fonts.push(F.f);
195
+ }
196
+ const rr = (x, s, X, Y, w, h, r) => { x.beginPath(); x.roundRect(X * s, Y * s, Math.max(0, w) * s, Math.max(0, h) * s, Math.max(0, Math.min(r, w / 2, h / 2)) * s); };
197
+ // device pixels per unit once the element around n is open and framed (sets how sharp its print is drawn)
198
+ const pxIn = host => Math.max(2, Math.min(16, 2 * Math.min(1700, innerWidth) * .6 / Math.max(host.w, host.d * 1.6)));
199
+
200
+ /* ---------- the plate, its districts and the dock (static) ---------- */
201
+ const PICK = [], VIS = {};
202
+ const first0 = twMeshes.length;
203
+ const plate = rbox(-34, -34, -16, PX + 68, PY + 68, 16, 26, bodyMat, g0); plate.userData = { plate: true }; PICK.push(plate);
204
+ loopAt(-22, -22, PX + 44, PY + 44, 18, .06, lineMat, g0, 12);
205
+ for (const [a, b] of [[-11, -11], [PX + 11, -11], [-11, PY + 11], [PX + 11, PY + 11]]) {
206
+ const sc = noRay(new THREE.Mesh(new THREE.CircleGeometry(5 * TS, 24), recessMat)); sc.rotation.x = -Math.PI / 2; sc.position.copy(W(a, b, .05)); g0.add(sc);
207
+ seg([[[a - 3, b, .2], [a + 3, b, .2]]], g0);
208
+ }
209
+ const DIST = [];
210
+ for (const dd of SCENE.districts) {
211
+ inset(dd.x, dd.y, 0, dd.w, dd.d, 16, g0);
212
+ const name = (SCENE.labels[dd.layer] || LAYER[dd.layer].name).toUpperCase(), f = Math.min(26, (dd.w - 70) / (name.length * (CW + .14)));
213
+ roofAt(tplane(name.length * f * (CW + .14) + 6, f * 1.4, 4, (x, s, C, t) => { font(x, f, s); x.letterSpacing = `${(f * .14 * s).toFixed(1)}px`; x.fillStyle = C.ink; x.fillText(name, 0, f * 1.05 * s); x.letterSpacing = '0px'; t.fonts.push(f); }, g0), dd.x + 20, dd.y + 12, 0);
214
+ DIST.push({ id: 'L:' + dd.layer, layer: dd.layer, at: [dd.x + 20 + f * name.length * (CW + .14) + 18, dd.y + 14 + f * .5, .3] });
215
+ }
216
+ const RT = SCENE.railTxt || '', RTW = 16 * RT.length * (CW + .14) + 8;
217
+ if (R) {
218
+ const rail = rbox(R.x, R.y, 0, R.w, R.d, 5, 10, bodyMat, g0); rail.userData = { rail: true }; PICK.push(rail);
219
+ roofAt(tplane(RTW, 22, 5, (x, s, C, t) => { font(x, 16, s); x.letterSpacing = `${(16 * .14 * s).toFixed(1)}px`; x.fillStyle = C.ink; x.fillText(RT, 0, 17 * s); x.letterSpacing = '0px'; t.fonts.push(16); }, g0), R.x + 20, R.y + R.d - 28, 5);
220
+ }
221
+ creases(first0);
222
+ const PORTV = {};
223
+ for (const pt of SCENE.ports) {
224
+ const M = mats(), P = PORTV[pt.n] = { M, hot: 0, dim: 0, hd: false };
225
+ const b = solid(pt.x - 14, R.y + 6, 5, 28, 32, 6, 3, M, g0, { port: pt.n }); PICK.push(b);
226
+ P.ledMat = new THREE.MeshBasicMaterial({ color: DETAIL.clone(), toneMapped: false, transparent: true, polygonOffset: true, polygonOffsetFactor: -4, polygonOffsetUnits: -12 });
227
+ const led = noRay(new THREE.Mesh(new THREE.PlaneGeometry(18 * TS, 3.5 * TS), P.ledMat)); led.rotation.x = -Math.PI / 2; led.position.copy(W(pt.x, R.y + 10.75, 11.16)); g0.add(led);
228
+ P.lab = roofAt(tplane(28, 22, 7, (x, s, C, t) => { font(x, 14, s, 500); x.textAlign = 'center'; x.fillStyle = P.hd ? C.accHi : C.ink; x.fillText(String(pt.n), 14 * s, 17 * s); t.fonts.push(14); }, g0, M), pt.x - 14, R.y + 14, 11);
229
+ P.glow = glowPoints([W(pt.x, R.y + 10.75, 11.6)], .32); g0.add(P.glow);
230
+ }
231
+
232
+ /* ---------- level 1: the blocks ---------- */
233
+ function buildBlock(n) {
234
+ const v = VIS[n.id] = { n, root: new THREE.Group(), cover: new THREE.Group(), M: mats(), inner: null, dim: 0, hot: 0, hov: 0, halo: 0, q: 1, hotDrawn: false };
235
+ g0.add(v.root); v.root.add(v.cover);
236
+ const { x, y, w, d, h } = n;
237
+ PICK.push(solid(x, y, 0, w, d, h, 9, v.M, v.cover, { node: n, cover: true }));
238
+ // the roof: a tint when lit, the name, one led per part
239
+ v.tintMat = new THREE.MeshBasicMaterial({ color: GLASSON.clone(), toneMapped: false, transparent: true, opacity: 0, depthWrite: false, polygonOffset: true, polygonOffsetFactor: -2, polygonOffsetUnits: -4 });
240
+ { const sh = new THREE.Shape(); roundRect(sh, x + 1, -(y + d - 1), w - 2, d - 2, 8); const tg = new THREE.ShapeGeometry(sh, 8); tg.rotateX(-Math.PI / 2); tg.translate(0, h + .08, 0); tg.scale(TS, TS, TS);
241
+ const tm = noRay(new THREE.Mesh(tg, v.tintMat)); tm.renderOrder = 2; v.cover.add(tm); }
242
+ const nm = fit(n.m.short, w - 28, d - 44, 46, 2, 8);
243
+ if (nm) v.name = roofAt(tplane(w - 24, nm.L.length * nm.f * 1.18 + 5, 4, (cx, s, C, t) => lines(cx, s, nm, 2, 2, v.hotDrawn ? C.accHi : C.inkHi, w - 28, t, 500), v.cover, v.M, n), x + 12, y + 10, h);
244
+ v.ledMat = new THREE.MeshBasicMaterial({ color: DETAIL.clone(), toneMapped: false, transparent: true, polygonOffset: true, polygonOffsetFactor: -4, polygonOffsetUnits: -12 });
245
+ v.M.labs.push(v.ledMat);
246
+ const ledPts = [], quad = [];
247
+ n.m.parts.forEach((_, i) => { const lx = x + 14 + i * 10 + 3, ly = y + d - 15;
248
+ for (const [a, b] of [[-3, -3], [-3, 3], [3, 3], [-3, -3], [3, 3], [3, -3]]) quad.push(...W(lx + a, ly + b, h + .16).toArray());
249
+ ledPts.push(W(lx, ly, h + .6)); });
250
+ { const lg = new THREE.BufferGeometry(); lg.setAttribute('position', new THREE.Float32BufferAttribute(quad, 3)); v.cover.add(noRay(new THREE.Mesh(lg, v.ledMat))); }
251
+ v.ledGlow = glowPoints(ledPts, .16); v.cover.add(v.ledGlow);
252
+ // the front: a small display with the count
253
+ const cf = fit(n.m.disp || `${n.m.parts.length} parts`, w - 36, 12, 10, 1, 5);
254
+ v.disp = frontAt(tplane(w - 24, 16, 4, (cx, s, C, t) => {
255
+ const on = v.hotDrawn; cx.fillStyle = on ? C.glassOn : C.glassOff; cx.strokeStyle = on ? C.acc : C.detail; cx.lineWidth = Math.max(1, .5 * s);
256
+ rr(cx, s, .5, .5, w - 25, 15, 3); cx.fill(); cx.stroke();
257
+ if (cf) { font(cx, cf.f, s); cx.fillStyle = on ? C.accHi : C.ink; cx.textBaseline = 'middle'; cx.fillText(cf.L[0], 6 * s, 8.3 * s, (w - 36) * s); t.fonts.push(cf.f); }
258
+ }, v.cover, v.M, n), x + 12, y + d, h - 10);
259
+ // the side: vents
260
+ const sl = []; for (let u = 16; u <= Math.min(d - 16, 72) + 1e-6; u += 8) sl.push([[x + w + .08, y + d - u, h - 10], [x + w + .08, y + d - u, 8]]);
261
+ seg(sl, v.cover, v.M.line);
262
+ // a pool of light on the plate around it, for the dark theme
263
+ v.haloMat = softMat(0xffffff, true); v.haloMat.uniforms.color.value.copy(accent);
264
+ const hm = noRay(new THREE.Mesh(new THREE.PlaneGeometry((w + 90) * TS, (d + 90) * TS), v.haloMat)); hm.rotation.x = -Math.PI / 2; hm.position.copy(W(x + w / 2, y + d / 2, .5)); v.root.add(hm);
265
+ }
266
+ for (const m of LIST) if (PL[m.id]) buildBlock(NODE[m.id]);
267
+
268
+ /* ---------- levels 2 and 3: parts and the pieces inside them, built the first time they are needed ---------- */
269
+ function buildPart(n) {
270
+ const pv = VIS[n.parent.id];
271
+ const v = VIS[n.id] = { n, root: new THREE.Group(), cover: new THREE.Group(), M: mats(), inner: null, dim: 0, hot: 0, q: 0, hotDrawn: false };
272
+ pv.inner.add(v.root); v.root.add(v.cover); v.root.visible = false;
273
+ const { x, y, w, d, kind } = n, sm = Math.min(w, d), hh = n.h, L3 = n.level === 3, P = pxIn(n.parent);
274
+ const r = kind === 'model' ? sm / 2 : kind === 'store' ? sm * .3 : Math.min(4, sm * .07);
275
+ PICK.push(solid(x, y, 0, w, d, hh, r, v.M, v.cover, { node: n, cover: true }));
276
+ if (kind === 'store' && hh > sm * .18) for (const k of [1, 2]) loopAt(x, y, w, d, r, hh * k / 3, v.M.line, v.cover, 6);
277
+ // roof: the name, a stack mark when there is another level inside, the led of a model
278
+ const deeper = openable(n) && n.level === 2;
279
+ const pad = Math.max(2.2, sm * .085), side = kind === 'model' ? r * .62 : pad, mark = deeper ? Math.max(5, sm * .16) : 0;
280
+ const lab = fit(n.tag, w - 2 * side - (deeper ? mark * .9 : 0), d - 2 * pad, 12, 4, L3 ? .6 : 3.2, L3 ? .1 : .5);
281
+ v.roof = roofAt(tplane(w, d, P, (cx, s, C, t) => {
282
+ const on = v.hotDrawn;
283
+ if (on) { cx.fillStyle = C.glassOn; rr(cx, s, .5, .5, w - 1, d - 1, r - .5); cx.fill(); }
284
+ if (kind === 'external') { cx.setLineDash([2.4 * s, 1.8 * s]); cx.strokeStyle = on ? C.acc : C.line; cx.lineWidth = Math.max(1, .3 * s); rr(cx, s, 1.6, 1.6, w - 3.2, d - 3.2, r - 1.2); cx.stroke(); cx.setLineDash([]); }
285
+ if (lab) lines(cx, s, lab, side, kind === 'model' ? (d - lab.L.length * lab.f * 1.18) / 2 : pad * .8, on ? C.accHi : C.inkHi, w - 2 * side, t, 500);
286
+ if (deeper) { const m = mark, mx = w - pad - m, my = d - pad - m; cx.strokeStyle = on ? C.acc : C.ink; cx.lineWidth = Math.max(1, .045 * m * s);
287
+ rr(cx, s, mx + m * .28, my, m * .72, m * .72, m * .1); cx.stroke(); rr(cx, s, mx, my + m * .28, m * .72, m * .72, m * .1); cx.fillStyle = on ? C.glassOn : PALC.light ? '#f1f1f0' : '#1b1c1f'; cx.fill(); cx.stroke(); }
288
+ if (kind === 'model') { cx.fillStyle = on ? C.acc : C.detail; cx.beginPath(); cx.arc((w - r * .55) * s, d / 2 * s, Math.max(.6, sm * .07) * s, 0, 7); cx.fill(); }
289
+ }, v.cover, v.M, n), x, y, hh);
290
+ v.lab = lab;
291
+ // front: the number, and a mark for what kind of thing it is
292
+ if (kind !== 'model' && hh > sm * .12) {
293
+ const fl = r + Math.max(1.5, sm * .05), fw = w - 2 * fl;
294
+ v.front = frontAt(tplane(fw, hh, P, (cx, s, C, t) => {
295
+ const on = v.hotDrawn, u = Math.max(1, sm * .05), fs = Math.min(6.5, hh * .42, sm * .14);
296
+ font(cx, fs, s); cx.fillStyle = on ? C.accHi : C.ink; cx.fillText(num(n.idx), 0, Math.min(hh - u * .5, fs * 1.15) * s); t.fonts.push(fs);
297
+ cx.strokeStyle = on ? C.acc : C.line; cx.lineWidth = Math.max(1, .25 * s); cx.fillStyle = on ? C.acc : C.detail;
298
+ if (kind === 'ui' && hh > sm * .2) { cx.fillStyle = on ? C.glassOn : C.glassOff; rr(cx, s, fw * .3, hh * .22, fw * .66, hh * .5, u * .6); cx.fill(); cx.stroke(); }
299
+ if (kind === 'agent') for (const k of [.82, .95]) { cx.beginPath(); cx.arc(fw * k * s, hh * .4 * s, u * .9 * s, 0, 7); cx.fill(); }
300
+ if (kind === 'endpoint') { cx.fillStyle = C.recess; rr(cx, s, fw * .6, hh * .3, fw * .3, hh * .4, u * .5); cx.fill(); cx.stroke(); }
301
+ if (kind === 'gate') for (let j = 0; j < 3; j++) { const gx = fw * (.55 + j * .13); cx.beginPath(); cx.moveTo(gx * s, hh * .25 * s); cx.lineTo((gx + fw * .06) * s, hh * .5 * s); cx.lineTo(gx * s, hh * .75 * s); cx.stroke(); }
302
+ if (kind === 'queue') { cx.beginPath(); for (let k = fw * .5; k <= fw * .95; k += Math.max(1, fw * .07)) { cx.moveTo(k * s, hh * .25 * s); cx.lineTo(k * s, hh * .78 * s); } cx.stroke(); }
303
+ }, v.cover, v.M, n), x + fl, y + d, hh);
304
+ const sl = [];
305
+ if (kind === 'process' && hh > sm * .2) for (let u = d * .2; u <= d * .8 + 1e-6; u += Math.max(1.2, d * .09)) sl.push([[x + w + .08, y + d - u, hh * .78], [x + w + .08, y + d - u, hh * .2]]);
306
+ if (kind === 'config' && hh > sm * .2) for (let z = hh * .25; z <= hh * .8 + 1e-6; z += Math.max(1, hh * .18)) sl.push([[x + w + .08, y + d * .8, hh - z], [x + w + .08, y + d * .2, hh - z]]);
307
+ if (sl.length) seg(sl, v.cover, v.M.line);
308
+ }
309
+ }
310
+ function buildInner(n) {
311
+ const v = VIS[n.id], L1 = n.level === 1, sm = Math.min(n.w, n.d);
312
+ const G = v.inner = new THREE.Group(); G.visible = false; v.root.add(G);
313
+ v.IM = { line: lmat(), zones: [], links: [] };
314
+ const zf = L1 ? .2 : .45;
315
+ v.floorMat = recessMat.clone();
316
+ { const sh = new THREE.Shape(); roundRect(sh, n.x, -(n.y + n.d), n.w, n.d, L1 ? 9 : Math.max(.01, Math.min(4, sm * .07)));
317
+ const fg = new THREE.ShapeGeometry(sh, 8); fg.rotateX(-Math.PI / 2); fg.translate(0, zf, 0); fg.scale(TS, TS, TS);
318
+ const floor = new THREE.Mesh(fg, v.floorMat); floor.userData = { node: n, floor: true }; G.add(floor); PICK.push(floor); }
319
+ loopAt(n.x, n.y, n.w, n.d, L1 ? 9 : Math.min(4, sm * .07), zf + .06, v.IM.line, G);
320
+ for (const z of n.zones) {
321
+ const Z = { z, hot: 0, dim: 0, hd: false, mat: lmat({ dashed: true, dashSize: 5 * TS, gapSize: 4 * TS }) };
322
+ loopAt(z.x, z.y, z.w, z.d, 7, .32, Z.mat, G, 6).computeLineDistances();
323
+ const f = Math.min(9, z.w / (z.name.length * (CW + .12) + 2)), nm = z.name.toUpperCase();
324
+ Z.lab = roofAt(tplane(Math.min(z.w - 10, nm.length * f * (CW + .12) + 4), f * 1.5, pxIn(n), (cx, s, C, t) => {
325
+ font(cx, f, s); cx.letterSpacing = `${(f * .12 * s).toFixed(2)}px`; cx.fillStyle = Z.hd ? C.accHi : C.ink; cx.fillText(nm, 0, f * 1.1 * s); cx.letterSpacing = '0px'; t.fonts.push(f);
326
+ }, G, null, z), z.x + 9, z.y + 3, .3);
327
+ v.IM.zones.push(Z);
328
+ }
329
+ for (const k of n.kids) buildPart(k);
330
+ const seen = new Set();
331
+ for (const l of n.links) {
332
+ const key = [l.a.id, l.b.id].sort().join('|'), twin = seen.has(key); seen.add(key);
333
+ const [A, B] = ends(l.a, l.b, l.a.h, l.b.h), pts = curve(A, B, (L1 ? 10 : sm * .06) * (twin ? 2.2 : 1), L1 ? .16 : .14);
334
+ // a cable that is not exact (matched by name) is dashed
335
+ const exact = !l.status || l.status === 'exact';
336
+ const mat = lmat({ polygonOffsetFactor: -3, polygonOffsetUnits: -8, ...(exact ? {} : { dashed: true, dashSize: 4 * TS, gapSize: 3 * TS }) }), line = noRay(polyLine(pts, mat, G));
337
+ if (!exact) line.computeLineDistances();
338
+ const a = L1 ? 5 : Math.max(.5, sm * .04), e = W(...pts[24]), dir = e.clone().sub(W(...pts[21])).normalize();
339
+ const am = new THREE.MeshBasicMaterial({ color: 0xffffff, toneMapped: false, transparent: true });
340
+ const cone = noRay(new THREE.Mesh(new THREE.ConeGeometry(a * .7 * TS, a * 1.9 * TS, 14), am));
341
+ cone.position.copy(e).addScaledVector(dir, -a * .95 * TS); cone.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), dir); G.add(cone);
342
+ v.IM.links.push({ l, n, line, mat, cone, am, pts, cum: cumLen(pts), hot: 0, dim: 0, ph: (v.IM.links.length * .37) % 1, size: L1 ? .16 : .07, r: L1 ? 2.6 : Math.max(.45, sm * .022) });
343
+ }
344
+ }
345
+ function ensureInner(n) {
346
+ if (!n || n.id === 'dock') return;
347
+ if (n.level === 2) ensureInner(n.parent);
348
+ if (!VIS[n.id].inner) buildInner(n);
349
+ }
350
+
351
+ /* ---------- travelling lights: pulses on lit cables and journeys ---------- */
352
+ const beadMat = new THREE.MeshBasicMaterial({ color: accent.clone(), toneMapped: false });
353
+ const BEADS = [];
354
+ for (let i = 0; i < 28; i++) {
355
+ const m = noRay(new THREE.Mesh(new THREE.SphereGeometry(TS, 12, 8), beadMat)); m.visible = false; g0.add(m);
356
+ const gl = glowPoints([new THREE.Vector3()], .3); gl.visible = false; g0.add(gl); BEADS.push({ m, gl });
357
+ }
358
+ function beads(list) {
359
+ BEADS.forEach((b, i) => {
360
+ const w = list[i]; b.m.visible = b.gl.visible = !!w; if (!w) return;
361
+ const p = alongPts(w.pts, w.cum, w.t); b.m.position.copy(W(...p)); b.m.scale.setScalar(w.r);
362
+ b.gl.material.uniforms.size.value = w.size; moveGlow(b.gl, p, w.k);
363
+ });
364
+ }
365
+ const JB = []; // journey pulses in flight
366
+ const arcsG = new THREE.Group(); g0.add(arcsG);
367
+ const flowsG = new THREE.Group(); g0.add(flowsG);
368
+ const faintMat = lmat({ dashed: true, dashSize: 9 * TS, gapSize: 7 * TS });
369
+ const clearG = g => { for (const o of [...g.children]) { g.remove(o); o.geometry && o.geometry.dispose(); } };
370
+
371
+ /* ---------- journeys between blocks (as on the flat overview) ---------- */
372
+ const portPt = n => [PORT[n].x, R.y + 22, 12];
373
+ const anchorB = (s, label) => {
374
+ if (isPort(s)) return portPt(+s.slice(1));
375
+ if (s === 'dock') return portPt(portsIn(label)[0] || 1);
376
+ const [x, y, w, d, h] = PL[s]; return [x + w / 2, y + d / 2, h + 2];
377
+ };
378
+ const sideOf = s => isPort(s) || s === 'dock' ? 'dock' : MOD[s].layer === SCENE.front ? 'front' : 'back';
379
+ const via = (a, b) => {
380
+ const sa = sideOf(a), sb = sideOf(b); if (sa === 'dock' || sb === 'dock' || sa === sb) return null;
381
+ const f = sa === 'front' ? a : b, k = sa === 'front' ? `${a}|${b}` : `${b}|${a}`;
382
+ return SCENE.portmap[k] || SCENE.ports.find(p => p.mod === f).n;
383
+ };
384
+ const arc = (a, b) => {
385
+ const dist = Math.hypot(b[0] - a[0], b[1] - a[1]), t0 = Math.max(a[2], b[2]) + 40 + Math.min(260, dist * .26);
386
+ const c = [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2, t0], pts = [];
387
+ for (let i = 0; i <= 40; i++) { const t = i / 40, u = 1 - t; pts.push([0, 1, 2].map(k => u * u * a[k] + 2 * u * t * c[k] + t * t * b[k])); }
388
+ return pts;
389
+ };
390
+ const hop = (a, b, label) => {
391
+ const v = via(a, b);
392
+ if (v) return [{ pts: arc(anchorB(a), portPt(v)), port: v }, { pts: arc(portPt(v), anchorB(b)), port: v }];
393
+ return [{ pts: arc(anchorB(a, label), anchorB(b, label)), port: isPort(b) ? +b.slice(1) : isPort(a) ? +a.slice(1) : null }];
394
+ };
395
+ const flowArcs = f => (f.from === 'dock' || f.to === 'dock')
396
+ ? portsOf(f).map(n => arc(anchorB(f.from === 'dock' ? 'P' + n : f.from), anchorB(f.to === 'dock' ? 'P' + n : f.to)))
397
+ : hop(f.from, f.to).map(s => s.pts);
398
+
399
+ /* ---------- state, clock and timers ---------- */
400
+ const st = { path: [], sel: null, focus: null, hover: null, hoverPort: null, jour: null, touched: false, dockSel: 0, branch: !ARCH && /[?&]branch\b/.test(location.search) };
401
+ const prog = {};
402
+ for (const id in NODE) if (openable(NODE[id]) && NODE[id].kind !== 'rail') prog[id] = { p: 0, t: 0 };
403
+ const top = () => st.path[st.path.length - 1] || null;
404
+ let NOW = 0;
405
+ const TIMERS = [];
406
+ const after = (ms, fn) => { const t = { at: NOW + ms, fn }; TIMERS.push(t); return t; };
407
+ const cancel = t => { const i = TIMERS.indexOf(t); if (i >= 0) TIMERS.splice(i, 1); };
408
+ const runTimers = () => { for (const t of TIMERS.filter(t => t.at <= NOW)) { cancel(t); t.fn(); } };
409
+
410
+ /* ---------- camera: frame what is open in the free part of the window, keeping the user's angle ---------- */
411
+ const DIR_ISO = new THREE.Vector3(1, 1, 1).normalize(), UP = new THREE.Vector3(0, 1, 0);
412
+ const BOX0 = [-34, -34, -16, PX + 34, PY + 34, ZM];
413
+ const FREE = { w: innerWidth, h: innerHeight, px: 0, top: 0, bot: 0 };
414
+ let CUR = BOX0, CAMT = null;
415
+ function boxFor(id) {
416
+ if (!id) return BOX0;
417
+ if (id === 'dock') return [R.x, R.y, 0, R.x + R.w, R.y + R.d, 60];
418
+ const n = NODE[id], hs = Math.max(4, ...n.kids.map(k => k.h));
419
+ return [n.x, n.y, 0, n.x + n.w, n.y + n.d, hs * 1.4];
420
+ }
421
+ function fitFor(box, dir) {
422
+ const c = W((box[0] + box[3]) / 2, (box[1] + box[4]) / 2, (box[2] + box[5]) / 2);
423
+ const f = dir.clone().negate(), r = new THREE.Vector3().crossVectors(f, UP).normalize(), u = new THREE.Vector3().crossVectors(r, f);
424
+ const tv = Math.tan(THREE.MathUtils.degToRad(persp.fov / 2)), tvF = tv * FREE.h / innerHeight, thF = tv * FREE.w / innerHeight;
425
+ let D = .5;
426
+ for (const X of [box[0], box[3]]) for (const Y of [box[1], box[4]]) for (const Z of [box[2], box[5]]) {
427
+ const p = W(X, Y, Z).sub(c), px = p.dot(r), py = p.dot(u), pz = p.dot(f);
428
+ D = Math.max(D, Math.abs(px) / (thF * .92) - pz, Math.abs(py) / (tvF * .86) - pz);
429
+ }
430
+ return { c, D };
431
+ }
432
+ function layoutView() {
433
+ const wide = innerWidth >= 820, pw = wide ? Math.round(Math.min(400, Math.max(310, innerWidth * .27))) : 0;
434
+ ins.style.width = wide ? pw + 'px' : '';
435
+ FREE.px = wide ? pw + 28 : 0; FREE.top = wide ? 0 : 128; FREE.bot = wide ? 0 : Math.round(Math.min(innerHeight * .4, 340)) + 48; // narrow: top bar and buttons above, panel below
436
+ FREE.w = innerWidth - FREE.px; FREE.h = innerHeight - FREE.top - FREE.bot;
437
+ for (const c of [persp, ortho]) c.setViewOffset(innerWidth, innerHeight, -FREE.px / 2, (FREE.bot - FREE.top) / 2, innerWidth, innerHeight);
438
+ }
439
+ function frameBox(box, dur = 900, minEl = 26) {
440
+ CUR = box;
441
+ let dir = camera.position.clone().sub(controls.target);
442
+ dir = dir.lengthSq() > 1e-8 ? dir.normalize() : DIR_ISO.clone();
443
+ const el = Math.asin(THREE.MathUtils.clamp(dir.y, -1, 1)), lo = THREE.MathUtils.degToRad(minEl);
444
+ if (el < lo) { const hz = Math.hypot(dir.x, dir.z) || 1; dir.set(dir.x / hz * Math.cos(lo), Math.sin(lo), dir.z / hz * Math.cos(lo)); }
445
+ const { c, D } = fitFor(box, dir);
446
+ if (VIEWS.az) { VIEWS.az.target.copy(fitFor(box, DIR_ISO).c); VIEWS.az.dist = () => fitFor(CUR, DIR_ISO).D; }
447
+ const to = { t: c, off: dir.clone().multiplyScalar(D), zoom: camera === ortho ? orthoH / (D * fovK()) : null };
448
+ if (REDUCE || dur <= 1) { controls.target.copy(to.t); camera.position.copy(to.t).add(to.off); if (to.zoom) { ortho.zoom = to.zoom; ortho.updateProjectionMatrix(); } return; }
449
+ if (!CAMT) CAMT = { spin: controls.autoRotate };
450
+ Object.assign(CAMT, { t0: NOW, dur, fromT: controls.target.clone(), fromO: camera.position.clone().sub(controls.target), fromZ: camera === ortho ? ortho.zoom : null, to });
451
+ controls.autoRotate = false; controls.enableDamping = false;
452
+ }
453
+ function camStep() {
454
+ if (!CAMT) return;
455
+ if (camAnim) { controls.autoRotate = CAMT.spin; CAMT = null; return; } // the Iso view snap takes over
456
+ const k = ez(clamp((NOW - CAMT.t0) / CAMT.dur));
457
+ controls.target.lerpVectors(CAMT.fromT, CAMT.to.t, k);
458
+ camera.position.copy(controls.target).add(CAMT.fromO.clone().lerp(CAMT.to.off, k));
459
+ if (camera === ortho && CAMT.to.zoom && CAMT.fromZ) { ortho.zoom = THREE.MathUtils.lerp(CAMT.fromZ, CAMT.to.zoom, k); ortho.updateProjectionMatrix(); }
460
+ if (k >= 1) { controls.enableDamping = true; controls.autoRotate = CAMT.spin; CAMT = null; }
461
+ }
462
+ const cancelCam = () => { if (CAMT) { controls.enableDamping = true; controls.autoRotate = CAMT.spin; CAMT = null; } };
463
+
464
+ /* ---------- every frame: open, dim, light ---------- */
465
+ function update(dt) {
466
+ for (const id in prog) {
467
+ const pr = prog[id]; if (pr.p === pr.t) continue;
468
+ const lv = NODE[id].level;
469
+ pr.p = pr.t > pr.p ? Math.min(pr.t, pr.p + dt * 1000 / (lv === 1 ? 1500 : 1100)) : Math.max(pr.t, pr.p - dt * 1000 / 600);
470
+ }
471
+ const root = st.path[0], J = st.jour, steps = J ? AZ.journeys[J.k].steps : [], cur = J && J.i >= 0 ? steps[J.i] : null, done = J ? steps.slice(0, Math.max(0, J.i)) : [];
472
+ const kk = REDUCE ? 1 : Math.min(1, dt * 7);
473
+ for (const m of LIST) {
474
+ const v = VIS[m.id]; if (!v) continue;
475
+ const n = v.n, p = prog[n.id].p;
476
+ v.dim += ((root && root !== n.id ? 1 : 0) - v.dim) * kk;
477
+ v.hot += ((n.id === cur ? 1 : done.includes(n.id) ? .5 : !J && st.branch && !st.cmpWant && !root ? (BRM.has(n.id) ? 1 : BRHIT.has(n.id) ? .45 : 0) : 0) - v.hot) * kk;
478
+ v.hov += ((!root && !J && (st.hover === n.id || st.focus === n.id) ? 1 : 0) - v.hov) * kk;
479
+ v.halo += ((n.id === cur ? .36 : root === n.id ? .2 : 0) - v.halo) * kk;
480
+ const up = ez(segT(p, .04, .46)), ob = 1 - segT(p, .06, .3), ol = 1 - segT(p, .14, .46);
481
+ v.cover.visible = ol > .003;
482
+ v.cover.position.y = liftOf(n) * up * TS;
483
+ v.cover.scale.y = 1 - .62 * v.dim;
484
+ paint(v.M, 1 - .86 * v.dim, v.hot, v.hov, ol, ob);
485
+ const hd = v.hot > .75; if (hd !== v.hotDrawn) { v.hotDrawn = hd; if (v.name) v.name.redraw(); v.disp.redraw(); }
486
+ v.ledMat.color.copy(hd ? accent : DETAIL);
487
+ v.ledGlow.material.uniforms.intensity.value = hd ? .85 * ol : 0;
488
+ v.tintMat.color.copy(GLASSON); v.tintMat.opacity = clamp((v.hot - .5) * 2) * ob;
489
+ v.haloMat.uniforms.opacity.value = v.halo;
490
+ if (v.inner) { const vis = p > .002; if (!vis && v.inner.visible) release(v.inner); v.inner.visible = vis; if (vis) updateInner(n, p, kk); }
491
+ }
492
+ const lit = root === 'dock' ? new Set(DOCKSETS[st.dockSel]) : J ? J.ports : new Set([...(st.branch && !root ? BRDB : []), ...(st.hoverPort ? [st.hoverPort] : [])]);
493
+ for (const pt of SCENE.ports) {
494
+ const P = PORTV[pt.n];
495
+ P.hot += ((lit.has(pt.n) ? 1 : 0) - P.hot) * kk; P.dim += ((root && root !== 'dock' && !(J && lit.has(pt.n)) ? 1 : 0) - P.dim) * kk;
496
+ paint(P.M, 1 - .8 * P.dim, P.hot, 0, 1, 1);
497
+ const hd = P.hot > .5; if (hd !== P.hd) { P.hd = hd; P.lab.redraw(); }
498
+ P.ledMat.color.copy(hd ? accent : DETAIL); P.ledMat.opacity = 1 - .8 * P.dim;
499
+ P.glow.material.uniforms.intensity.value = hd ? .9 : 0;
500
+ }
501
+ // travelling lights
502
+ const want = [];
503
+ for (let i = JB.length - 1; i >= 0; i--) { const b = JB[i], t = (NOW - b.t0) / 1100; if (t > 1) { JB.splice(i, 1); continue; } if (t >= 0) want.push({ pts: b.pts, cum: b.cum, t, size: .5, r: 4.5, k: Math.sin(Math.PI * t) * .9 + .1 }); }
504
+ for (const L of HOTL) want.push({ pts: L.pts, cum: L.cum, t: (NOW / 1500 + L.ph) % 1, size: L.size, r: L.r, k: .8 });
505
+ beads(want);
506
+ }
507
+ let HOTL = [];
508
+ function updateInner(n, p, kk) {
509
+ const v = VIS[n.id], pi = st.path.indexOf(n.id), deeper = pi >= 0 ? st.path[pi + 1] || null : null;
510
+ const fo = segT(p, 0, .3);
511
+ v.floorMat.color.copy(recessMat.color); setOp(v.floorMat, fo);
512
+ lineCol(v.IM.line, lineMat.color, 0, 1); v.IM.line.linewidth = lineMat.linewidth; setOp(v.IM.line, fo);
513
+ const sel = st.sel && NODE[st.sel];
514
+ for (const Z of v.IM.zones) {
515
+ const z = Z.z, hot = !deeper && sel && sel.parent === n && sel.x >= z.x && sel.y >= z.y && sel.x + sel.w <= z.x + z.w && sel.y + sel.d <= z.y + z.d ? 1 : 0;
516
+ Z.hot += (hot - Z.hot) * kk; Z.dim += ((deeper ? 1 : 0) - Z.dim) * kk;
517
+ lineCol(Z.mat, majorMat.color, Z.hot, 1 - .8 * Z.dim); Z.mat.color.lerp(INKHI, .18 * (1 - Z.hot)); Z.mat.linewidth = lineMat.linewidth * (1 + .4 * Z.hot); setOp(Z.mat, fo);
518
+ const hd = Z.hot > .5; if (hd !== Z.hd) { Z.hd = hd; Z.lab.redraw(); } Z.lab.mat.opacity = fo * (1 - .8 * Z.dim);
519
+ }
520
+ for (const k of n.kids) {
521
+ const kv = VIS[k.id], [a, b] = stagger(n, k.idx), q = back(segT(p, a, b));
522
+ kv.q = q; kv.root.visible = q > .001; if (!kv.root.visible) continue;
523
+ const pk = prog[k.id] ? prog[k.id].p : 0;
524
+ kv.dim += ((deeper && deeper !== k.id ? 1 : 0) - kv.dim) * kk;
525
+ kv.hot += ((k.id === st.sel ? 1 : st.branch && isCh(k) ? .7 : 0) - kv.hot) * kk;
526
+ const up = ez(segT(pk, .04, .46)), ob = 1 - segT(pk, .06, .3), ol = 1 - segT(pk, .14, .46);
527
+ kv.cover.visible = ol > .003; kv.cover.scale.y = Math.max(.001, q); kv.cover.position.y = liftOf(k) * up * TS;
528
+ paint(kv.M, 1 - .86 * kv.dim, kv.hot, 0, ol, ob);
529
+ const hd = kv.hot > .5; if (hd !== kv.hotDrawn) { kv.hotDrawn = hd; kv.roof.redraw(); if (kv.front) kv.front.redraw(); }
530
+ if (kv.inner) { const vis = pk > .002; if (!vis && kv.inner.visible) release(kv.inner); kv.inner.visible = vis; if (vis) updateInner(k, pk, kk); }
531
+ }
532
+ for (const L of v.IM.links) {
533
+ const o = segT(Math.min(VIS[L.l.a.id].q, VIS[L.l.b.id].q), .7, 1);
534
+ const hot = !deeper && st.sel && (L.l.a.id === st.sel || L.l.b.id === st.sel) ? 1 : 0;
535
+ L.hot += (hot - L.hot) * kk; L.dim += ((deeper ? 1 : 0) - L.dim) * kk;
536
+ L.line.visible = L.cone.visible = o > .01;
537
+ lineCol(L.mat, LINK0, L.hot, 1 - .86 * L.dim); L.mat.linewidth = lineMat.linewidth * (1.15 + .55 * L.hot); setOp(L.mat, o);
538
+ L.am.color.copy(L.mat.color); L.am.opacity = o;
539
+ if (L.hot > .5 && o > .95) HOTL.push(L);
540
+ }
541
+ }
542
+
543
+ /* ---------- opening and closing, at any level ---------- */
544
+ function open(id, selId) {
545
+ const n = NODE[id]; if (!n) return;
546
+ stopJourney(true); J_OPENED = false; showLinks(null); hideTip(true);
547
+ if (n.level === 1) {
548
+ if (st.path[0] && st.path[0] !== id) { for (const o of st.path) if (prog[o]) prog[o].t = 0; }
549
+ else if (st.path[1]) prog[st.path[1]].t = 0;
550
+ st.path = [id];
551
+ } else {
552
+ if (st.path[0] !== n.parent.id) { for (const o of st.path) if (prog[o]) prog[o].t = 0; }
553
+ else if (st.path[1] && st.path[1] !== id) prog[st.path[1]].t = 0;
554
+ st.path = [n.parent.id, id];
555
+ if (prog[n.parent.id]) prog[n.parent.id].t = 1;
556
+ }
557
+ if (prog[id]) prog[id].t = 1;
558
+ ensureInner(n);
559
+ st.focus = rootOf(n).id;
560
+ st.sel = id === 'dock' ? null : selId || (n.kids[0] ? n.kids[0].id : null);
561
+ if (id === 'dock') st.dockSel = selId || 0;
562
+ if (REDUCE) jump();
563
+ frameBox(boxFor(id), n.level === 1 ? 1000 : 850, 46); refresh(); ins.scrollTop = 0;
564
+ }
565
+ function back1() {
566
+ const id = top(); if (!id) return;
567
+ if (prog[id]) prog[id].t = 0;
568
+ st.path.pop();
569
+ st.sel = st.path.length ? id : null;
570
+ if (REDUCE) jump();
571
+ frameBox(boxFor(top()), 850, top() ? 46 : 26); refresh();
572
+ }
573
+ const closeAll = () => { while (st.path.length) back1(); };
574
+ function jump() { for (const k in prog) prog[k].p = prog[k].t; }
575
+ function select(id) {
576
+ if (id === st.sel) return;
577
+ st.sel = id; say(); syncPins(); renderCode();
578
+ ins.querySelectorAll('.row[data-n]').forEach(r => r.classList.toggle('on', r.dataset.n === id));
579
+ const r = [...ins.querySelectorAll('.row[data-n]')].find(x => x.dataset.n === id && !x.dataset.link); if (r) r.scrollIntoView({ block: 'nearest', behavior: REDUCE ? 'auto' : 'smooth' });
580
+ }
581
+ const openPort = n => open('dock', Math.max(0, DOCKSETS.findIndex(s => s.includes(n))));
582
+ const kidsOfTop = () => { const t = top(); return t && t !== 'dock' ? NODE[t].kids : []; };
583
+ const refresh = () => { panel(); say(); syncPins(); if (!st.path.length && !st.jour && !st.hover && !st.hoverPort) showLinks(null); };
584
+
585
+ /* ---------- journeys ---------- */
586
+ function playJourney(k) {
587
+ if (!AZ.journeys[k]) return;
588
+ const wasOpen = st.path.length > 0;
589
+ stopJourney(true); J_OPENED = false; closeAll(); showLinks(null); hideTip(true);
590
+ st.jour = { k, i: -1, t: null, ports: new Set() };
591
+ refresh();
592
+ st.jour.t = after(wasOpen ? 800 : 80, () => stepJourney(k));
593
+ frameBox(BOX0, 900);
594
+ }
595
+ const modAt = (steps, i, d) => { for (let j = i + d; j >= 0 && j < steps.length; j += d) if (!isPort(steps[j])) return steps[j]; return null; };
596
+ function jOpen(id) { // open a block for a walkthrough without stopping it
597
+ if (st.path.length === 1 && st.path[0] === id) return false;
598
+ for (const o of st.path) if (prog[o]) prog[o].t = 0;
599
+ st.path = [id]; prog[id].t = 1; ensureInner(NODE[id]); st.focus = id; J_OPENED = true;
600
+ if (REDUCE) jump();
601
+ frameBox(boxFor(id), 1000, 46); return true;
602
+ }
603
+ function jClose(frame) { if (!st.path.length) return; for (const o of st.path) if (prog[o]) prog[o].t = 0; st.path = []; st.sel = null; if (frame) frameBox(BOX0, 1000); }
604
+ let J_OPENED = false;
605
+ function stepJourney(k) {
606
+ const J = st.jour; if (!J || J.k !== k) return;
607
+ const jj = AZ.journeys[k], steps = jj.steps; J.i++;
608
+ const s = steps[J.i], c = (jj.code || [])[J.i], mv = isPort(s) ? null : s;
609
+ const inside = mv && c && c.sel && NODE[c.sel] && (modAt(steps, J.i, -1) === mv || modAt(steps, J.i, 1) === mv);
610
+ let moved = false;
611
+ if (isPort(s)) J.ports.add(+s.slice(1));
612
+ else if (inside) { moved = jOpen(mv); st.sel = c.sel; }
613
+ else if (st.path.length) { jClose(true); moved = true; }
614
+ const prev = J.i > 0 ? steps[J.i - 1] : null;
615
+ if (prev && prev !== s && !(isPort(prev) && modAt(steps, J.i, -1) === s)) hop(isPort(s) ? prev : (isPort(prev) ? modAt(steps, J.i, -1) || prev : prev), s).forEach((h, j) => {
616
+ noRay(polyLine(h.pts, accentLineMat, arcsG));
617
+ JB.push({ pts: h.pts, cum: cumLen(h.pts), t0: NOW + (moved ? 500 : 0) + j * 320 });
618
+ if (h.port) J.ports.add(h.port);
619
+ });
620
+ refresh();
621
+ const on = ins.querySelector('.row.on'); if (on) on.scrollIntoView({ block: 'nearest', behavior: REDUCE ? 'auto' : 'smooth' });
622
+ J.t = J.i < steps.length - 1 ? after(moved ? 1900 : 1350, () => stepJourney(k)) : after(6000, () => { if (st.jour === J) { stopJourney(); } });
623
+ }
624
+ function stopJourney(quiet) {
625
+ const J = st.jour; if (!J) return;
626
+ cancel(J.t); st.jour = null; clearG(arcsG); JB.length = 0;
627
+ if (J_OPENED) { J_OPENED = false; if (!quiet) { jClose(true); } }
628
+ if (!quiet) refresh();
629
+ }
630
+ function showLinks(id, port) {
631
+ if (st.jour) return;
632
+ clearG(flowsG); if (st.path.length) return;
633
+ const fs = port ? AZ.flows.filter(f => (f.from === 'dock' || f.to === 'dock') && portsOf(f).includes(port))
634
+ : id ? AZ.flows.filter(f => f.from === id || f.to === id) : [];
635
+ if (!fs.length && st.branch) { // the branch view: dashed arcs from the blocks that call changed code
636
+ for (const [b, l] of Object.entries(CUR_C && !st.cmpWant ? CUR_C.blast : {})) for (const [a] of l.slice(0, 4)) if (PL[a] && PL[b]) noRay(polyLine(arc(anchorB(a), anchorB(b)), faintMat, flowsG)).computeLineDistances();
637
+ return;
638
+ }
639
+ const arcs = fs.flatMap(f => port ? [arc(anchorB(f.from === 'dock' ? 'P' + port : f.from), anchorB(f.to === 'dock' ? 'P' + port : f.to))] : flowArcs(f));
640
+ for (const pts of arcs) noRay(polyLine(pts, faintMat, flowsG)).computeLineDistances();
641
+ }
642
+
643
+ /* ---------- overlay: info buttons on every element in view, and the tooltip card ---------- */
644
+ const css = document.createElement('style');
645
+ css.textContent = `
646
+ :root { --hi:#eef1f6; --tipbg:rgba(19,20,22,.97); --slab:#0c0d0e; --pinbg:rgba(22,23,24,.94); }
647
+ :root[data-theme="light"] { --hi:#16181a; --tipbg:rgba(251,251,250,.98); --slab:#d9dbdb; --pinbg:rgba(250,250,249,.96); }
648
+ #info { display:none !important; }
649
+ #ctl-az { top:auto !important; bottom:58px; right:16px; width:auto !important; max-width:calc(100vw - 440px); flex-direction:row !important; flex-wrap:wrap; justify-content:flex-end; }
650
+ #ctl-az .sep { display:none; }
651
+ #ctl-az button { gap:10px; padding:6px 10px; }
652
+ @media (max-width:819px) { #ctl-az { top:84px !important; bottom:auto; left:12px; right:12px; max-width:none; justify-content:flex-start; } #ctl-az button kbd { display:none; } }
653
+ #az-ins { position:fixed; z-index:4; left:16px; top:64px; bottom:60px; width:340px; overflow:auto; display:grid; gap:18px; align-content:start;
654
+ padding:18px 14px 20px; background:var(--panel); border:1px solid var(--line); border-radius:16px; -webkit-backdrop-filter:blur(8px); backdrop-filter:blur(8px); scrollbar-width:thin; }
655
+ @media (max-width:819px) { #az-ins { top:auto; left:12px; right:12px; bottom:40px; width:auto !important; max-height:40vh; } #hint { display:none; } }
656
+ #az-ins .k { text-transform:uppercase; letter-spacing:.14em; font-size:10.5px; color:var(--dim); }
657
+ #az-ins .k b { color:var(--accent); font-weight:500; }
658
+ #az-ins .head { display:grid; gap:8px; padding:0 4px; }
659
+ #az-ins h2 { margin:0; font:500 19px/1.25 ui-monospace, "SF Mono", Menlo, Consolas, monospace; color:var(--hi); text-wrap:balance; }
660
+ #az-ins p { margin:0; font-size:12px; line-height:1.6; color:var(--ink); }
661
+ #az-ins p.lead { color:var(--hi); }
662
+ #az-ins section { display:grid; gap:2px; }
663
+ #az-ins section > .k { padding:0 10px 6px; }
664
+ #az-ins .chip { display:inline-block; width:fit-content; padding:3px 10px; border:1px solid var(--line); border-radius:999px; font-size:11px; letter-spacing:.06em; color:var(--hi); }
665
+ #az-ins .crumbs { display:flex; flex-wrap:wrap; align-items:center; gap:4px 6px; font-size:10.5px; letter-spacing:.08em; text-transform:uppercase; color:var(--dim); }
666
+ #az-ins .crumbs button { all:unset; cursor:pointer; color:var(--ink); border:1px solid var(--line); border-radius:8px; padding:4px 9px; }
667
+ #az-ins .crumbs button:hover { color:var(--hi); border-color:var(--dim); }
668
+ #az-ins .crumbs span { color:var(--hi); padding:4px 2px; }
669
+ #az-ins .row { all:unset; box-sizing:border-box; cursor:pointer; display:grid; grid-template-columns:24px minmax(0,1fr) auto; gap:3px 8px;
670
+ padding:8px 10px; border:1px solid transparent; border-radius:10px; transition:border-color .2s, background .2s; }
671
+ #az-ins .row:hover, #az-ins .row.on { border-color:var(--line); background:var(--bg); }
672
+ #az-ins .row .n { color:var(--dim); font-size:11px; line-height:18px; font-variant-numeric:tabular-nums; transition:color .2s; }
673
+ #az-ins .row.on .n { color:var(--accent); text-shadow:0 0 8px var(--accent); }
674
+ #az-ins .row .t { color:var(--hi); font-size:12.5px; line-height:18px; min-width:0; overflow-wrap:anywhere; }
675
+ #az-ins .row .c { color:var(--dim); font-size:10.5px; line-height:18px; white-space:nowrap; }
676
+ #az-ins .row .c.go { color:var(--accent); }
677
+ #az-ins .row .d { grid-column:2 / 4; font-size:11.5px; line-height:1.55; color:var(--ink); min-width:0; }
678
+ #az-ins .row:focus-visible, #az-ins .crumbs button:focus-visible, #az-ins .back:focus-visible { outline:1px solid var(--accent); outline-offset:1px; }
679
+ #az-ins .back { all:unset; cursor:pointer; color:var(--ink); border:1px solid var(--line); border-radius:10px; padding:6px 12px; width:fit-content; text-transform:uppercase; letter-spacing:.14em; font-size:10.5px; }
680
+ #az-ins .back:hover { color:var(--hi); border-color:var(--dim); }
681
+ #az-ins .keys { font-size:10.5px; line-height:1.9; color:var(--dim); letter-spacing:.04em; padding:0 4px; }
682
+ #az-ins .keys b { color:var(--ink); font-weight:500; }
683
+ #az-ins .code { display:grid; gap:2px; padding:12px 12px 14px; border:1px solid var(--line); border-radius:12px; min-width:0; }
684
+ #az-ins .code > .k { padding:0 0 6px; }
685
+ #az-ins .code .k.sub { padding:10px 0 4px; }
686
+ #az-ins .code p { padding:2px 0; }
687
+ #az-ins p.meta { font-size:11px; line-height:1.55; color:var(--dim); font-variant-numeric:tabular-nums; }
688
+ #az-ins p.meta b { color:var(--ink); font-weight:500; }
689
+ #az-ins p.meta b.gap { color:var(--accent); }
690
+ #az-ins a.src { display:flex; align-items:baseline; gap:8px; padding:4px 6px; margin:0 -6px; border-radius:6px; color:var(--hi); text-decoration:none; min-width:0;
691
+ font:11.5px/1.45 ui-monospace, "SF Mono", Menlo, Consolas, monospace; transition:background .15s; }
692
+ #az-ins a.src span { flex:1; min-width:0; overflow-wrap:anywhere; }
693
+ #az-ins a.src i { font-style:normal; color:var(--dim); transition:color .15s; }
694
+ #az-ins a.src em { font-style:normal; font-size:9.5px; letter-spacing:.1em; text-transform:uppercase; color:var(--accent); white-space:nowrap; }
695
+ #az-ins a.src:hover { background:var(--bg); }
696
+ #az-ins a.src:hover i { color:var(--accent); }
697
+ #az-ins a.src:focus-visible, #az-ins .seg button:focus-visible, #az-ins .chip-b:focus-visible, #az-ins .copy:focus-visible, #az-ins .branch-b:focus-visible { outline:1px solid var(--accent); outline-offset:1px; }
698
+ #az-ins .head a.src { margin:4px -6px 0; }
699
+ #az-ins pre.sig { margin:6px 0 2px; padding:8px 10px; background:var(--bg); border:1px solid var(--line); border-radius:8px; color:var(--ink);
700
+ font:11px/1.5 ui-monospace, "SF Mono", Menlo, Consolas, monospace; white-space:pre-wrap; overflow-wrap:anywhere; }
701
+ #az-ins details > summary { cursor:pointer; font-size:11px; color:var(--dim); padding:4px 0; list-style-position:inside; }
702
+ #az-ins details > summary:hover { color:var(--ink); }
703
+ #az-ins details.sites { margin:-4px 10px 6px 42px; }
704
+ #az-ins .step-src { margin:-6px 10px 4px 36px; }
705
+ #az-ins .chips { display:flex; flex-wrap:wrap; gap:6px; padding:2px 0; }
706
+ #az-ins .chip-b { all:unset; cursor:pointer; font-size:11px; line-height:1.4; padding:3px 9px; border:1px solid var(--line); border-radius:999px; color:var(--hi); transition:border-color .15s; }
707
+ #az-ins .chip-b em { font-style:normal; color:var(--dim); margin-left:4px; }
708
+ #az-ins .chip-b:hover { border-color:var(--accent); }
709
+ #az-ins .copy { all:unset; cursor:pointer; margin-top:10px; width:fit-content; font-size:11px; padding:5px 11px; border:1px solid var(--line); border-radius:9px; color:var(--ink); }
710
+ #az-ins .copy:hover { color:var(--hi); border-color:var(--dim); }
711
+ #az-ins textarea.copied { box-sizing:border-box; width:100%; height:120px; margin-top:6px; padding:8px; font:11px/1.5 ui-monospace, Menlo, monospace; color:var(--hi); background:var(--bg); border:1px solid var(--line); border-radius:8px; }
712
+ #az-ins .branch-b { all:unset; box-sizing:border-box; cursor:pointer; display:grid; gap:2px; margin-top:4px; padding:9px 12px; border:1px solid var(--line); border-radius:10px; transition:border-color .2s; }
713
+ #az-ins .branch-b span { font-size:12.5px; color:var(--hi); }
714
+ #az-ins .branch-b em { font-style:normal; font-size:11px; color:var(--accent); }
715
+ #az-ins .branch-b:hover { border-color:var(--accent); }
716
+ #az-ins .openin { display:grid; gap:8px; padding:0 4px; }
717
+ #az-ins .seg { display:flex; width:fit-content; border:1px solid var(--line); border-radius:9px; overflow:hidden; }
718
+ #az-ins .seg button { all:unset; cursor:pointer; padding:5px 11px; font-size:11px; color:var(--ink); }
719
+ #az-ins .seg button + button { border-left:1px solid var(--line); }
720
+ #az-ins .seg button.on { background:var(--bg); color:var(--accent); }
721
+ #az-ins label.root { display:grid; gap:4px; font-size:10.5px; color:var(--dim); }
722
+ #az-ins label.root input { box-sizing:border-box; width:100%; padding:6px 8px; border:1px solid var(--line); border-radius:8px; background:var(--bg); color:var(--hi); font:11px ui-monospace, "SF Mono", Menlo, monospace; outline:none; }
723
+ #az-ins label.root input:focus { border-color:var(--accent); }
724
+ #az-ins p.warn { font-size:11px; line-height:1.5; color:var(--accent); }
725
+ #az-ins .cmpctl { display:grid; grid-template-columns:minmax(0,1fr) auto minmax(0,1fr); align-items:end; gap:8px; margin:4px 0 6px; }
726
+ #az-ins .cmpctl label { display:grid; gap:4px; min-width:0; }
727
+ #az-ins .cmpctl select { box-sizing:border-box; width:100%; min-width:0; padding:6px 8px; border:1px solid var(--line); border-radius:8px; background:var(--bg); color:var(--hi);
728
+ font:11.5px ui-monospace, "SF Mono", Menlo, Consolas, monospace; }
729
+ #az-ins .cmpctl select:focus-visible { outline:1px solid var(--accent); outline-offset:1px; }
730
+ #az-ins .cmpctl .swap { all:unset; box-sizing:border-box; cursor:pointer; height:30px; padding:0 9px; border:1px solid var(--line); border-radius:8px; color:var(--ink); display:grid; place-items:center; }
731
+ #az-ins .cmpctl .swap:hover:not(:disabled) { color:var(--accent); border-color:var(--accent); }
732
+ #az-ins .cmpctl .swap:disabled { opacity:.35; cursor:default; }
733
+ #az-ins .cmpctl .swap:focus-visible { outline:1px solid var(--accent); outline-offset:1px; }
734
+ #az-pins { position:fixed; inset:0; pointer-events:none; z-index:3; overflow:hidden; }
735
+ .az-pin { all:unset; position:absolute; left:0; top:0; width:22px; height:31px; margin:-31px 0 0 -11px; display:flex; flex-direction:column; align-items:center;
736
+ pointer-events:auto; cursor:help; transition:opacity .2s; will-change:transform; }
737
+ .az-pin i { box-sizing:border-box; width:16px; height:16px; border-radius:50%; border:1px solid var(--dim); background:var(--pinbg); color:var(--ink);
738
+ font:italic 600 10.5px/14px Georgia, "Times New Roman", serif; text-align:center; transition:border-color .2s, color .2s, box-shadow .2s; }
739
+ .az-pin b { width:1px; flex:1; background:var(--dim); opacity:.75; }
740
+ .az-pin:hover i, .az-pin.on i, .az-pin:focus-visible i { border-color:var(--accent); color:var(--accent); box-shadow:0 0 10px -1px var(--accent); }
741
+ .az-pin:hover b, .az-pin.on b { background:var(--accent); opacity:1; }
742
+ .az-pin.off { opacity:0; pointer-events:none; }
743
+ #az-tip { position:fixed; z-index:6; left:0; top:0; width:264px; pointer-events:none; transform-origin:0 100%;
744
+ transition:transform .3s cubic-bezier(.2,.9,.25,1.12), opacity .22s; }
745
+ #az-tip.fold { transform:perspective(640px) rotateX(-82deg) rotateY(10deg); opacity:0; transition:none; }
746
+ #az-tip .card { position:relative; padding:12px 14px 13px; background:var(--tipbg); border:1px solid var(--accent); border-radius:2px; box-shadow:0 22px 40px -18px rgba(0,0,0,.55); }
747
+ #az-tip .card::before { content:""; position:absolute; left:-1px; right:-1px; top:-9px; height:8px; background:var(--slab); border:1px solid var(--accent); border-bottom:0;
748
+ transform:skewX(45deg); transform-origin:0 100%; }
749
+ #az-tip .card::after { content:""; position:absolute; top:-1px; bottom:-1px; left:-9px; width:8px; background:var(--slab); border:1px solid var(--accent); border-right:0;
750
+ transform:skewY(45deg); transform-origin:100% 0; }
751
+ #az-tip .meta { font-size:9.5px; letter-spacing:.12em; text-transform:uppercase; color:var(--accent); }
752
+ #az-tip .title { margin-top:6px; font-size:13.5px; line-height:1.3; font-weight:500; color:var(--hi); }
753
+ #az-tip .body { margin-top:9px; padding-top:9px; border-top:1px solid var(--line); font-size:11.5px; line-height:1.5; color:var(--ink); }
754
+ #az-tip .foot { margin-top:10px; font-size:10px; letter-spacing:.06em; color:var(--accent); }
755
+ #az-lead { position:fixed; left:0; top:0; width:100vw; height:100vh; pointer-events:none; z-index:5; display:none; overflow:visible; }
756
+ #az-lead.on { display:block; }
757
+ #az-lead line { stroke:var(--accent); stroke-width:1; stroke-dasharray:2 3; }
758
+ `;
759
+ document.head.appendChild(css);
760
+ const ins = document.createElement('aside'); ins.id = 'az-ins'; ins.setAttribute('aria-label', 'What is inside'); document.body.appendChild(ins);
761
+ const pinsEl = document.createElement('div'); pinsEl.id = 'az-pins'; document.body.appendChild(pinsEl);
762
+ const leadEl = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); leadEl.id = 'az-lead'; leadEl.innerHTML = '<line x1="0" y1="0" x2="0" y2="0"/>'; document.body.appendChild(leadEl);
763
+ const tipEl = document.createElement('div'); tipEl.id = 'az-tip'; tipEl.hidden = true; tipEl.setAttribute('role', 'tooltip'); document.body.appendChild(tipEl);
764
+
765
+ function pinTargets() {
766
+ const t = top(), out = [];
767
+ const railPin = { id: 'rail', at: () => [R.x + 20 + RTW + 14, R.y + R.d - 16, 5.2] };
768
+ if (!t) {
769
+ for (const d of DIST) out.push({ id: d.id, at: () => d.at });
770
+ if (R) out.push(railPin);
771
+ for (const m of LIST) if (PL[m.id]) { const n = NODE[m.id]; out.push({ id: n.id, at: () => [n.x + n.w - 16, n.y + 16, n.h * VIS[n.id].cover.scale.y + .3] }); }
772
+ } else if (t === 'dock') {
773
+ out.push(railPin);
774
+ for (const pt of SCENE.ports) out.push({ id: 'P:' + pt.n, at: () => [pt.x, R.y + 8, 11.3] });
775
+ } else {
776
+ const n = NODE[t];
777
+ out.push({ id: n.id, at: () => [n.x + 4, n.y + 4, .6] });
778
+ for (const z of n.zones) out.push({ id: z.id, at: () => [z.x + 9 + Math.min(9, z.w / (z.name.length * (CW + .12) + 2)) * z.name.length * (CW + .12) + 9, z.y + 7, .4] });
779
+ for (const k of n.kids) out.push({ id: k.id, at: () => { const s = Math.min(k.w, k.d), m = k.kind === 'model' ? s * .5 : s * .12; return [k.x + k.w - m, k.y + Math.min(m, k.d * .3), k.h * (VIS[k.id] ? VIS[k.id].q : 0) + .2]; } });
780
+ }
781
+ return out;
782
+ }
783
+ const PINS = new Map();
784
+ function syncPins() {
785
+ const T = pinTargets(), want = new Set(T.map(p => p.id));
786
+ for (const [id, P] of PINS) if (!want.has(id)) { P.el.remove(); PINS.delete(id); }
787
+ for (const t of T) {
788
+ let P = PINS.get(t.id);
789
+ if (!P) {
790
+ const el = document.createElement('button'); el.type = 'button'; el.className = 'az-pin off'; el.dataset.tip = t.id;
791
+ const inf = tipInfo(t.id); el.setAttribute('aria-label', `What is ${inf ? inf.title : 'this'}?`); el.innerHTML = '<i>i</i><b></b>';
792
+ pinsEl.appendChild(el); P = { el, occ: false, vis: false, sx: -99, sy: -99 }; PINS.set(t.id, P);
793
+ }
794
+ P.at = t.at;
795
+ }
796
+ pinsOn(); occTick = 0;
797
+ }
798
+ const pinsOn = () => { for (const [id, P] of PINS) P.el.classList.toggle('on', !!tip && tip.id === id); };
799
+ const VP = new THREE.Vector3(), ORAY = new THREE.Raycaster(), ONDC = new THREE.Vector2();
800
+ let occTick = 0;
801
+ const ownHit = (ud, id) => (ud.node && (ud.node.id === id || (ud.floor && id.startsWith(ud.node.id + '#')))) || (ud.port && id === 'P:' + ud.port) || (ud.rail && id === 'rail') || ud.plate;
802
+ function occluded(wp, id) {
803
+ ORAY.setFromCamera(ONDC.set(VP.x, VP.y), camera);
804
+ const dist = ORAY.ray.origin.distanceTo(wp);
805
+ for (const h of ORAY.intersectObjects(pickList(), false)) {
806
+ if (h.distance > dist - .015) return false;
807
+ if (!pickable(h.object)) continue;
808
+ if (ownHit(h.object.userData, id)) continue;
809
+ return true;
810
+ }
811
+ return false;
812
+ }
813
+ function placePins() {
814
+ const occNow = occTick++ % 6 === 0;
815
+ for (const [id, P] of PINS) {
816
+ const wp = W(...P.at()); VP.copy(wp).project(camera);
817
+ const on = VP.z < 1 && VP.z > -1 && Math.abs(VP.x) < 1.04 && Math.abs(VP.y) < 1.04;
818
+ P.sx = (VP.x + 1) / 2 * innerWidth; P.sy = (1 - VP.y) / 2 * innerHeight;
819
+ if (occNow) P.occ = on && occluded(wp, id);
820
+ const vis = on && !P.occ;
821
+ if (vis !== P.vis) { P.vis = vis; P.el.classList.toggle('off', !vis); }
822
+ P.el.style.transform = `translate(${P.sx.toFixed(1)}px, ${P.sy.toFixed(1)}px)`;
823
+ }
824
+ placeTip();
825
+ }
826
+
827
+ /* ---------- the tooltip: an extruded card that unfolds from its pin ---------- */
828
+ let tip = null, tipHide = null;
829
+ function tipInfo(id) {
830
+ if (id.startsWith('L:')) { const l = LAYER[id.slice(2)]; return { meta: 'layer', title: l.name, body: l.tip || l.blurb }; }
831
+ if (id === 'rail') return { meta: RL, title: MOD.dock.name, body: AZ.rail && AZ.rail.tip || MOD.dock.role, foot: `${AZ.groups.length} ${ARCH ? 'ports' : 'groups'} · click the rail to list them` };
832
+ if (id.startsWith('P:')) { const n = +id.slice(2), g = GROUP[n]; return { meta: `${RL} · port ${n}${g.models.length ? ` · ${g.models.length} models` : ''}`, title: g.name, body: `${g.tip || ''} ${g.models.slice(0, 8).join(', ')}${g.models.length > 8 ? '…' : ''}`.trim(), foot: g.users.length ? `used by ${g.users.slice(0, 3).map(u => MOD[u[0]] ? MOD[u[0]].short : u[0]).join(', ')}${g.users.length > 3 ? ` +${g.users.length - 3}` : ''}` : (ARCH ? 'no block uses it' : 'no calls found') }; }
833
+ const n = NODE[id]; if (!n) return null;
834
+ if (n.kind === 'zone') return { meta: `group in ${n.parent.tag}`, title: n.name, body: n.tip || '' };
835
+ if (n.level === 1) return { meta: LAYER[n.m.layer].name, title: n.name, body: n.tip || n.what, foot: st.path[0] === n.id ? `${n.kids.length} parts inside` : `click to open · ${n.kids.length} parts inside` };
836
+ const stt = n.item && n.item.status ? ` · ${n.item.status}` : '';
837
+ return { meta: `${KIND[n.kind].label}${stt} · in ${n.parent.tag}`, title: n.name, body: n.tip || n.what,
838
+ foot: openable(n) ? (st.path.includes(n.id) ? `${n.kids.length} inside` : `click to open · ${n.kids.length} inside`) : '' };
839
+ }
840
+ function showTip(id, pinned) {
841
+ cancel(tipHide);
842
+ if (tip && tip.id === id) { tip.pinned = tip.pinned || pinned; return; }
843
+ const info = tipInfo(id); if (!info) return;
844
+ tip = { id, pinned, info };
845
+ tipEl.innerHTML = `<div class="card"><div class="meta">${esc(info.meta)}</div><div class="title">${esc(info.title)}</div>${info.body ? `<div class="body">${esc(info.body)}</div>` : ''}${info.foot ? `<div class="foot">${esc(info.foot)}</div>` : ''}</div>`;
846
+ tipEl.hidden = false; tipEl.classList.add('fold'); leadEl.classList.add('on');
847
+ placeTip(); after(0, () => tipEl.classList.remove('fold'));
848
+ pinsOn();
849
+ }
850
+ function hideTip(now) {
851
+ cancel(tipHide);
852
+ const go = () => { tip = null; tipEl.hidden = true; tipEl.innerHTML = ''; leadEl.classList.remove('on'); pinsOn(); };
853
+ if (now) go(); else tipHide = after(160, go);
854
+ }
855
+ function placeTip() {
856
+ if (!tip) return;
857
+ const P = PINS.get(tip.id); if (!P) return hideTip(true);
858
+ tipEl.style.visibility = P.vis ? '' : 'hidden'; leadEl.classList.toggle('on', P.vis);
859
+ if (!P.vis) return;
860
+ const ax = P.sx, ay = P.sy - 23, cw = tipEl.offsetWidth || 264, ch = tipEl.offsetHeight || 150;
861
+ let left = ax + 22, y = ay - 20 - ch, flip = false;
862
+ if (left + cw + 14 > innerWidth) { left = ax - 22 - cw; flip = true; }
863
+ if (left < FREE.px + 12) { left = FREE.px + 12; }
864
+ if (y < 60) y = Math.min(innerHeight - ch - 14, ay + 34);
865
+ tipEl.style.left = left.toFixed(1) + 'px'; tipEl.style.top = y.toFixed(1) + 'px'; tipEl.style.transformOrigin = flip ? '100% 100%' : '0 100%';
866
+ const ln = leadEl.firstChild, cx = flip ? left + cw : left, cy = y + ch > ay ? y : y + ch;
867
+ ln.setAttribute('x1', ax.toFixed(1)); ln.setAttribute('y1', ay.toFixed(1)); ln.setAttribute('x2', cx.toFixed(1)); ln.setAttribute('y2', cy.toFixed(1));
868
+ }
869
+ pinsEl.addEventListener('pointerover', e => { const b = e.target.closest('.az-pin'); if (b && (!tip || !tip.pinned)) showTip(b.dataset.tip, false); });
870
+ pinsEl.addEventListener('pointerout', e => { const b = e.target.closest('.az-pin'); if (b && tip && !tip.pinned && !(e.relatedTarget && b.contains(e.relatedTarget))) hideTip(); });
871
+ pinsEl.addEventListener('click', e => {
872
+ const b = e.target.closest('.az-pin'); if (!b) return;
873
+ st.touched = true; const id = b.dataset.tip;
874
+ if (tip && tip.id === id && tip.pinned) hideTip(true); else { hideTip(true); showTip(id, true); }
875
+ b.blur();
876
+ });
877
+
878
+ /* ---------- links into the code: GitHub at the scanned commit, or the viewer's editor ---------- */
879
+ const PREF = { open: REPO && REPO.pushed ? 'github' : 'vscode', root: REPO ? REPO.root : '' };
880
+ try { const j = JSON.parse((localStorage.getItem('archivision-open') || localStorage.getItem('birdview-open')) || 'null'); if (j && j.open) Object.assign(PREF, j); } catch {}
881
+ const savePref = () => { try { localStorage.setItem('archivision-open', JSON.stringify(PREF)); } catch {} };
882
+ const encP = p => p.split('/').map(encodeURIComponent).join('/');
883
+ function href(p, l, e) {
884
+ if (PREF.open === 'github') return `${REPO.remote}/blob/${REPO.sha}/${encP(p)}${l ? `#L${l}${e && e > l ? `-L${e}` : ''}` : ''}`;
885
+ const root = String(PREF.root || REPO.root).replace(/\/+$/, '');
886
+ return `${PREF.open}://file${encodeURI(root + '/' + p)}${l ? `:${l}` : ''}`;
887
+ }
888
+ const diffHref = p => CUR_C ? `${REPO.remote}/compare/${encodeURIComponent(CUR_C.bl)}...${encodeURIComponent(CUR_C.hl)}${p in PIDX ? `#diff-${CM.hash[PIDX[p]]}` : ''}` : '#';
889
+ // a path inside its code root: the most specific root holding it (a package's src/ inside the repo's)
890
+ const unroot = p => { const r = ROOTS.filter(x => x !== '.' && String(p).startsWith(x + '/')).sort((a, b) => b.length - a.length)[0]; return r ? String(p).slice(r.length + 1) : String(p); };
891
+ const loc = (p, l) => `${unroot(p)}${l ? `:${l}` : ''}`;
892
+ // xa: more data-* attributes, as a data use of a store other than Prisma carries its store and resource name
893
+ const aSrc = (p, l, e, txt, kind, name, tgt, col, xa) => `<a class="src" href="${esc(href(p, l, e))}" target="_blank" rel="noopener" title="${esc(p)}${l ? ':' + l : ''}" data-kind="${kind}" data-path="${esc(p)}"${l ? ` data-line="${l}"` : ''}${col ? ` data-col="${col}"` : ''}${e ? ` data-end="${e}"` : ''}${name ? ` data-name="${esc(name)}"` : ''}${tgt && tgt[0] ? ` data-target="${esc(`${tgt[0]}:${tgt[1]}-${tgt[2]}`)}"` : ''}${xa ? Object.entries(xa).map(([k, v]) => ` data-${k}="${esc(v)}"`).join('') : ''}><span>${esc(txt || loc(p, l))}</span><i aria-hidden="true">↗</i></a>`;
894
+ const aDiff = (p, s) => !(REPO && REPO.remote) ? `<span class="src diff" data-path="${esc(p)}" data-status="${esc(s)}"><span>${esc(loc(p))}</span><em>${esc(CHL[s] || s)}</em></span>` : `<a class="src diff" href="${esc(diffHref(p))}" target="_blank" rel="noopener" title="diff of ${esc(p)}" data-kind="diff" data-path="${esc(p)}" data-status="${esc(s)}"><span>${esc(loc(p))}</span><em>${esc(CHL[s] || s)}</em><i aria-hidden="true">↗</i></a>`;
895
+ const CHL = { A: 'new', M: 'changed', D: 'deleted', R: 'renamed', C: 'copied', T: 'type' };
896
+ const KL = { page: 'page', layout: 'layout', component: 'component', hook: 'hook', handler: 'API route', lambda: 'Lambda handler', action: 'server action', function: 'function', class: 'class', const: 'constant', enum: 'enum' };
897
+ const PLUR = { page: 'pages', action: 'actions', handler: 'API routes', lambda: 'Lambda handlers', component: 'components', hook: 'hooks', function: 'functions' };
898
+ // a data use links its line: a Prisma call by its model, any other store's call by its store and resource
899
+ const dataLink = (p, l, txt, m, st, res) => aSrc(p, l, null, txt, st ? 'data-op' : 'data-use', m, null, null, st ? { store: st, res } : null);
900
+ const RW = { r: 'reads', w: 'writes', rw: 'reads, writes' };
901
+ const plural = (n, w) => `${n} ${w}${n === 1 ? '' : 's'}`;
902
+ const more = (list, n, fn, label) => list.length <= n ? list.map(fn).join('') : list.slice(0, n).map(fn).join('') + `<details><summary>${list.length - n} more ${label}</summary>${list.slice(n).map(fn).join('')}</details>`;
903
+ const pieceCh = c => !c || !c.p ? '' : CHF.get(c.p) === 'A' ? 'added' : CHS.has(`${c.p}#${c.sym}`) ? 'changed' : '';
904
+ const isCh = k => { const c = k && k.item && k.item.code; if (!c) return false; return c.files ? c.files.some(f => CHF.has(f[0])) : !!pieceCh(c); };
905
+ function openIn() {
906
+ if (!REPO) return '';
907
+ return `<div class="openin"><span class="k">Code opens in</span><div class="seg">${[['github', 'GitHub'], ['vscode', 'VS Code'], ['cursor', 'Cursor']].map(([k, l]) => `<button type="button" data-open="${k}" class="${PREF.open === k ? 'on' : ''}">${l}</button>`).join('')}</div>
908
+ ${PREF.open !== 'github' ? `<label class="root"><span>Your checkout of ${esc(REPO.name)}</span><input type="text" data-root value="${esc(PREF.root)}" spellcheck="false" autocomplete="off"></label>` : REPO.pushed ? '' : `<p class="warn">Commit ${esc(REPO.sha.slice(0, 7))} is not on GitHub yet: these links work once ${esc(REPO.branch)} is pushed.</p>`}</div>`;
909
+ }
910
+ function partCode(k, title) {
911
+ const c = k.item && k.item.code; if (!c || !c.files) return '';
912
+ const cnt = Object.entries(c.counts).filter(([, v]) => v).map(([kk, v]) => `${v} ${v === 1 ? KL[kk] : PLUR[kk]}`).join(' · ');
913
+ const files = c.files.slice().sort((a, b) => (st.branch ? (CHF.has(b[0]) ? 1 : 0) - (CHF.has(a[0]) ? 1 : 0) : 0) || b[1] - a[1]);
914
+ let h = `<section class="code"><span class="k">${title || 'Code'} · ${esc(k.name)}</span><p class="meta">${plural(c.files.length, 'file')} · ${c.nLines.toLocaleString('en-US')} lines${cnt ? ` · ${cnt}` : ''}</p>`;
915
+ h += more(files, 8, f => st.branch && CHF.has(f[0]) ? aDiff(f[0], CHF.get(f[0])) : aSrc(f[0], null, null, `${loc(f[0])} · ${f[1]}`, 'file'), 'files');
916
+ if (c.db.length) h += `<span class="k sub">${esc(RLC)}</span><div class="chips">${c.db.map(([m, r, g]) => `<button type="button" class="chip-b" data-port="${g}" title="${RW[r]} ${esc(m)}">${esc(m)} <em>${r === 'rw' ? 'r/w' : r}</em></button>`).join('')}</div>`;
917
+ h += `<span class="k sub">Tests</span><p class="meta">${plural(c.vitest.length, `${RUN.unit} file`)} · ${plural(c.e2e.length, `${RUN.e2e} spec`)}${!c.vitest.length && !c.e2e.length ? ' · <b class="gap">no test reaches this part</b>' : ''}</p>`;
918
+ if (c.vitest.length + c.e2e.length) h += more([...c.vitest.map(f => [f, (AZ.runners || {})[f] || RUN.unit]), ...c.e2e.map(f => [f, (AZ.runners || {})[f] || RUN.e2e])], 3, ([f, w]) => aSrc(f, null, null, `${w} · ${f.replace(TEST_RE, '')}`, 'test'), 'tests');
919
+ h += `<button type="button" class="copy" data-copy="${esc(k.id)}">Copy context for Claude</button></section>`;
920
+ return h;
921
+ }
922
+ function pieceCode(k) {
923
+ const c = k.item && k.item.code; if (!c) return '';
924
+ let h = `<section class="code"><span class="k">${esc(KL[k.item.sk] || 'symbol')} · ${esc(c.sym)}${st.branch && pieceCh(c) ? ` · <b>${esc(pieceCh(c))} in ${esc(CUR_C.hl)}</b>` : ''}</span>${aSrc(c.p, c.l, c.e, `${loc(c.p)}:${c.l}–${c.e}`, 'symbol', c.sym)}`;
925
+ if (c.sig) h += `<pre class="sig">${esc(c.sym + c.sig)}</pre>`;
926
+ if (c.doc) h += `<p>${esc(c.doc)}</p>`;
927
+ h += `<span class="k sub">Used by · ${c.nCallers}</span>${c.callers.length ? more(c.callers, 4, ([p, l, fs, st, , k, cn, col]) => aSrc(p, l, null, `${fs || 'module'} · ${loc(p, l)}`, k === 'call' ? 'call-site' : 'ref-site', k === 'call' ? cn : c.sym, [c.p, c.l, c.e], col, k === 'call' && st ? { st } : null), 'callers') : `<p class="meta">Nothing in ${esc(ROOTS_TXT)} calls it directly (a page, a route or an entry point).</p>`}`;
928
+ if (c.nCallers > c.callers.length) h += `<p class="meta">and ${c.nCallers - c.callers.length} more places</p>`;
929
+ if (c.calls.length) h += `<span class="k sub">Calls · ${c.nCalls}</span>${more(c.calls, 4, ([sym, p, l, , , e]) => aSrc(p, l, e || null, `${sym} · ${loc(p)}`, 'symbol', sym), 'calls')}`;
930
+ if (c.uses && c.uses.length) h += `<span class="k sub">Uses · ${c.nUses}</span>${more(c.uses, 4, ([sym, p, l, , , e]) => aSrc(p, l, e || null, `${sym} · ${loc(p)}`, 'symbol', sym), 'uses')}`;
931
+ if (c.db.length) h += `<span class="k sub">${esc(RLC)}</span>${c.db.map(([m, r, l, st, res]) => dataLink(c.p, l, `${RW[r]} ${m} · line ${l}`, m, st, res)).join('')}`;
932
+ return h + '</section>';
933
+ }
934
+ function groupCode(i) {
935
+ const g = GROUP[DOCKSETS[i][0]];
936
+ if (!g.models.length) return `<section class="code"><span class="k">Port ${g.n} · ${esc(g.name)}</span>${g.tip ? `<p>${esc(g.tip)}</p>` : ''}<span class="k sub">Blocks on this port</span>${g.users.length ? g.users.map(([m, , r]) => row(`data-m="${esc(m)}"`, '←', MOD[m] ? MOD[m].name : m, r || '', '')).join('') : '<p class="meta">No block uses this port.</p>'}</section>`;
937
+ let h = `<section class="code"><span class="k">Port ${g.n} · ${esc(g.name)}</span>${g.tip ? `<p>${esc(g.tip)}</p>` : ''}<span class="k sub">${esc(STORE.what[0].toUpperCase() + STORE.what.slice(1))} · ${g.models.length}</span>`;
938
+ // a Prisma model links its block in the schema; a bucket, a table or a queue the line that names it
939
+ h += more(g.modelsInfo, 10, ([m, l, e, nf, file, sto, res]) => { const ch = st.branch && CUR_C && CUR_C.models.includes(m) ? ' · changed' : '';
940
+ return sto ? aSrc(file, l, e, `${m} · ${(STORE.of || {})[sto] || sto}${nf ? ` · ${nf} fields` : ''}${ch}`, 'resource', m, null, null, { store: sto, res }) : aSrc(file || 'prisma/schema.prisma', l, e, `${m} · ${nf} fields${ch}`, 'model', m); }, STORE.what);
941
+ h += `<span class="k sub">Used by</span>${g.users.length ? g.users.map(([m, n, r]) => row(`data-m="${esc(m)}"`, '←', MOD[m] ? MOD[m].name : m, RW[r], `${plural(n, 'call site')}`)).join('') : `<p class="meta">No ${esc(STORE.label)} calls found for these ${esc(STORE.what)}.</p>`}`;
942
+ return h + '</section>';
943
+ }
944
+ function codeFor() {
945
+ const t = top(), s = st.sel && NODE[st.sel];
946
+ if (t === 'dock') return groupCode(st.dockSel);
947
+ if (!t) return '';
948
+ const n = NODE[t];
949
+ if (n.level === 1) return s && s.parent === n ? partCode(s) : '';
950
+ return (s && s.parent === n ? pieceCode(s) : '') + partCode(n, 'All code');
951
+ }
952
+ const renderCode = () => { const el = ins.querySelector('#az-code'); if (el) el.innerHTML = codeFor(); };
953
+ function copyContext(id) {
954
+ const k = NODE[id], c = k.item.code;
955
+ const txt = `In ${REPO.name} (commit ${REPO.sha.slice(0, 7)}), look at «${k.name}» inside the «${k.parent.name}» block: ${k.tip || k.what || ''}\nFiles:\n${c.files.map(f => `- ${f[0]}`).join('\n')}\n${c.db.length ? `It touches these ${STORE.label} ${STORE.what}: ${c.db.map(d => d[0]).join(', ')}.\n` : ''}`;
956
+ const done = ok => { const b = ins.querySelector(`[data-copy="${CSS.escape(id)}"]`); if (b) { b.textContent = ok ? 'Copied' : 'Select and copy below'; } if (!ok && b) { const ta = document.createElement('textarea'); ta.className = 'copied'; ta.value = txt; b.after(ta); ta.select(); } };
957
+ try { navigator.clipboard.writeText(txt).then(() => done(true), () => done(false)); } catch { done(false); }
958
+ }
959
+
960
+ /* ---------- readout and side panel ---------- */
961
+ const stepName = s => isPort(s) ? `${RLC} · ${GROUP[+s.slice(1)].name}` : MOD[s].name;
962
+ // the comparison on show: which files and functions changed, which blocks light, which tables
963
+ const CM = AZ.cmp, PATHS = CM.paths, PIDX = Object.fromEntries(PATHS.map((p, i) => [p, i]));
964
+ const lab = r => { const h = CM.heads.find(x => x[0] === r); return h ? h[1] : String(r).replace(/^(origin|upstream)\//, ''); };
965
+ let CUR_C = null, CHF = new Map(), CHS = new Set(), BRM = new Map(), BRDB = new Set(), BRHIT = new Set();
966
+ function setCmp(i) {
967
+ CUR_C = CM.list[i] || null; st.cmpI = i;
968
+ CHF = new Map((CUR_C ? CUR_C.f : []).map(([x, s]) => [PATHS[x], s]));
969
+ CHS = new Set(CUR_C ? Object.entries(CUR_C.s).flatMap(([x, ns]) => ns.map(n => `${PATHS[x]}#${n}`)) : []);
970
+ BRM = new Map((CUR_C ? CUR_C.mods : []).map(x => [x[0], x]));
971
+ BRDB = new Set(CUR_C ? CUR_C.db : []);
972
+ BRHIT = new Set(Object.values(CUR_C ? CUR_C.blast : {}).flatMap(l => l.map(x => x[0])).filter(m => !BRM.has(m)));
973
+ }
974
+ setCmp(CM.def);
975
+ function pickCmp(base, head) {
976
+ const i = CM.list.findIndex(c => c.base === base && c.head === head);
977
+ if (i >= 0) { st.cmpWant = null; setCmp(i); } else st.cmpWant = { base, head };
978
+ if (!st.path.length && !st.jour) showLinks(null);
979
+ const y = ins.scrollTop; panel(); say(); ins.scrollTop = y;
980
+ }
981
+ function say() {
982
+ if (active !== 'az') return;
983
+ const el = document.getElementById('read'), out = s => { el.textContent = s; };
984
+ const J = st.jour, t = J ? null : top();
985
+ if (t === 'dock') return out(`${RL} · port ${DOCKSETS[st.dockSel][0]} · ${GROUP[DOCKSETS[st.dockSel][0]].name.toLowerCase()}`);
986
+ if (t) { const s = st.sel && NODE[st.sel]; return out(`${st.path.map(i => NODE[i].tag.toLowerCase()).join(' › ')}${s ? ` · ${s.tag.toLowerCase()}` : ''}${s && openable(s) ? ' · enter opens it' : ''}`); }
987
+ if (J) { const jj = AZ.journeys[J.k]; return out(`walkthrough ${J.k + 1} · step ${Math.max(1, J.i + 1)} of ${jj.steps.length} · ${stepName(jj.steps[Math.max(0, J.i)]).toLowerCase()}`); }
988
+ if (st.hoverPort) return out(`port ${st.hoverPort} · ${GROUP[st.hoverPort].name.toLowerCase()}`);
989
+ const f = st.hover || st.focus;
990
+ if (st.branch && !f) return out(CUR_C && !st.cmpWant ? `${CUR_C.hl} vs ${CUR_C.bl} · ${CUR_C.f.length} files changed · ${CUR_C.mods.length} blocks lit` : 'compare branches · pick a pair');
991
+ out(f ? `${MOD[f].short.toLowerCase()} · ${MOD[f].parts.length} parts · ${MOD[f].count}` : `${LIST.filter(m => m.id !== 'dock').length} blocks · ${REPO ? `${AZ.totals.files} files · ` : ''}${AZ.journeys.length} walkthroughs · drag to orbit`);
992
+ }
993
+ const row = (attrs, n, t, c, d, on, go) => `<button type="button" class="row${on ? ' on' : ''}" ${attrs}><span class="n">${n}</span><span class="t">${esc(t)}</span><span class="c${go ? ' go' : ''}">${esc(c || '')}</span>${d ? `<span class="d">${esc(d)}</span>` : ''}</button>`;
994
+ const sites = list => list && list.length ? `<details class="sites"><summary>${plural(list.length, 'place')} in the code</summary>${list.map(([p, l, fs, ts, tp, tl, te, st, , k, cn, col]) => aSrc(p, l, null, `${fs || 'module'} → ${ts} · ${loc(p, l)}`, k === 'call' ? 'call-site' : 'ref-site', k === 'call' ? cn : ts, [tp, tl, te], col, k === 'call' && st ? { st } : null)).join('')}</details>` : '';
995
+ const keysTxt = `<b>click</b> a block to open it · <b>ⓘ</b> or <b>i</b> explains · <b>← →</b> move · <b>enter</b> open · <b>space</b> next walkthrough${ARCH ? '' : ' · <b>b</b> this branch'} · <b>esc</b> back`;
996
+ function panel() {
997
+ const J = st.jour, t = J ? null : top();
998
+ if (t === 'dock') {
999
+ const m = MOD.dock;
1000
+ ins.innerHTML = `<nav class="crumbs"><button type="button" data-act="root">${WHOLE}</button>›<span>${esc(RLC)}</span></nav>
1001
+ <div class="head"><span class="k">${esc(LAYER.dock.name)}</span><h2>${esc(m.name)}</h2><p class="lead">${esc((AZ.rail && AZ.rail.tip) || m.role)}</p><span class="chip">${esc(m.count)}</span></div>
1002
+ <section><span class="k">${ARCH ? 'Ports' : 'Groups · one port each'}</span>${m.parts.map((p, i) => row(`data-dock="${i}"`, String(p.ports[0]).padStart(2, '0'), p.name, st.branch && BRDB.has(p.ports[0]) ? 'changed' : '', p.what, i === st.dockSel)).join('')}</section>
1003
+ <div id="az-code">${codeFor()}</div>${openIn()}
1004
+ <div class="keys"><b>↑ ↓</b> ${ARCH ? 'ports' : 'groups'} · <b>esc</b> back to the whole ${REPO ? 'repo' : 'map'}</div>`;
1005
+ return;
1006
+ }
1007
+ if (t) {
1008
+ const n = NODE[t], crumbs = st.path.map((id, i) => i < st.path.length - 1 ? `<button type="button" data-n-crumb="${esc(id)}">${esc(NODE[id].tag)}</button>›` : `<span>${esc(NODE[id].tag)}</span>`).join('');
1009
+ const kick = n.level === 1 ? esc(LAYER[n.m.layer].name) : `${KIND[n.kind].label} · inside ${esc(n.parent.name)}`;
1010
+ let h = `<nav class="crumbs"><button type="button" data-act="root">${WHOLE}</button>›${crumbs}</nav>
1011
+ <div class="head"><span class="k">${kick}</span><h2>${esc(n.name)}</h2>${n.tip ? `<p class="lead">${esc(n.tip)}</p>` : ''}${n.what && n.what !== n.tip ? `<p>${esc(n.what)}</p>` : ''}${n.level === 1 && n.m.count ? `<span class="chip">${esc(n.m.count)}</span>` : ''}</div>`;
1012
+ const cc = k => { if (st.branch && isCh(k)) return k.item.code.files ? `${k.item.code.files.filter(f => CHF.has(f[0])).length} changed` : pieceCh(k.item.code); return openable(k) ? `▸ ${k.kids.length} inside` : (n.level === 2 && k.item.sk ? KL[k.item.sk] : KIND[k.kind].label); };
1013
+ h += `<section><span class="k">Inside · ${n.kids.length} ${n.level === 1 ? 'parts' : REPO ? 'symbols' : 'pieces'}</span>${n.kids.map(k => row(`data-n="${esc(k.id)}"`, num(k.idx), k.name, cc(k), k.what, k.id === st.sel, openable(k) || (st.branch && isCh(k)))).join('')}</section>`;
1014
+ h += `<div id="az-code">${codeFor()}</div>`;
1015
+ if (n.links.length) h += `<section><span class="k">Connections inside</span>${n.links.map(l => { const L = (n.level === 1 ? n.m.links : n.item.links || []).find(x => x.from === l.a.item.id && x.to === l.b.item.id); return row(`data-n="${esc(l.a.id)}" data-link="1"`, '→', `${l.a.tag} → ${l.b.tag}`, L && L.n ? plural(L.n, 'call') : '', l.label, false) + sites(L && L.sites); }).join('')}</section>`;
1016
+ if (n.level === 1) {
1017
+ const outs = AZ.flows.filter(f => f.from === n.id && MOD[f.to] && f.to !== 'dock'), inn = AZ.flows.filter(f => f.to === n.id && MOD[f.from]), db = AZ.flows.filter(f => f.from === n.id && f.to === 'dock');
1018
+ if (outs.length + inn.length) h += `<section><span class="k">Connections to other blocks</span>${outs.map(f => row(`data-m="${f.to}"`, '→', MOD[f.to].name, `${f.kind}${f.n ? ` · ${f.n}` : ''}`, f.label) + sites(f.sites)).join('')}${inn.map(f => row(`data-m="${f.from}"`, '←', MOD[f.from].name, `${f.kind}${f.n ? ` · ${f.n}` : ''}`, f.label) + sites(f.sites)).join('')}</section>`;
1019
+ if (db.length) h += `<section><span class="k">${esc(RLC)}</span>${db.map(f => row(`data-m="P${f.ports[0]}"`, String(f.ports[0]).padStart(2, '0'), GROUP[f.ports[0]].name, `${f.kind}${f.n ? ` · ${f.n}` : ''}`, f.label.split(': ')[1] || '')).join('')}</section>`;
1020
+ }
1021
+ h += openIn();
1022
+ h += `<div class="keys"><b>↑ ↓</b> ${n.level === 1 ? 'parts' : 'symbols'} · <b>enter</b> opens a part with ▸ · <b>i</b> explains · <b>← →</b> next ${n.level === 1 ? 'block' : 'part'} · <b>esc</b> one level up · <b>drag</b> to look around</div>`;
1023
+ ins.innerHTML = h;
1024
+ return;
1025
+ }
1026
+ if (J) {
1027
+ const jj = AZ.journeys[J.k];
1028
+ ins.innerHTML = `<button type="button" class="back" data-act="stop">← ${WHOLE}</button>
1029
+ <div class="head"><span class="k">Walkthrough <b>${J.k + 1}</b> of ${AZ.journeys.length}${jj.group ? ` · ${esc(jj.group)}` : ''}</span><h2>${esc(jj.name)}</h2><p>${esc(jj.caption)}</p></div>
1030
+ <section><span class="k">Steps · click one to open it</span>${jj.steps.map((s, i) => { const c = (jj.code || [])[i];
1031
+ return row(`data-m="${s}"${c && c.sel ? ` data-sel="${esc(c.pc || c.sel)}"` : ''}`, num(i), c && c.sym && !c.db ? c.sym : stepName(s), isPort(s) ? RLC : MOD[s].short, (jj.notes || [])[i] || '', i === J.i) + (c && c.p ? `<div class="step-src">${(c.db ? dataLink(c.p, c.l, loc(c.p, c.l), c.m, c.st, c.res) : aSrc(c.p, c.l, c.e, loc(c.p, c.l), 'symbol', c.sym))}</div>` : ''); }).join('')}</section>
1032
+ ${openIn()}<div class="keys"><b>space</b> next walkthrough · <b>esc</b> stop</div>`;
1033
+ return;
1034
+ }
1035
+ if (st.branch) {
1036
+ const B = CUR_C, sel = st.cmpWant || (B ? { base: B.base, head: B.head } : { base: CM.bases[0], head: (CM.heads[0] || [])[0] });
1037
+ const ctl = `<div class="cmpctl">
1038
+ <label><span class="k">Base</span><select data-cmp="base">${CM.bases.map(b => `<option value="${esc(b)}"${b === sel.base ? ' selected' : ''}>${esc(lab(b))}</option>`).join('')}</select></label>
1039
+ <button type="button" class="swap" data-act="swap" title="Swap the two branches" aria-label="Swap the two branches"${CM.bases.includes(sel.head) ? '' : ' disabled'}>⇄</button>
1040
+ <label><span class="k">Compare</span><select data-cmp="head">${CM.heads.map(([n, l, , c]) => `<option value="${esc(n)}"${n === sel.head ? ' selected' : ''}>${esc(l)}${c ? ' · checked out' : ''}</option>`).join('')}</select></label></div>`;
1041
+ if (!B || st.cmpWant) {
1042
+ const same = sel.base === sel.head;
1043
+ ins.innerHTML = `<button type="button" class="back" data-act="branch">← Leave the branch view</button>
1044
+ <div class="head"><span class="k">Compare branches</span>${ctl}<h2>${same ? 'Pick two different branches' : 'This pair is not in the build'}</h2>
1045
+ ${same ? '' : `<p>This page carries the ${CM.list.length} comparisons made when it was built. To add ${esc(lab(sel.base))} → ${esc(lab(sel.head))}, build it again with:</p><pre class="sig">archivision repo ${esc(REPO.root)} --compare ${esc(sel.base)}..${esc(sel.head)}</pre>`}</div>`;
1046
+ return;
1047
+ }
1048
+ const day = new Date(B.t * 1000).toISOString().slice(0, 10);
1049
+ ins.innerHTML = `<button type="button" class="back" data-act="branch">← Leave the branch view</button>
1050
+ <div class="head"><span class="k">Compare branches</span>${ctl}
1051
+ <h2>${B.f.length ? `${plural(B.f.length, 'file')} changed` : 'Nothing new'}</h2>
1052
+ <p class="meta">${plural(B.ahead, 'commit')} on ${esc(B.hl)} that ${esc(B.bl)} does not have · ${plural(B.behind, 'commit')} the other way · last commit ${day}</p>
1053
+ ${B.inc ? `<p class="meta"><b class="gap">${esc(B.inc)}</b></p>` : ''}
1054
+ ${B.f.length ? `<p>${B.src} of them are code in ${esc(ROOTS_TXT)}, in ${plural(B.mods.length, 'block')}, touching ${plural(B.nsym, 'top-level symbol')}. Lit blocks hold the changes; dashed arcs come from blocks that call the changed code. Open a block to see which parts and functions changed.</p>` : `<p>${esc(B.hl)} has no changes that ${esc(B.bl)} lacks.</p>`}
1055
+ ${REPO.remote ? `<a class="src" href="${esc(`${REPO.remote}/compare/${encodeURIComponent(B.bl)}...${encodeURIComponent(B.hl)}`)}" target="_blank" rel="noopener" data-kind="compare" data-base="${esc(B.bl)}" data-head="${esc(B.hl)}"><span>Compare ${esc(B.bl)}…${esc(B.hl)} on GitHub</span><i aria-hidden="true">↗</i></a>` : ''}</div>
1056
+ ${B.mods.length ? `<section><span class="k">Blocks with changes</span>${B.mods.slice().sort((a, b) => b[1] - a[1]).map(([m, nf, ps]) => MOD[m] ? row(`data-m="${m}"`, '●', MOD[m].name, plural(nf, 'file'), ps.map(p => (MOD[m].parts.find(x => x.id === p) || {}).name).filter(Boolean).join(', '), false, true) : '').join('')}</section>` : ''}
1057
+ ${Object.keys(B.blast).length ? `<section><span class="k">Who calls the changed code</span>${Object.entries(B.blast).filter(([b]) => MOD[b]).map(([b, l]) => row(`data-m="${b}"`, '←', MOD[b].name, '', `called from ${l.slice(0, 4).map(([a, k]) => `${MOD[a] ? MOD[a].short : a} (${k})`).join(', ')}${l.length > 4 ? ` +${l.length - 4}` : ''}`)).join('')}</section>` : ''}
1058
+ ${B.db.length ? `<section><span class="k">Database groups with changed models</span>${B.db.map(n => row(`data-m="P${n}"`, String(n).padStart(2, '0'), GROUP[n].name, `${GROUP[n].models.filter(m => B.models.includes(m)).length} changed`, B.models.filter(m => GROUP[n].models.includes(m)).join(', '))).join('')}</section>` : ''}
1059
+ ${B.other.length ? `<section class="code"><span class="k">Outside the mapped code · ${B.other.length}</span>${more(B.other, 6, ([x, s]) => aDiff(PATHS[x], s), 'files')}</section>` : ''}
1060
+ <div class="keys"><b>b</b> or <b>esc</b> leaves the branch view · ${keysTxt}</div>`;
1061
+ return;
1062
+ }
1063
+ const T = AZ.totals || {};
1064
+ ins.innerHTML = (REPO ? `<div class="head"><span class="k">Archivision · repo</span><h2>${esc(REPO.name)}</h2><p>${esc(AZ.intro)}</p>
1065
+ <p class="meta">${T.files.toLocaleString('en-US')} files · ${T.lines.toLocaleString('en-US')} lines · ${T.symbols.toLocaleString('en-US')} symbols${T.models ? ` · ${T.models} ${esc(STORE.label)} ${esc(STORE.what)}` : ''}<br>read from <b>${esc(REPO.branch)}</b> at ${esc(REPO.sha.slice(0, 7))} · ${esc(REPO.scanned.slice(0, 10))}</p>
1066
+ <button type="button" class="branch-b" data-act="branch"><span>Compare branches</span><em>${CUR_C ? `${esc(CUR_C.hl)} vs ${esc(CUR_C.bl)}: ${plural(CUR_C.f.length, 'file')} changed` : 'no comparisons in this build'}</em></button>`
1067
+ : `<div class="head"><span class="k">Archivision · architecture</span><h2>${esc(AZ.title)}</h2><p>${esc(AZ.intro)}</p>
1068
+ <p class="meta">${plural(T.blocks, 'block')} · ${plural(T.parts, 'part')} · ${plural(T.pieces, 'piece')}${T.ports ? ` · ${plural(T.ports, 'port')}` : ''}${AZ.source ? `<br>${esc(AZ.source)}` : ''}</p>`) + `
1069
+ <p>Drag to turn the model, scroll to zoom, right-drag to pan.</p></div>
1070
+ ${[...new Set(AZ.journeys.map(j => j.group || 'Walkthroughs'))].map(g => `<section><span class="k">Walkthroughs · ${esc(g)}</span>${AZ.journeys.map((j, i) => (j.group || 'Walkthroughs') !== g ? '' : row(`data-j="${i}"`, String(i + 1), j.name, `${j.steps.length} steps${j.status === 'unresolved' ? ' · not traced' : j.status && j.status !== 'exact' ? ' · inferred' : ''}`, '')).join('')}</section>`).join('')}
1071
+ ${AZ.layers.map(l => { const ms = LIST.filter(m => m.layer === l.id); return ms.length ? `<section><span class="k">${esc(l.name)}</span>${ms.map(m => { const deep = m.id === 'dock' ? 0 : NODE[m.id].kids.filter(openable).length; return row(`data-m="${m.id}"`, num(LIST.indexOf(m)), m.name, m.id === 'dock' ? `${AZ.groups.length} ${ARCH ? 'ports' : 'groups'}` : `${m.parts.length} parts${deep ? ` · ${deep} ▸` : ''}`, '', m.id === st.focus); }).join('')}</section>` : ''; }).join('')}
1072
+ ${coverage()}${openIn()}<div class="keys">${keysTxt}</div>`;
1073
+ }
1074
+ // what the map covers and leaves out: everything not read, not resolved or not placed is counted (repo pages)
1075
+ function coverage() {
1076
+ const C = AZ.coverage; if (!REPO || !C) return '';
1077
+ return `<section class="code coverage"><span class="k">Coverage</span>${C.lines.map(l => `<p class="meta">${esc(l[0].toUpperCase() + l.slice(1))}.</p>`).join('')}</section>`;
1078
+ }
1079
+ function setBranch(on) {
1080
+ st.branch = on && !ARCH; stopJourney(true); hideTip(true);
1081
+ if (!st.path.length) showLinks(null);
1082
+ refresh(); ins.scrollTop = 0;
1083
+ }
1084
+ ins.addEventListener('click', e => {
1085
+ if (e.target.closest('a')) { st.touched = true; return; }
1086
+ const b = e.target.closest('button'); if (!b) return;
1087
+ st.touched = true;
1088
+ if (b.dataset.act === 'root') { closeAll(); }
1089
+ else if (b.dataset.act === 'stop') stopJourney();
1090
+ else if (b.dataset.act === 'branch') setBranch(!st.branch);
1091
+ else if (b.dataset.act === 'swap') { const bs = ins.querySelector('select[data-cmp="base"]'), hs = ins.querySelector('select[data-cmp="head"]'); if (bs && hs) pickCmp(hs.value, bs.value); }
1092
+ else if (b.dataset.open) { PREF.open = b.dataset.open; savePref(); const y = ins.scrollTop; panel(); ins.scrollTop = y; }
1093
+ else if (b.dataset.copy) { copyContext(b.dataset.copy); return; }
1094
+ else if (b.dataset.port) openPort(+b.dataset.port);
1095
+ else if (b.dataset.nCrumb) { while (st.path.length && top() !== b.dataset.nCrumb) back1(); }
1096
+ else if (b.dataset.dock !== undefined) { st.dockSel = +b.dataset.dock; say(); renderCode(); ins.querySelectorAll('.row[data-dock]').forEach(r => r.classList.toggle('on', r === b)); }
1097
+ else if (b.dataset.n) { const n = NODE[b.dataset.n]; if (openable(n) && !b.dataset.link) open(n.id); else select(n.id); }
1098
+ else if (b.dataset.m) { if (isPort(b.dataset.m)) openPort(+b.dataset.m.slice(1)); else { const sel = b.dataset.sel && NODE[b.dataset.sel]; if (sel && sel.level === 3) { open(sel.parent.id, sel.id); } else open(b.dataset.m, sel ? sel.id : undefined); } }
1099
+ else if (b.dataset.j !== undefined) playJourney(+b.dataset.j);
1100
+ b.blur();
1101
+ });
1102
+ ins.addEventListener('change', e => { if (e.target.matches('select[data-cmp]')) { st.touched = true; pickCmp(ins.querySelector('select[data-cmp="base"]').value, ins.querySelector('select[data-cmp="head"]').value); return; } if (e.target.matches('input[data-root]')) { PREF.root = e.target.value.trim() || REPO.root; savePref(); const y = ins.scrollTop; panel(); ins.scrollTop = y; } });
1103
+ ins.addEventListener('keydown', e => { if (e.target.matches('input, textarea, select')) e.stopPropagation(); }, true);
1104
+ ins.addEventListener('pointerover', e => {
1105
+ const b = e.target.closest('button.row'); if (!b) return;
1106
+ if (b.dataset.n && top()) select(b.dataset.n);
1107
+ else if (b.dataset.dock !== undefined && top() === 'dock' && +b.dataset.dock !== st.dockSel) { st.dockSel = +b.dataset.dock; say(); renderCode(); ins.querySelectorAll('.row[data-dock]').forEach(r => r.classList.toggle('on', r === b)); }
1108
+ else if (!top() && !st.jour && b.dataset.m && b.dataset.m !== st.hover && MOD[b.dataset.m] && b.dataset.m !== 'dock') { st.hover = b.dataset.m; showLinks(st.hover); say(); }
1109
+ });
1110
+ ins.addEventListener('pointerleave', () => { if (!top() && !st.jour && st.hover) { st.hover = null; showLinks(null); say(); } });
1111
+
1112
+ /* ---------- pointer on the model ---------- */
1113
+ const RAY = new THREE.Raycaster(), NDC = new THREE.Vector2();
1114
+ function pickable(o) {
1115
+ for (let x = o; x; x = x.parent) if (!x.visible) return false;
1116
+ const ud = o.userData;
1117
+ if (ud.cover && ud.node) { const pr = prog[ud.node.id]; if (pr && pr.p > .22) return false; if (ud.node.level > 1 && VIS[ud.node.id].q < .5) return false; }
1118
+ return true;
1119
+ }
1120
+ let PICKV = null, PICKF = -1, FRAME = 0;
1121
+ const pickList = () => { if (PICKF !== FRAME) { PICKF = FRAME; PICKV = PICK.filter(o => { for (let x = o; x; x = x.parent) if (!x.visible) return false; return true; }); } return PICKV; };
1122
+ function pickAt(cx, cy) {
1123
+ NDC.set(cx / innerWidth * 2 - 1, -(cy / innerHeight) * 2 + 1); RAY.setFromCamera(NDC, camera);
1124
+ for (const h of RAY.intersectObjects(pickList(), false)) if (pickable(h.object)) return h.object.userData;
1125
+ return null;
1126
+ }
1127
+ function onClick(ud) {
1128
+ st.touched = true;
1129
+ if (tip && tip.pinned) hideTip(true);
1130
+ if (!ud || ud.plate) { if (st.path.length) back1(); else if (st.jour) stopJourney(); return; }
1131
+ if (ud.port) return openPort(ud.port);
1132
+ if (ud.rail) return open('dock');
1133
+ const n = ud.node, t = top();
1134
+ if (ud.floor) { if (n.id !== t) while (st.path.length && top() !== n.id) back1(); return; }
1135
+ if (n.level === 1) { if (n.id !== st.path[0]) open(n.id); return; }
1136
+ if (n.parent && n.parent.id === t) return openable(n) ? open(n.id) : select(n.id);
1137
+ if (n.parent && st.path.includes(n.parent.id)) return openable(n) ? open(n.id) : (back1(), select(n.id));
1138
+ }
1139
+ function onHover(ud) {
1140
+ const cv = renderer.domElement;
1141
+ cv.style.cursor = ud && (ud.node || ud.port || ud.rail) && !(ud.floor && ud.node.id === top()) ? 'pointer' : '';
1142
+ if (st.path.length) { if (!st.jour && ud && ud.cover && ud.node.parent && ud.node.parent.id === top()) select(ud.node.id); return; }
1143
+ const hp = ud && ud.port || null, id = ud && ud.node && ud.node.level === 1 ? ud.node.id : null;
1144
+ if (hp !== st.hoverPort || id !== st.hover) { st.hoverPort = hp; st.hover = id; showLinks(id, hp); say(); }
1145
+ }
1146
+ let HP = null, hoverDirty = false, pressed = false;
1147
+ const cvs = renderer.domElement;
1148
+ cvs.addEventListener('pointermove', e => { HP = [e.clientX, e.clientY]; hoverDirty = true; });
1149
+ cvs.addEventListener('pointerleave', () => { HP = null; hoverDirty = true; });
1150
+ cvs.addEventListener('pointerdown', () => { pressed = true; st.touched = true; cancelCam(); });
1151
+ addEventListener('pointerup', () => { pressed = false; });
1152
+ cvs.addEventListener('wheel', () => { st.touched = true; cancelCam(); }, { passive: true });
1153
+ addEventListener('resize', () => { if (inited) { layoutView(); } });
1154
+
1155
+ // pins follow the camera after the controls have moved it, just before the frame is drawn
1156
+ const _render = composer.render.bind(composer);
1157
+ composer.render = (...a) => { if (active === 'az' && inited) { camera.updateMatrixWorld(); placePins(); } return _render(...a); };
1158
+
1159
+ themeHooks.push(() => {
1160
+ palette(); LINK0.copy(outlineMat.uniforms.color.value).lerp(INKHI, .3);
1161
+ faintMat.color.set(PALC.ink); beadMat.color.copy(accent);
1162
+ for (const m of LIST) if (VIS[m.id]) VIS[m.id].haloMat.uniforms.color.value.copy(accent);
1163
+ TXT.forEach(t => t.redraw());
1164
+ });
1165
+
1166
+ /* ---------- keys ---------- */
1167
+ const move = d => {
1168
+ const t = top();
1169
+ if (t && NODE[t] && NODE[t].level === 2) {
1170
+ const sib = NODE[t].parent.kids.filter(openable), i = sib.indexOf(NODE[t]);
1171
+ if (sib.length > 1) open(sib[(i + d + sib.length) % sib.length].id);
1172
+ return;
1173
+ }
1174
+ const cur = st.path[0] || st.focus, i = cur ? LIST.findIndex(m => m.id === cur) : (d > 0 ? -1 : 0), nx = LIST[(i + d + LIST.length) % LIST.length].id;
1175
+ if (st.path.length) open(nx); else { st.focus = nx; showLinks(nx === 'dock' ? null : nx); refresh(); }
1176
+ };
1177
+ const cycle = d => {
1178
+ const t = top();
1179
+ if (t === 'dock') { st.dockSel = (st.dockSel + d + DOCKSETS.length) % DOCKSETS.length; panel(); say(); return; }
1180
+ const ks = kidsOfTop(); if (!ks.length) return;
1181
+ const i = ks.findIndex(k => k.id === st.sel); select(ks[(i + d + ks.length) % ks.length].id);
1182
+ };
1183
+ const explain = () => {
1184
+ const id = st.path.length ? (st.sel || top()) : (st.focus || st.hover);
1185
+ const want = id && (PINS.has(id) ? id : null);
1186
+ if (!want) return;
1187
+ if (tip && tip.id === want) hideTip(true); else { hideTip(true); showTip(want, true); }
1188
+ };
1189
+ function key(e) {
1190
+ st.touched = true;
1191
+ if (e.key === 'Escape') { if (tip && tip.pinned) hideTip(true); else if (st.jour) { const o = J_OPENED; stopJourney(); if (o || st.path.length) { closeAll(); frameBox(BOX0, 900); } } else if (st.path.length) back1(); else if (st.branch) setBranch(false); else { st.focus = null; showLinks(null); refresh(); frameBox(BOX0, 900); } return true; }
1192
+ if (!ARCH && (e.key === 'b' || e.key === 'B') && !e.metaKey && !e.ctrlKey) { setBranch(!st.branch); return true; }
1193
+ if (e.key === ' ') { if (!e.repeat) playJourney(st.jour ? (st.jour.k + 1) % AZ.journeys.length : 0); return true; }
1194
+ if (/^[1-9]$/.test(e.key) && +e.key <= AZ.journeys.length) { playJourney(+e.key - 1); return true; }
1195
+ if (e.key === 'i' || e.key === 'I') { explain(); return true; }
1196
+ if (e.key === 'ArrowRight') { move(1); return true; }
1197
+ if (e.key === 'ArrowLeft') { move(-1); return true; }
1198
+ if (st.path.length && e.key === 'ArrowDown') { cycle(1); return true; }
1199
+ if (st.path.length && e.key === 'ArrowUp') { cycle(-1); return true; }
1200
+ if (e.key === 'Enter') {
1201
+ if (!st.path.length && st.focus) { st.focus === 'dock' ? open('dock') : open(st.focus); return true; }
1202
+ const s = st.sel && NODE[st.sel]; if (s && openable(s) && s.parent && s.parent.id === top()) { open(s.id); return true; }
1203
+ }
1204
+ return false;
1205
+ }
1206
+
1207
+ /* ---------- start: fly in to the whole plate, then a short tour unless the viewer takes over ---------- */
1208
+ let inited = false;
1209
+ function demo() { // open company state, explain a part, go one level deeper
1210
+ const cs = NODE[SCENE.demoOpen]; if (!cs) return;
1211
+ st.touched = false;
1212
+ open(cs.id);
1213
+ after(2100, () => { if (st.touched) return; const k = cs.kids.find(openable) || cs.kids[0]; select(k.id); showTip(k.id, false); });
1214
+ after(5000, () => { if (st.touched) return; const k = cs.kids.find(openable); hideTip(true); if (k) open(k.id); });
1215
+ after(7200, () => { if (st.touched) return; const t = NODE[top()]; if (t && t.kids[1]) { select(t.kids[1].id); showTip(t.kids[1].id, false); } });
1216
+ }
1217
+ function init() {
1218
+ inited = true;
1219
+ layoutView();
1220
+ LINK0.copy(outlineMat.uniforms.color.value).lerp(INKHI, .3);
1221
+ const { c, D } = fitFor(BOX0, DIR_ISO);
1222
+ controls.minDistance = .35; controls.maxDistance = Math.max(90, D * 2.6); // a big repo on a narrow screen needs the camera further out
1223
+ if (STILL || REDUCE) frameBox(BOX0, 0);
1224
+ else {
1225
+ controls.target.copy(c); camera.position.copy(c).add(new THREE.Vector3(-.35, .8, 1).normalize().multiplyScalar(D * 2.3));
1226
+ frameBox(BOX0, 2200);
1227
+ after(2600, () => { if (!st.touched) demo(); });
1228
+ }
1229
+ refresh();
1230
+ }
1231
+
1232
+ window.__az3 = { NODE, VIS, st, prog, TXT, PINS, PICK, open, back1, select, pickAt, onClick, playJourney, frameBox, boxFor, FREE, tipEl: () => tipEl, tip: () => tip, ins, key, cam: () => camera, W, TS, BOX0, AZ, href, PREF, setBranch, codeFor, flowsG, PORTV, SCENE, pickCmp, cmp: () => CUR_C, CM };
1233
+
1234
+ return {
1235
+ group: g0, readout: say, demo: () => { st.touched = false; demo(); }, stop: () => {},
1236
+ key,
1237
+ click: (hit, e) => { if (e) onClick(pickAt(e.clientX, e.clientY)); },
1238
+ tick: (now, dt) => {
1239
+ NOW = now; FRAME++; if (!inited) init();
1240
+ runTimers(); camStep();
1241
+ HOTL = []; update(dt);
1242
+ if (hoverDirty && !pressed) { hoverDirty = false; onHover(HP ? pickAt(HP[0], HP[1]) : null); }
1243
+ },
1244
+ panel: [[WHOLE, 'esc', () => { st.touched = true; stopJourney(); closeAll(); frameBox(BOX0, 900); }],
1245
+ ['Next walkthrough', 'space', () => { st.touched = true; playJourney(st.jour ? (st.jour.k + 1) % AZ.journeys.length : 0); }],
1246
+ ...(ARCH ? [] : [['Branches', 'b', () => { st.touched = true; setBranch(!st.branch); }]]),
1247
+ ['Explain', 'i', () => { st.touched = true; explain(); }],
1248
+ ['Tour', '▶', () => { stopJourney(true); closeAll(); demo(); }]],
1249
+ view: { target: W(PX / 2, PY / 2, 0), dist: 34 },
1250
+ info: `<h2>${esc(REPO ? REPO.name : AZ.title)}</h2><p>Click a block to open it.</p>`,
1251
+ hint: `click a block · ⓘ explains${ARCH ? '' : ' · b shows this branch'} · space plays a walkthrough · drag to orbit`,
1252
+ };
1253
+ })() });