PageSourceSearch

https://ceg.rent/hero-reel/signal/concept.js?v=1790421219

js ceg.rent collected 2026-10-10 00:25:12 UTC 151,847 bytes, 3,198 lines download raw bytes

1/*
2 * CEG hero reel — Concept 1 "Signal": a kinetic, broadcast-style brand film.
3 *
4 * Hard colour-block cuts (black / orange / cream), heavy Space Grotesk kinetic type that slams in with smear and
5 * overshoot, a HUD frame throughout, and the CEG hexagon as the recurring motif (ignition ring, hex grids, hex-cropped
6 * product plates, the lockup). Every product, image, count and sub-category comes from ctx.data (the live catalogue).
7 *
8 * Harness hook: when the reel root carries data-signal-dock="<selector>", the element it points at (the homepage's
9 * search/H1 panel) is measured on resize and landscape compositions keep their focal action clear of it.
10 *
11 * render(ctx, t) is a pure function of t: all motion is derived from t; the only async state is image readiness.
12 */
13(function () {
14    'use strict';
15
16    const Reel = window.CegReel;
17    if (!Reel || !Reel.util || !Reel.brand) {
18        return;
19    }
20
21    const U = Reel.util;
22    const { clamp, lerp, progress, hash, noise, h, apply } = U;
23    const E = U.ease;
24    const brand = Reel.brand;
25
26    /* ------------------------------------------------------------------ constants */
27
28    const DURATION = 66;
29    const DEG = Math.PI / 180;
30    const SQ3 = Math.sqrt(3);
31    const NS = 'http://www.w3.org/2000/svg';
32    const DISPLAY = "'Space Grotesk', system-ui, sans-serif";
33    const COL = { black: '#202020', orange: '#FFAC46', dark: '#FF8C00', cream: '#FFE8D3', white: '#FFFFFF', amber: '#B45309' };
34    const RGB = { orange: '255,172,70', cream: '255,232,211', black: '32,32,32', white: '255,255,255', dark: '255,140,0' };
35    const TONES = {
36        dark: { bg: COL.black },
37        orange: { bg: COL.orange },
38        cream: { bg: COL.cream },
39        white: { bg: COL.white },
40    };
41    const SERVICE_TONES = ['cream', 'dark', 'orange', 'white', 'dark', 'cream'];
42    const SERVICE_INK = { cream: RGB.black, dark: RGB.cream, orange: RGB.black, white: RGB.black };
43    const CATEGORY_KEYS = ['lighting', 'rigging', 'power', 'effects'];
44    const HEX_POINTS = '43.3,1 85.6,25.5 85.6,74.5 43.3,99 1,74.5 1,25.5';
45    const LOGO_CX = 30.52;
46    const LOGO_CY = 35.16;
47
48    const SCENES = [
49        { id: 'ignition', label: 'Ignition', start: 0, end: 5 },
50        { id: 'statement', label: 'Statement', start: 5, end: 10 },
51        { id: 'lighting', label: 'Lighting', start: 10, end: 17, category: 'lighting' },
52        { id: 'rigging', label: 'Rigging', start: 17, end: 21.5, category: 'rigging' },
53        { id: 'power', label: 'Power', start: 21.5, end: 27.5, category: 'power' },
54        { id: 'effects', label: 'Effects', start: 27.5, end: 33, category: 'effects' },
55        { id: 'service', label: 'Service', start: 33, end: 53 },
56        { id: 'account', label: 'My Account', start: 53, end: 60 },
57        { id: 'lockup', label: 'Lockup', start: 60, end: DURATION, design: 6 },
58    ];
59    const SC = Object.fromEntries(SCENES.map((scene) => [scene.id, scene]));
60    /*
61     * Social Studio templates: every chapter plus each service beat, as self-contained clips. Ranges end on the
62     * scene's own hold (before its exit wipe) so a clip never finishes on the next chapter's colour.
63     */
64    const TEMPLATES = [
65        { id: 'ignition', label: 'Ignition — logo reveal', description: 'The orange dot ignites the CEG hexagon: starburst, instrument ring and orbiting strapline', range: [0, 4.1], still: 3.4, picks: null },
66        { id: 'statement', label: 'Statement — Rental Excellence', description: 'RENTAL EXCELLENCE, MADE SIMPLE. slams in on orange, with the strapline and the four live categories', range: [5, 9.25], still: 8.6, picks: null },
67        { id: 'lighting', label: 'Lighting — category slam', description: 'LIGHTING. with live products, sub-categories and the live product count', range: [10, 16.3], still: 13, picks: { category: 'lighting', products: 6 } },
68        { id: 'rigging', label: 'Rigging — honeycomb callouts', description: 'RIGGING. drops in; products lock into a hexagon column with callouts', range: [17, 20.15], still: 19.5, picks: { category: 'rigging', products: 3 } },
69        { id: 'power', label: 'Power — live current', description: 'POWER. flickers on over a hex lattice carrying current; product cards ride a conveyor', range: [21.5, 26.8], still: 24.6, picks: { category: 'power', products: 5 } },
70        { id: 'effects', label: 'Effects — haze and beams', description: 'EFFECTS. condenses out of haze and sweeping beams; products surface from the haze', range: [27.5, 32.4], still: 30.9, picks: { category: 'effects', products: 3 } },
71        { id: 'service-delivery', label: 'Service — UK-wide delivery', description: 'Great Britain map: routes fan out and lorries drive their runs', range: [33, 37], still: 36.6, picks: null },
72        { id: 'service-cleaned', label: 'Service — Deep cleaned & tested', description: 'A product is scanned, glints and is stamped TESTED', range: [37, 41], still: 40.4, picks: { category: 'lighting', products: 1 } },
73        { id: 'service-same-day', label: 'Service — Same day availability', description: 'The clock whips round and TODAY lands', range: [41, 43], still: 42.8, picks: null },
74        { id: 'service-support', label: 'Service — 24/7 tech support', description: 'The 24-hour ring closes round a headset that starts broadcasting', range: [43, 47], still: 46.4, picks: null },
75        { id: 'service-spares', label: 'Service — Free spares', description: 'The SPARES box opens and live products fan out of it', range: [47, 49], still: 48.8, picks: null },
76        { id: 'service-rates', label: 'Service — Trade rates', description: 'A price tag swings, flips to the trade label and gets its check', range: [49, 53], still: 52.4, picks: null },
77        { id: 'my-account', label: 'My Account — sign in and dashboard', description: 'Sign in, then the account dashboard assembles and its benefits tick off', range: [53, 59.3], still: 58.3, picks: null },
78        { id: 'lockup', label: 'Lockup — brand end card', description: 'Particles assemble the CEG mark with its orbit of brands, headline and live stats', range: [60, 64.35], still: 63.3, picks: null },
79    ];
80
81    const SV_START = SC.service.start;
82    const SV_END = SC.service.end;
83    /*
84     * Scenes are choreographed on their own "design" clock and played back onto their real slot, so a chapter can be
85     * tightened without re-timing every beat inside it.
86     */
87    const loc = (t, id) => {
88        const scene = SC[id];
89        const span = scene.end - scene.start;
90        return ((t - scene.start) * (scene.design || span)) / span;
91    };
92    /* The lockup is designed at 6s; its poster beat (fully typed stats) sits 3.3s in. */
93    const POSTER = SC.lockup.start + 3.3;
94    /* Relative screen time per service (the service chapter is shared out by weight). */
95    const SV_WEIGHT = { delivery: 2, cleaned: 2, 'same-day': 1, support: 2, spares: 1, rates: 2 };
96
97    let uid = 0;
98
99    /* ------------------------------------------------------------------ small helpers */
100
101    const r1 = (n) => Math.round(n * 10) / 10;
102    /** A size clamped to pixel bounds that grow with the layout's scale (1 on the site; larger on studio canvases). */
103    const sz = (L, v, lo, hi) => clamp(v, lo * L.s, hi * L.s);
104    const px = (n) => r1(n) + 'px';
105    const op = (v) => Math.round(clamp(v) * 1000) / 1000;
106
107    /** translate3d + optional centre offset, rotation, skew and (non-uniform) scale as one transform string. */
108    const pose = (x, y, o) => {
109        let s = `translate3d(${r1(x)}px,${r1(y)}px,0)`;
110        if (!o) return s;
111        if (o.center) s += ' translate(-50%,-50%)';
112        if (o.r) s += ` rotate(${r1(o.r)}deg)`;
113        if (o.sk) s += ` skewX(${r1(o.sk)}deg)`;
114        const k = o.s == null ? 1 : o.s;
115        const sx = k * (o.sx == null ? 1 : o.sx);
116        const sy = k * (o.sy == null ? 1 : o.sy);
117        if (sx !== 1 || sy !== 1) s += ` scale(${sx.toFixed(3)},${sy.toFixed(3)})`;
118        return s;
119    };
120
121    const setText = (el, text) => {
122        if (el.__sgText !== text) {
123            el.__sgText = text;
124            el.textContent = text;
125        }
126    };
127    const show = (el, on) => apply(el, { visibility: on ? 'inherit' : 'hidden' });
128    const toggle = (el, cls, on) => {
129        const key = '__sg_' + cls;
130        if (el[key] !== on) {
131            el[key] = on;
132            el.classList.toggle(cls, on);
133        }
134    };
135    const svgEl = (tag, attrs = {}) => {
136        const el = document.createElementNS(NS, tag);
137        for (const k in attrs) el.setAttribute(k, attrs[k]);
138        return el;
139    };
140
141    /** Numerical velocity (px/s) of a pure position function at t. */
142    const velocity = (fn, t) => (fn(t) - fn(t - 1 / 120)) * 120;
143
144    /** Stretch + shear for a fast horizontal move — the "smear" of a whip pan. */
145    const smear = (v, w) => ({ sx: 1 + Math.min(0.85, Math.abs(v) / (w * 7)), sk: clamp(-v / (w * 0.9), -1, 1) * 14 });
146
147    /** Directional ghost trail for text (text-shadow copies behind the motion vector). */
148    const trail = (vx, vy, rgb, F) => {
149        const speed = Math.hypot(vx, vy);
150        if (speed < 60) return 'none';
151        const len = Math.min(F * 0.75, speed * 0.02);
152        const dx = (-vx / speed) * len;
153        const dy = (-vy / speed) * len;
154        return `${r1(dx * 0.34)}px ${r1(dy * 0.34)}px 0 rgba(${rgb},.42),${r1(dx * 0.68)}px ${r1(dy * 0.68)}px 0 rgba(${rgb},.2),${r1(dx)}px ${r1(dy)}px 0 rgba(${rgb},.08)`;
155    };
156
157    const sceneIndex = (t) => {
158        for (let i = SCENES.length - 1; i >= 0; i--) if (t >= SCENES[i].start) return i;
159        return 0;
160    };
161
162    /* ------------------------------------------------------------------ text metrics */
163
164    const mctx = document.createElement('canvas').getContext('2d');
165    const METRIC = { capTop: 0.2, capH: 0.7 };
166
167    const measureEm = (text, weight = 700, ls = -0.04, family = DISPLAY) => {
168        mctx.font = `${weight} 100px ${family}`;
169        return mctx.measureText(text).width / 100 + ls * Array.from(text).length;
170    };
171
172    /** Cap-top offset inside a line-height:1 box — lets giant type be aligned by its visible cap height. */
173    const readMetrics = () => {
174        mctx.font = `700 100px ${DISPLAY}`;
175        const m = mctx.measureText('HEX');
176        if (m.fontBoundingBoxAscent && m.actualBoundingBoxAscent) {
177            const fa = m.fontBoundingBoxAscent / 100;
178            const fd = m.fontBoundingBoxDescent / 100;
179            const cap = m.actualBoundingBoxAscent / 100;
180            const base = (1 - fa - fd) / 2 + fa;
181            METRIC.capH = cap;
182            METRIC.capTop = base - cap;
183        }
184    };
185
186    const breakWords = (words, maxEm, weight, ls) => {
187        const lines = [];
188        let cur = '';
189        for (const word of words) {
190            const next = cur ? cur + ' ' + word : word;
191            if (!cur || measureEm(next, weight, ls) <= maxEm) cur = next;
192            else {
193                lines.push(cur);
194                cur = word;
195            }
196        }
197        if (cur) lines.push(cur);
198        return lines;
199    };
200
201    /** Largest font size (px) that sets `text` within maxW × maxH in at most maxLines lines. */
202    const fitBlock = (text, maxW, maxH, maxLines, { weight = 700, ls = -0.045, lh = 0.92, maxF = 400 } = {}) => {
203        const words = text.split(/\s+/).filter(Boolean);
204        let lo = 6;
205        let hi = Math.max(lo, Math.min(maxF, maxH / lh));
206        let best = { F: lo, lines: breakWords(words, maxW / lo, weight, ls) };
207        for (let i = 0; i < 18; i++) {
208            const F = (lo + hi) / 2;
209            const lines = breakWords(words, maxW / F, weight, ls);
210            const widest = Math.max(...lines.map((l) => measureEm(l, weight, ls)));
211            if (lines.length <= maxLines && lines.length * lh * F <= maxH && widest * F <= maxW) {
212                best = { F, lines };
213                lo = F;
214            } else {
215                hi = F;
216            }
217        }
218        return best;
219    };
220
221    /* ------------------------------------------------------------------ hexagon geometry */
222
223    /** Pointy-top hexagon (the logo's orientation) as a canvas sub-path. */
224    const hexPath = (g, cx, cy, r, rot = 0) => {
225        for (let k = 0; k < 6; k++) {
226            const a = (-90 + 60 * k + rot) * DEG;
227            const x = cx + r * Math.cos(a);
228            const y = cy + r * Math.sin(a);
229            if (k) g.lineTo(x, y);
230            else g.moveTo(x, y);
231        }
232        g.closePath();
233    };
234
235    /** Hexagon that morphs into a circle of the same radius as `round` → 1 (the iris that closes back into the dot). */
236    const irisPath = (g, cx, cy, r, rot, round) => {
237        if (round <= 0.001) {
238            hexPath(g, cx, cy, r, rot);
239            return;
240        }
241        const n = 72;
242        const c30 = Math.cos(30 * DEG);
243        for (let i = 0; i < n; i++) {
244            const a = (i / n) * 360;
245            const m = ((((a - rot + 30) % 60) + 60) % 60) - 30;
246            const rr = lerp((r * c30) / Math.cos(m * DEG), r, round);
247            const x = cx + rr * Math.cos(a * DEG);
248            const y = cy + rr * Math.sin(a * DEG);
249            if (i) g.lineTo(x, y);
250            else g.moveTo(x, y);
251        }
252        g.closePath();
253    };
254
255    /** Soft round sprite (drawn once) for glows, haze and sparks — cheaper than per-frame gradients. */
256    const makeSprite = (rgb, size = 128, falloff = 2) => {
257        const c = document.createElement('canvas');
258        c.width = c.height = size;
259        const g = c.getContext('2d');
260        const grad = g.createRadialGradient(size / 2, size / 2, 0, size / 2, size / 2, size / 2);
261        for (let i = 0; i <= 8; i++) {
262            const p = i / 8;
263            grad.addColorStop(p, `rgba(${rgb},${Math.pow(1 - p, falloff).toFixed(3)})`);
264        }
265        g.fillStyle = grad;
266        g.fillRect(0, 0, size, size);
267        return c;
268    };
269
270    const sprite = (g, spr, x, y, r, alpha) => {
271        if (alpha <= 0.002 || r <= 0.5) return;
272        g.globalAlpha = Math.min(1, alpha);
273        g.drawImage(spr, x - r, y - r, r * 2, r * 2);
274        g.globalAlpha = 1;
275    };
276
277    /* ------------------------------------------------------------------ data */
278
279    const prepareData = (raw, studio = false) => {
280        const d = raw || {};
281        const stats = Object.assign({ products: 0, subcategories: 0, brands: 0 }, d.stats || {});
282        const b = Object.assign({ name: 'CEG', headline: 'Rental Excellence, Made Simple', strapline: '', since: '' }, d.brand || {});
283        /* the studio shows staff picks in their own order — the site's featured product only leads on the homepage */
284        const featured = !studio && d.featured && d.featured.image && d.featured.name ? d.featured : null;
285        const cats = {};
286        CATEGORY_KEYS.forEach((key) => {
287            const c = (d.categories || []).find((x) => x && x.key === key) || {};
288            const title = c.title || key.charAt(0).toUpperCase() + key.slice(1);
289            const products = (c.products || [])
290                .filter((p) => p && p.image && p.name)
291                .map((p) => Object.assign({}, p, { featured: !!(featured && p.id === featured.id) }));
292            if (featured && String(featured.category || '').toLowerCase() === title.toLowerCase() && !products.some((p) => p.id === featured.id)) {
293                products.unshift(Object.assign({}, featured, { featured: true }));
294            }
295            cats[key] = {
296                key,
297                title,
298                url: c.url || '/hire/' + key,
299                count: Math.max(0, Number(c.count) || 0),
300                subs: (c.subcategories || []).filter((s) => s && s.title),
301                products,
302            };
303        });
304        const services = (d.services || []).filter((s) => s && (s.short || s.title));
305        if (!services.length && b.strapline) services.push({ key: 'brand', short: b.strapline, title: b.strapline });
306        const manufacturers = (d.manufacturers || []).filter(Boolean);
307        const brandNames = (d.brands || []).map((x) => x && x.name).filter(Boolean);
308        const account = d.account && typeof d.account === 'object' && (d.account.features || d.account.benefits) ? d.account : null;
309        return { stats, brand: b, cats, services, manufacturers: manufacturers.length ? manufacturers : brandNames, account };
310    };
311
312    /* ------------------------------------------------------------------ DOM builders */
313
314    const makeCharWord = (parent, text, cls = '') => {
315        const el = h('div', { class: 'sg-cw ' + cls });
316        const main = h('div', { class: 'sg-cw-main' });
317        const chars = U.splitChars(main, text, 'sg-ch');
318        el.appendChild(main);
319        parent.appendChild(el);
320        return { el, main, chars, text };
321    };
322
323    /** A word that can slide with a ghost trail and be revealed from an outline with a left→right fill wipe. */
324    const makeKinWord = (parent, text, outline) => {
325        const el = h('div', { class: 'sg-kw' });
326        const out = outline ? h('span', { class: 'sg-kw-o', text }) : null;
327        const fillInner = h('span', { class: 'sg-kw-fi', text });
328        const fill = h('span', { class: 'sg-kw-f' }, [fillInner]);
329        if (out) el.appendChild(out);
330        el.appendChild(fill);
331        parent.appendChild(el);
332        return { el, out, fill, fillInner, text, lay: null };
333    };
334
335    const makeRing = (parent, cls, text) => {
336        const id = 'sg-ring-' + ++uid;
337        const el = svgEl('svg', { viewBox: '-100 -100 200 200', class: cls, 'aria-hidden': 'true' });
338        const defs = svgEl('defs');
339        defs.appendChild(svgEl('path', { id, d: 'M0,-80A80,80 0 1,1 0,80A80,80 0 1,1 0,-80', fill: 'none' }));
340        const ticks = svgEl('circle', { r: '70', class: 'sg-ring-ticks' });
341        const txt = svgEl('text', { class: 'sg-ring-text' });
342        const tp = svgEl('textPath', { href: '#' + id, textLength: String(Math.round(2 * Math.PI * 80 - 3)), lengthAdjust: 'spacing' });
343        tp.textContent = text;
344        txt.appendChild(tp);
345        el.append(defs, ticks, txt);
346        parent.appendChild(el);
347        return { el, txt, text };
348    };
349
350    const makePlate = (parent, shape, key) => {
351        const img = h('img', { class: 'sg-pl-img', alt: '', decoding: 'async', draggable: 'false' });
352        const face = h('div', { class: 'sg-pl-face' }, [brand.icon(key, 'sg-pl-ph'), img]);
353        const back = svgEl('svg', { class: 'sg-pl-back', viewBox: '0 0 86.6 100', preserveAspectRatio: 'none', 'aria-hidden': 'true' });
354        back.appendChild(svgEl('polygon', { points: HEX_POINTS }));
355        const badge = h('div', { class: 'sg-pl-badge' });
356        const meta = h('div', { class: 'sg-pl-meta' });
357        const name = h('div', { class: 'sg-pl-name' });
358        const specs = h('div', { class: 'sg-pl-specs' });
359        const label = h('div', { class: 'sg-pl-label' }, [meta, name, specs]);
360        const el = h('div', { class: `sg-pl sg-pl--${shape}`, 'data-state': 'empty' }, [back, face, badge, label]);
361        parent.appendChild(el);
362        return { el, img, face, meta, name, specs, label, badge, product: null, pool: null, callout: null };
363    };
364
365    const productMeta = (p) =>
365 [p.brand, p.subcategory].filter(Boolean).join(' · ').toUpperCase();
366
367    const fillPlate = (pl, product) => {
368        pl.product = product;
369        pl.el.dataset.state = 'loading';
370        pl.img.removeAttribute('src');
371        setText(pl.name, product.name);
372        setText(pl.meta, productMeta(product));
373        pl.specs.textContent = '';
374        (product.specs || []).slice(0, 3).forEach((spec) => pl.specs.appendChild(h('span', { text: String(spec) })));
375        toggle(pl.el, 'is-featured', !!product.featured);
376        if (pl.callout) {
377            setText(pl.callout.name, product.name);
378            setText(pl.callout.meta, [product.subcategory, (product.specs || [])[0]].filter(Boolean).join(' · ').toUpperCase());
379        }
380    };
381
382    const take = (pool) => (pool.next < pool.list.length ? pool.list[pool.next++] : null);
383
384    const assignPlates = (plates, products) => {
385        const pool = { list: products, next: 0 };
386        plates.forEach((pl) => {
387            pl.pool = pool;
388            const p = take(pool);
389            if (p) fillPlate(pl, p);
390        });
391    };
392
393    /** Load plate images in priority order (a few at a time); a failed image hands the plate to the next product. */
394    const loadPlates = (S, plates) => {
395        const queue = plates.slice();
396        const loadOne = async (pl) => {
397            while (pl.product && !S.dead) {
398                const product = pl.product;
399                const img = await S.ctx.images.load(product.image);
400                if (S.dead || pl.product !== product) return;
401                if (img) {
402                    pl.img.src = product.image;
403                    pl.el.dataset.state = 'ready';
404                    return;
405                }
406                const next = take(pl.pool);
407                if (!next) {
408                    pl.product = null;
409                    pl.el.dataset.state = 'empty';
410                    return;
411                }
412                fillPlate(pl, next);
413            }
414        };
415        const worker = async () => {
416            while (queue.length && !S.dead) await loadOne(queue.shift());
417        };
418        const lanes = S.ctx.layout && S.ctx.layout.size === 'sm' ? 2 : 3;
419        for (let i = 0; i < lanes; i++) worker();
420    };
421
422    /* ------------------------------------------------------------------ scene builders */
423
424    const buildIgnition = (S) => {
425        const D = S.D;
426        const root = h('div', { class: 'sg-scene sg-ig' });
427        const parts = [D.brand.strapline, D.brand.since ? `Since ${D.brand.since}` : '', `${D.stats.products} products`, `${D.stats.brands} brands`].filter(Boolean);
428        const ring = makeRing(root, 'sg-ring', (parts.join(' · ') + ' · ').toUpperCase());
429        const logo = brand.logo({ ring: COL.cream, letters: COL.orange, className: 'sg-logo reel-logo' });
430        const partEls = {};
431        logo.querySelectorAll('[data-part]').forEach((p) => (partEls[p.dataset.part] = p));
432        root.appendChild(logo);
433        return { root, ring, logo, parts: partEls };
434    };
435
436    /**
437     * The statement is the headline ("RENTAL EXCELLENCE, / MADE SIMPLE.") — except beside the homepage dock, which
438     * already carries that H1; there the reel states the live catalogue size instead ("316 PRODUCTS. / 27 BRANDS.").
439     */
440    const buildStatement = (S) => {
441        const D = S.D;
442        const root = h('div', { class: 'sg-scene sg-st', 'data-tone': 'orange' });
443
444        const makeSet = (lines) => {
445            const words = [];
446            lines.forEach((line, li) => line.forEach((w) => words.push(Object.assign(makeKinWord(root, w, li > 0), { lineB: li > 0 }))));
447            return words;
448        };
449        let words = String(D.brand.headline || '').toUpperCase().split(/\s+/).filter(Boolean);
450        if (!words.length) words = [String(D.brand.name).toUpperCase()];
451        const last = words.length - 1;
452        if (!/[.!?]$/.test(words[last])) words[last] = words[last].replace(/,$/, '') + '.';
453        let split = words.findIndex((w) => /,$/.test(w)) + 1;
454        if (split <= 0 || split >= words.length) split = Math.max(1, Math.ceil(words.length / 2));
455        const sets = { headline: makeSet([words.slice(0, split), words.slice(split)]) };
456        if (D.stats.products > 0 && D.stats.brands > 0) {
457            sets.stats = makeSet([
458                [String(D.stats.products), 'PRODUCTS.'],
459                [String(D.stats.brands), 'BRANDS.'],
460            ]);
461        }
462
463        const strap = h('div', { class: 'sg-st-strap' });
464        const strapWords = U.splitWords(strap, D.brand.strapline || '', 'sg-st-sw');
465        root.appendChild(strap);
466
467        /* "coming up": the four category chapters with their live counts */
468        const toc = h('div', { class: 'sg-st-toc' });
469        const tocItems = CATEGORY_KEYS.map((key, i) => {
470            const cat = D.cats[key];
471            const el = h('div', { class: 'sg-st-ti' }, [h('i', { text: String(i + 3).padStart(2, '0') }), h('span', { text: cat.title.toUpperCase() }), h('b', { text: String(cat.count) })]);
472            toc.appendChild(el);
473            return el;
474        });
475        root.appendChild(toc);
476        return { root, sets, words: sets.headline, strap, strapWords, toc, tocItems };
477    };
478
479    const CATEGORY_CFG = {
480        lighting: { tone: 'dark', shape: 'hex', plates: 6, rows: true, ink: RGB.cream },
481        rigging: { tone: 'cream', shape: 'hex', plates: 3, callouts: true, ink: RGB.black },
482        power: { tone: 'orange', shape: 'card', plates: 5, ink: RGB.black },
483        effects: { tone: 'dark', shape: 'hex', plates: 3, ink: RGB.cream },
484    };
485
486    const buildCategory = (S, key) => {
487        const cat = S.D.cats[key];
488        const cfg = CATEGORY_CFG[key];
489        const root = h('div', { class: `sg-scene sg-cat sg-cat--${key}`, 'data-tone': cfg.tone });
490        const sc = { key, cat, cfg, root, lay: null };
491
492        if (cfg.rows) {
493            const rowsEl = h('div', { class: 'sg-rows' });
494            root.appendChild(rowsEl);
495            sc.rows = Array.from({ length: 9 }, (_, k) => {
496                const sub = cat.subs.length ? cat.subs[k % cat.subs.length] : null;
497                const unit = sub ? `${sub.title.toUpperCase()} ${sub.count}` : cat.title.toUpperCase();
498                const el = h('div', { class: 'sg-row' });
499                rowsEl.appendChild(el);
500                return { el, unit: unit + ' / ', reps: 0 };
501            });
502        }
503
504        sc.word = makeCharWord(root, cat.title.toUpperCase() + '.', 'sg-cw--' + key);
505        if (key === 'power') {
506            sc.slices = [0, 1].map((i) => {
507                const el = h('div', { class: `sg-slice sg-slice--${i ? 'b' : 't'}`, text: sc.word.text });
508                sc.word.el.appendChild(el);
509                return el;
510            });
511        }
512        sc.cntN = h('span', { class: 'sg-cnt-n', text: '0' });
513        sc.cntL = h('span', { class: 'sg-cnt-l', text: 'PRODUCTS' });
514        sc.cnt = h('div', { class: 'sg-cnt' }, [sc.cntN, sc.cntL]);
515        sc.word.el.appendChild(sc.cnt);
516
517        sc.ticker = { el: h('div', { class: 'sg-tk' }), track: h('div', { class: 'sg-tk-track' }), reps: 0 };
518        sc.ticker.el.appendChild(sc.ticker.track);
519        root.appendChild(sc.ticker.el);
520
521        const subs = cat.subs.slice(0, 6);
522        const max = Math.max(1, ...subs.map((s) => s.count || 0));
523        sc.info = { el: h('div', { class: 'sg-info' }), rows: [] };
524        sc.info.el.appendChild(h('div', { class: 'sg-info-h' }, [h('span', { text: `${cat.title.toUpperCase()} / SUB-CATEGORIES` }), h('span', { text: String(cat.subs.length).padStart(2, '0') })]));
525        sc.info.rows = subs.map((s) => {
526            const fill = h('i', { class: 'sg-info-fill' });
527            const row = h('div', { class: 'sg-info-r' }, [h('span', { class: 'sg-info-n', text: s.title }), h('span', { class: 'sg-info-c', text: String(s.count) }), h('span', { class: 'sg-info-b' }, [fill])]);
528            sc.info.el.appendChild(row);
529            return { row, fill, title: s.title, frac: (s.count || 0) / max };
530        });
531        root.appendChild(sc.info.el);
532
533        const plateParent = cfg.shape === 'card' ? (sc.conveyor = h('div', { class: 'sg-cv' })) : root;
534        if (sc.conveyor) root.appendChild(sc.conveyor);
535        sc.plates = Array.from({ length: cfg.plates }, () => makePlate(plateParent, cfg.shape, key));
536
537        if (cfg.callouts) {
538            sc.plates.forEach((pl, i) => {
539                pl.el.classList.add('sg-pl--bare');
540                setText(pl.badge, String((i % 3) + 1).padStart(2, '0'));
541                const name = h('div', { class: 'sg-co-name' });
542                const meta = h('div', { class: 'sg-co-meta' });
543                const el = h('div', { class: 'sg-co' }, [h('span', { class: 'sg-co-n', text: String((i % 3) + 1).padStart(2, '0') }), h('div', { class: 'sg-co-body' }, [name, meta])]);
544                root.appendChild(el);
545                pl.callout = { el, name, meta };
546            });
547        }
548
549        assignPlates(sc.plates, cat.products);
550        return sc;
551    };
552
553    const buildService = (S) => {
554        const root = h('div', { class: 'sg-scene sg-sv' });
555        const n = Math.min(6, S.D.services.length);
556        const lighting = S.D.cats.lighting.products;
557        const items = S.D.services.slice(0, n).map((sv, i) => {
558            const el = h('div', { class: 'sg-sv-item', 'data-tone': SERVICE_TONES[i] });
559            const block = h('div', { class: 'sg-sv-block' });
560            const cap = h('div', { class: 'sg-sv-cap' }, [
561                h('span', { class: 'sg-sv-no', text: `${String(i + 1).padStart(2, '0')} / ${String(n).padStart(2, '0')}` }),
562                h('span', { text: String(sv.title || sv.short) }),
563            ]);
564            el.append(block, cap);
565            root.appendChild(el);
566            const text = String(sv.short || sv.title).toUpperCase().replace(/[.!]*$/, '.');
567            let plate = null;
568            if (sv.key === 'cleaned') {
569                plate = makePlate(el, 'hex', 'lighting');
570                plate.el.classList.add('sg-pl--solo');
571                plate.badge.style.display = 'none';
572                const lead = lighting[0];
573                const rest = lighting.slice(6);
574                assignPlates([plate], rest.filter((p) => lead && p.subcategory === lead.subcategory).concat(rest, lighting));
575            }
576            const spares = sv.key === 'spares' ? pickSpares(S.D) : null;
577            return { sv, el, block, cap, text, lines: [], F: 0, tone: SERVICE_TONES[i] || 'dark', plate, spares, weight: SV_WEIGHT[sv.key] || 1 };
578        });
579        const total = items.reduce((sum, item) => sum + item.weight, 0) || 1;
580        let at = 0;
581        items.forEach((item) => {
582            item.start = at;
583            item.dur = ((SV_END - SV_START) * item.weight) / total;
584            at += item.dur;
585        });
586        return { root, items };
587    };
588
589    /** A mix of live products from every category (not the ones already shown) for the spares kit. */
590    const pickSpares = (D) => {
591        const shown = { lighting: 6, power: 5, rigging: 3, effects: 3 };
592        const out = [];
593        const seen = new Set();
594        const add = (p) => {
595            if (p && p.image && !seen.has(p.id)) {
596                seen.add(p.id);
597                out.push(p);
598            }
599        };
600        for (let round = 0; round < 8 && out.length < 10; round++) {
601            ['lighting', 'power', 'rigging', 'effects'].forEach((key) => add(D.cats[key].products[shown[key] + round]));
602        }
603        if (out.length < 5) CATEGORY_KEYS.forEach((key) => D.cats[key].products.forEach(add));
604        return out.slice(0, 10);
605    };
606
607    const buildLockup = (S) => {
608        const D = S.D;
609        const root = h('div', { class: 'sg-scene sg-lk', 'data-tone': 'orange' });
610        const names = D.manufacturers.slice(0, 14);
611        const ring = makeRing(root, 'sg-ring sg-lk-ring', names.length >= 4 ? (names.join(' · ') + ' · ').toUpperCase() : '');
612        if (names.length < 4) ring.el.style.display = 'none';
613        const logo = brand.logo({ ring: COL.black, letters: COL.black, className: 'sg-logo reel-logo' });
614        root.appendChild(logo);
615        const text = h('div', { class: 'sg-lk-text' });
616        const headline = String(D.brand.headline || D.brand.name);
617        const comma = headline.indexOf(',');
618        const lines = comma > 0 ? [headline.slice(0, comma + 1), headline.slice(comma + 1).trim()] : [headline];
619        const lineEls = lines.map((line) => {
620            const inner = h('span', { class: 'sg-lk-li', text: line });
621            const mask = h('div', { class: 'sg-lk-line' }, [inner]);
622            text.appendChild(mask);
623            return { mask, inner, text: line };
624        });
625        const rule = h('div', { class: 'sg-lk-rule' });
626        const strap = h('div', { class: 'sg-lk-strap', text: D.brand.strapline || '' });
627        const statsText = [`${D.stats.products} products`, `${D.stats.subcategories} ranges`, `${D.stats.brands} brands`, D.brand.since ? `since ${D.brand.since}
627` : ''].filter(Boolean).join(' · ').toUpperCase();
628        const stats = h('div', { class: 'sg-lk-stats' });
629        text.append(rule, strap, stats);
630        root.appendChild(text);
631        return { root, ring, logo, text, lines: lineEls, rule, strap, stats, statsText, pts: sampleLogo() };
632    };
633
634    /** Points inside the logo's filled shapes (viewBox units) — particle targets for the lockup assembly. */
635    const sampleLogo = () => {
636        const g = document.createElement('canvas').getContext('2d');
637        const paths = [brand.logoPaths.hexagonOutline, brand.logoPaths.c, brand.logoPaths.e, brand.logoPaths.g].map((d) => new Path2D(d));
638        const pts = [];
639        for (let y = 0.5; y < 71; y += 1.1) {
640            for (let x = 0.5; x < 62; x += 1.1) {
641                if (paths.some((p) => g.isPointInPath(p, x, y))) pts.push([x, y]);
642            }
643        }
644        return pts;
645    };
646
647    /* ------------------------------------------------------------------ layout (resize) */
648
649    const measureDock = (S, L) => {
650        const sel = S.ctx.root.dataset.signalDock;
651        if (!sel || !L.wide) return null;
652        let el = null;
653        try {
654            el = document.querySelector(sel);
655        } catch (e) {
656            return null;
657        }
658        if (!el) return null;
659        const a = el.getBoundingClientRect();
660        const b = S.ctx.root.getBoundingClientRect();
661        const d = { x0: a.left - b.left, y0: a.top - b.top, x1: a.right - b.left, y1: a.bottom - b.top };
662        const overlaps = a.width > 0 && d.x0 < b.width && d.x1 > 0 && d.y0 < b.height && d.y1 > 0;
663        return overlaps && d.x1 < L.w * 0.75 ? d : null;
664    };
665
666    const placeLogo = (svg, cx, cy, R) => {
667        const k = R / 35;
668        apply(svg, { width: px(62 * k), height: px(71 * k), transformOrigin: `${px(LOGO_CX * k)} ${px(LOGO_CY * k)}` });
669        return { k, x: cx - LOGO_CX * k, y: cy - LOGO_CY * k, cx, cy, R };
670    };
671
672    const placeRing = (ring, cx, cy, radius, fontPx) => {
673        const size = radius * 2.5;
674        const chars = Math.max(20, ring.text.length);
675        const font = Math.min(fontPx, (2 * Math.PI * radius) / (chars * 0.82));
676        apply(ring.el, { width: px(size), height: px(size) });
677        apply(ring.txt, { fontSize: r1((font * 80) / radius) + 'px' });
678        return { x: cx - size / 2, y: cy - size / 2, cx, cy, radius };
679    };
680
681    const buildGrid = (S) => {
682        const L = S.L;
683        const r = L.wide ? Math.max(24, L.h * 0.055) : Math.max(20, L.w * 0.075);
684        const dx = SQ3 * r;
685        const dy = 1.5 * r;
686        const cells = [];
687        const verts = [];
688        const index = new Map();
689        const vid = (x, y) => {
690            const key = Math.round(x * 2) + ',' + Math.round(y * 2);
691            let id = index.get(key);
692            if (id == null) {
693                id = verts.length;
694                index.set(key, id);
695                verts.push({ x, y, n: [] });
696            }
697            return id;
698        };
699        const link = (a, b) => {
700            if (!verts[a].n.includes(b)) verts[a].n.push(b);
701            if (!verts[b].n.includes(a)) verts[b].n.push(a);
702        };
703        const rows = Math.ceil(L.h / dy) + 1;
704        const cols = Math.ceil(L.w / dx) + 1;
705        for (let row = -1; row <= rows; row++) {
706            for (let col = -1; col <= cols; col++) {
707                const x = col * dx + (row & 1 ? dx / 2 : 0);
708                const y = row * dy;
709                const k = clamp(((L.w - x) / L.w) * 0.62 + (y / L.h) * 0.38);
710                const c = { x, y, k, hA: hash(cells.length, 3), hB: hash(cells.length, 9) };
711                cells.push(c);
712                const ids = [];
713                for (let v = 0; v < 6; v++) {
714                    const a = (-90 + 60 * v) * DEG;
715                    ids.push(vid(x + r * Math.cos(a), y + r * Math.sin(a)));
716                }
717                for (let v = 0; v < 6; v++) link(ids[v], ids[(v + 1) % 6]);
718            }
719        }
720        const edges = [];
721        verts.forEach((v, a) => v.n.forEach((b) => b > a && edges.push([a, b])));
722
723        const nearest = (x, y) => {
724            let best = 0;
725            let bd = Infinity;
726            verts.forEach((v, i) => {
727                const d = (v.x - x) ** 2 + (v.y - y) ** 2;
728                if (d < bd) {
729                    bd = d;
730                    best = i;
731                }
732            });
733            return best;
734        };
735        const walks = [];
736        const count = L.wide ? 24 : 14;
737        for (let p = 0; p < count; p++) {
738            const rnd = U.seeded(1000 + p * 17);
739            const start = nearest(L.w * (0.08 + 0.84 * hash(p, 21)), L.h * (0.1 + 0.8 * hash(p, 22)));
740            const dirA = hash(p, 23) * Math.PI * 2;
741            const dir = [Math.cos(dirA), Math.sin(dirA)];
742            const path = [start];
743            let prev = -1;
744            let cur = start;
745            for (let step = 0; step < 90; step++) {
746                const options = verts[cur].n.filter((n) => n !== prev);
747                if (!options.length) break;
748                let pick = options[0];
749                let score = -Infinity;
750                options.forEach((n) => {
751                    const vx = verts[n].x - verts[cur].x;
752                    const vy = verts[n].y - verts[cur].y;
753                    const sc = (vx * dir[0] + vy * dir[1]) / r + rnd() * 1.6;
754                    if (sc > score) {
755                        score = sc;
756                        pick = n;
757                    }
758                });
759                prev = cur;
760                cur = pick;
761                path.push(cur);
762            }
763            walks.push({ path, t0: 0.12 + hash(p, 24) * 0.7, speed: 6.5 + hash(p, 25) * 3.5 });
764        }
765        S.grid = { r, cells, verts, edges, walks };
766    };
767
768    const layoutIgnition = (S) => {
769        const L = S.L;
770        const I = S.ig;
771        I.logoPos = placeLogo(I.logo, L.fx, L.fy, L.logoR);
772        I.ringPos = placeRing(I.ring, L.fx, L.fy, L.logoR * 1.62, sz(L, L.u * 1.25, 9, 13));
773    };
774
775    const setWordFont = (el, F) => apply(el, { fontSize: px(F) });
776
777    const layoutStatement = (S) => {
778        const L = S.L;
779        const st = S.st;
780        st.words = (L.dock || S.harness) && st.sets.stats ? st.sets.stats : st.sets.headline;
781        Object.values(st.sets).forEach((set) => set.forEach((w) => apply(w.el, { display: set === st.words ? 'block' : 'none' })));
782        const words = st.words;
783        const space = measureEm(' ', 700, 0);
784        const capH = METRIC.capH;
785        if (L.wide) {
786            const x0 = L.dock ? L.dock.x0 : L.m;
787            const maxW = L.w - L.m - x0;
788            const lineA = words.filter((w) => !w.lineB);
789            const lineB = words.filter((w) => w.lineB);
790            const em = Math.max(measureEm(lineA.map((w) => w.text).join(' ')), measureEm(lineB.map((w) => w.text).join(' ')));
791            const topY = L.dock ? L.mt + L.h * 0.03 : 0;
792            const maxH = L.dock ? L.dock.y0 - L.h * 0.07 - topY : L.h * 0.5;
793            const F = Math.min(maxW / em, maxH / (0.94 + capH), L.h * 0.19);
794            const blockH = F * (0.94 + capH);
795            const strapF0 = sz(L, L.u * 2.1, L.w < 560 ? 10 : 13, 22);
796            const below = S.harness ? 0 : L.h * 0.045 + strapF0 * 1.4 + (L.w >= 700 ? L.h * 0.07 + strapF0 * 2.4 : 0);
797            const yA = L.dock ? topY : (L.h - blockH - below) / 2;
798            [lineA, lineB].forEach((line, li) => {
799                let x = x0;
800                line.forEach((w) => {
801                    w.lay = { x, y: yA + li * F * 0.94 - METRIC.capTop * F, F };
802                    x += (measureEm(w.text) + space) * F;
803                });
804            });
805            st.lay = { x0, ruleW: maxW, strapY: yA + blockH + L.h * 0.045, strapW: maxW * 0.55, F, strapOn: !S.harness };
806        } else {
807            const maxW = L.w - 2 * L.m;
808            let sizes = words.map((w) => Math.min(maxW / measureEm(w.text), L.h * (/^\d+$/.test(w.text) ? 0.22 : 0.13)));
809            const gap = 0.2;
810            let total = sizes.reduce((a, F) => a + F * (capH + gap), 0);
811            const avail = L.h - L.mt - L.mb - L.h * 0.18;
812            if (total > avail) {
813                sizes = sizes.map((F) => (F * avail) / total);
814                total = avail;
815            }
816            let y = (L.h - total) / 2 - L.h * 0.02;
817            const yTop = y;
818            words.forEach((w, i) => {
819                w.lay = { x: L.m, y: y - METRIC.capTop * sizes[i], F: sizes[i] };
820                y += sizes[i] * (capH + gap);
821            });
822            st.lay = { x0: L.m, ruleW: maxW, strapY: y + L.h * 0.01, strapW: maxW, F: Math.min(...sizes), strapOn: !S.harness };
823        }
824        words.forEach((w) => setWordFont(w.el, w.lay.F));
825        const strapF = sz(L, L.u * (L.wide ? 2.1 : 4), L.w < 560 ? 10 : 13, 22);
826        apply(st.strap, { fontSize: px(strapF), width: px(st.lay.strapW), display: st.lay.strapOn ? 'block' : 'none' });
827        st.lay.tocY = st.lay.strapY + strapF * 1.4 + L.h * 0.07;
828        st.lay.tocOn = L.wide && st.lay.strapOn && L.w >= 700 && st.lay.tocY + 40 < L.h - L.mb;
829        apply(st.toc, { display: st.lay.tocOn ? 'flex' : 'none', width: px(st.lay.ruleW), fontSize: px(sz(L, L.u * 1.35, 11, 15)) });
830        st.hex = L.dock ? { x: L.fx, y: L.h * 0.55, r: (L.w - L.dock.x1) * 0.42 } : L.wide ? { x: L.w * 0.8, y: L.h * 0.52, r: L.h * 0.62 } : { x: L.w * 0.5, y: L.h * 0.62, r: L.w * 0.62 };
831    };
832
833    const buildTicker = (sc, fs, width) => {
834        const tk = sc.ticker;
835        const subs = sc.cat.subs;
836        if (!subs.length) {
837            tk.reps = 0;
838            tk.track.textContent = '';
839            return;
840        }
841        const unitEm = measureEm(subs.map((s) => `${s.title.toUpperCase()} ${s.count}`).join('  /  ') + '  /  ', 500, 0.04);
842        const reps = Math.max(1, Math.ceil(width / Math.max(1, unitEm * fs)));
843        tk.unitW = unitEm * fs * reps;
844        if (tk.reps === reps) return;
845        tk.reps = reps;
846        tk.track.textContent = '';
847        for (let copy = 0; copy < reps * 2; copy++) {
848            subs.forEach((s) => {
849                tk.track.appendChild(h('span', { class: 'sg-tk-i' }, [s.title.toUpperCase() + ' ', h('b', { text: String(s.count) })]));
850                tk.track.appendChild(h('span', { class: 'sg-tk-sep', text: '/' }));
851            });
852        }
853    };
854
855    const rowSlots = (pr, n, zig = [0.05, -0.06, 0.05], cap = 320) => {
856        const pw = Math.min(pr.w / (n * 1.12), pr.h / 1.9, cap);
857        const blockH = pw * 1.86;
858        const top = pr.y0 + Math.max(0, (pr.h - blockH) / 2);
859        return Array.from({ length: n }, (_, i) => ({ x: pr.x0 + (pr.w * (i + 0.5)) / n, y: top + pw * 0.577 + (zig[i] || 0) * pw, pw }));
860    };
861
862    const singleSlot = (pr, cap = 320) => {
863        const pw = Math.min(pr.w * 0.62, pr.h / 1.9, cap);
864        const top = pr.y0 + Math.max(0, (pr.h - pw * 1.86) / 2);
865        return { x: pr.x0 + pr.w / 2, y: top + pw * 0.577, pw };
866    };
867
868    const layoutCategory = (S, sc) => {
869        const L = S.L;
870        const capH = METRIC.capH;
871        const em = measureEm(sc.word.text);
872        const cntEm = 0.74;
873        const lay = {};
874        if (L.wide) {
875            lay.x0 = L.dock ? L.dock.x0 : L.m;
876            const bandW = L.w - L.m - lay.x0;
877            lay.F = Math.min(bandW / (em + cntEm), L.h * 0.26);
878            lay.y0 = L.mt;
879        } else {
880            lay.x0 = L.m;
881            lay.F = Math.min((L.w - 2 * L.m) / (em + cntEm), L.h * 0.13);
882            lay.y0 = L.mt + L.h * 0.01;
883        }
884        const F = lay.F;
885        lay.W = em * F;
886        lay.top = lay.y0 - METRIC.capTop * F;
887        lay.capBottom = lay.y0 + capH * F;
888        setWordFont(sc.word.el, F);
889        const cntF = F * 0.3;
890        apply(sc.cntN, { fontSize: px(cntF) });
891        apply(sc.cntL, { fontSize: px(sz(L, F * 0.062, 8, 12)) });
892        lay.cntX = lay.W + F * 0.07;
893        lay.cntY = METRIC.capTop * F - METRIC.capTop * cntF;
894        if (sc.slices) sc.slices.forEach((el) => apply(el, { height: px(F) }));
895
896        const tkF = sz(L, F * (L.wide ? 0.09 : 0.2), 11, 17);
897        const tkW = L.wide ? L.w - L.m - lay.x0 : L.w - 2 * L.m;
898        const tkH = tkF * 1.4;
899        lay.tk = L.wide ? { x: lay.x0, y: lay.capBottom + Math.max(14, F * 0.16), w: tkW } : { x: L.m, y: L.h - L.mb - tkH, w: tkW };
900        apply(sc.ticker.el, { fontSize: px(tkF), width: px(tkW), height: px(tkH) });
901        buildTicker(sc, tkF, tkW);
902
903        let pr;
904        if (L.wide) {
905            const y0 = lay.tk.y + tkH + L.h * 0.05;
906            const x0 = L.dock ? L.dock.x1 + L.w * 0.035 : sc.key === 'power' ? L.m : L.w * 0.4;
907            pr = { x0, x1: L.w - L.m, y0, y1: L.h - L.mb };
908            lay.info = L.dock || !sc.cat.subs.length || sc.key === 'power' ? null : { x: L.m, y: y0, w: Math.min(L.w * 0.28, x0 - L.m - L.w * 0.05), h: pr.y1 - y0 };
909        } else {
910            pr = { x0: L.m, x1: L.w - L.m, y0: lay.capBottom + L.h * 0.045, y1: lay.tk.y - L.h * 0.035 };
911            lay.info = null;
912        }
913        pr.w = pr.x1 - pr.x0;
914        pr.h = pr.y1 - pr.y0;
915        lay.pr = pr;
916        if (lay.info) {
917            apply(sc.info.el, { width: px(lay.info.w), fontSize: px(sz(L, L.u * 1.55, 12, 17)), display: 'block' });
918        } else {
919            apply(sc.info.el, { display: 'none' });
920        }
921
922        if (sc.key === 'lighting') {
923            const cap = 320 * L.s;
924            lay.slots = L.wide ? rowSlots(pr, 3, undefined, cap) : [singleSlot(pr, cap)];
925            lay.rowSlotsBy = { 1: rowSlots(pr, 1, [0], cap), 2: rowSlots(pr, 2, [0.04, -0.04], cap), 3: rowSlots(pr, 3, undefined, cap) };
926            const rowF = L.wide ? F : sz(L, L.h / 6.2, 50, 150);
927            const start = L.wide ? lay.y0 + F * 0.93 : lay.capBottom + L.h * 0.03;
928            lay.rowF = rowF;
929            lay.rows = [];
930            for (let k = 0; k < sc.rows.length; k++) {
931                const capY = start + k * rowF * 0.93;
932                if (capY > L.h) break;
933                lay.rows.push({ top: capY - METRIC.capTop * rowF });
934            }
935            sc.rows.forEach((row, k) => {
936                const unitW = measureEm(row.unit) * rowF;
937                const reps = Math.ceil((L.w + unitW * 2) / unitW) + 1;
938                row.unitW = unitW;
939                row.speed = (L.w * (0.035 + 0.02 * hash(k, 41))) / 1;
940                apply(row.el, { fontSize: px(rowF) });
941                if (row.reps !== reps) {
942                    row.reps = reps;
943                    row.el.textContent = row.unit.repeat(reps);
944                }
945            });
946        } else if (sc.key === 'rigging') {
947            layoutCluster(S, sc, lay, pr);
948        } else if (sc.key === 'power') {
949            const cw = L.wide ? Math.min(pr.w / 3.3, pr.h / 1.45, (L.dock ? 290 : 360) * L.s) : Math.min(L.w * 0.62, pr.h / 1.5, 300 * L.s);
950            const pitch = cw * 1.1;
951            const box = L.wide ? { x: pr.x0, y: pr.y0, w: pr.w, h: pr.h } : { x: 0, y: pr.y0, w: L.w, h: pr.h };
952            const first = L.wide ? (box.w - (3 * pitch - (pitch - cw))) / 2 + cw / 2 : box.w / 2;
953            lay.cv = { box, cw, pitch, first, cy: box.h / 2 };
954            apply(sc.conveyor, { width: px(box.w), height: px(box.h), transform: pose(box.x, box.y) });
955            sc.plates.forEach((pl) => apply(pl.el, { '--pw': px(cw) }));
956        } else if (sc.key === 'effects') {
957            const cap = 320 * L.s;
958            lay.slots = L.wide ? rowSlots(pr, 3, [0.08, -0.08, 0.02], cap) : [singleSlot(pr, cap)];
959            lay.rowSlotsBy = { 1: rowSlots(pr, 1, [0], cap), 2: rowSlots(pr, 2, [0.05, -0.05], cap), 3: lay.slots };
960        }
961        if (lay.slots) sc.plates.forEach((pl, i) => apply(pl.el, { '--pw': px((lay.slots[i % lay.slots.length] || lay.slots[0]).pw) }));
962        sc.lay = lay;
963    };
964
965    const layoutCluster = (S, sc, lay, pr) => {
966        const L = S.L;
967        const g = 1.07;
968        const fs = L.wide ? sz(L, L.u * 1.9, 13, 20) : sz(L, L.u * 3.6, 12, 16);
969        if (L.wide) {
970            /* a zig-zag honeycomb column; each plate's callout runs straight out from its right vertex */
971            const area = { x0: pr.x0, w: pr.w * 0.46 };
972            const r = Math.min(area.w / (SQ3 * (1 + g) * 1.04), pr.h / (3 * g + 2.15));
973            const cx = area.x0 + area.w / 2;
974            const dx = (SQ3 / 2) * r * g;
975            const listX = Math.max(cx + dx + SQ3 * r * 0.5 + L.w * 0.04, pr.x0 + pr.w * 0.5);
976            /* the column re-centres for however many products are live (1–3) */
977            lay.clusterBy = {};
978            [1, 2, 3].forEach((n) => {
979                const y0 = pr.y0 + (pr.h - r * (1.5 * g * (n - 1) + 2)) / 2 + r;
980                const slots = [0, 1, 2].slice(0, n).map((i) => ({ x: n === 1 ? cx : cx + (i % 2 ? dx : -dx), y: y0 + 1.5 * r * g * i, pw: SQ3 * r, r }));
981                lay.clusterBy[n] = { slots, list: { x: listX, w: pr.x1 - listX, fs, row: true, items: slots.map((sl) => ({ y: sl.y - fs * 0.75, h: fs * 3 })) } };
982            });
983        } else {
984            const area = { x0: pr.x0, w: pr.w, y0: pr.y0, h: pr.h * 0.6 };
985            const list = { x: pr.x0, w: pr.w, y0: pr.y0 + pr.h * 0.64, h: pr.h * 0.36 };
986            const r = Math.min(area.w / 3.9, area.h / 3.85);
987            const cx = area.x0 + area.w / 2;
988            const cy = area.y0 + area.h / 2;
989            const dx = (SQ3 / 2) * r * g;
990            const shapes = {
991                1: [{ x: cx, y: cy }],
992                2: [
993                    { x: cx - dx, y: cy - 0.4 * r * g },
994                    { x: cx + dx, y: cy + 0.4 * r * g },
995                ],
996                3: [
997                    { x: cx - dx, y: cy - 0.75 * r * g },
998                    { x: cx + dx, y: cy - 0.75 * r * g },
999                    { x: cx, y: cy + 0.75 * r * g },
1000                ],
1001            };
1002            const itemH = fs * 2.55;
1003            lay.clusterBy = {};
1004            [1, 2, 3].forEach((n) => {
1005                const listTop = list.y0 + Math.max(0, (list.h - itemH * n) / 2);
1006                lay.clusterBy[n] = {
1007                    slots: shapes[n].map((c) => ({ x: c.x, y: c.y, pw: SQ3 * r, r })),
1008                    list: { x: list.x, w: list.w, fs, row: false, items: Array.from({ length: n }, (_, i) => ({ y: listTop + i * itemH, h: itemH })) },
1009                };
1010            });
1011        }
1012        lay.slots = lay.clusterBy[3].slots;
1013        lay.list = lay.clusterBy[3].list;
1014        sc.plates.forEach((pl) => pl.callout && apply(pl.callout.el, { width: px(lay.list.w), fontSize: px(fs) }));
1015    };
1016
1017    const layoutService = (S) => {
1018        const L = S.L;
1019        const sv = S.sv;
1020        let region;
1021        const capF = sz(L, L.u * (L.wide ? 1.35 : 3), 10, 14);
1022        const capSpace = Math.max(12, L.h * 0.03) + capF * 2.8;
1023        if (L.dock) {
1024            const y = L.mt + L.h * 0.02;
1025            region = { x: L.dock.x0, y, w: L.w * 0.64 - L.dock.x0, h: L.dock.y0 - L.h * 0.04 - capSpace - y, anchor: 'top' };
1026        } else if (L.wide) region = { x: L.m, y: L.h * 0.18, w: L.w * 0.54, h: L.h * 0.56, anchor: 'mid' };
1027        else region = { x: L.m, y: L.mt + L.h * 0.03, w: L.w - 2 * L.m, h: L.h * 0.42, anchor: 'top' };
1028        sv.region = region;
1029        const capOn = L.wide || L.h >= 560;
1030        let blockBottom = 0;
1031        sv.items.forEach((item) => {
1032            const fit = fitBlock(item.text, region.w, region.h, L.wide ? 3 : 4, { lh: 0.92, maxF: L.wide ? L.h * 0.19 : L.h * 0.12 });
1033            item.F = fit.F;
1034            item.block.textContent = '';
1035            const blockH = fit.lines.length * fit.F * 0.92;
1036            const y0 = region.anchor === 'mid' ? region.y + (region.h - blockH) / 2 : region.y;
1037            item.lines = fit.lines.map((text, i) => {
1038                const inner = h('span', { class: 'sg-sv-li', text });
1039                const line = h('div', { class: 'sg-sv-line' }, [inner]);
1040                apply(line, { fontSize: px(fit.F), height: px(fit.F * 0.98) });
1041                item.block.appendChild(line);
1042                return { line, inner, x: region.x, y: y0 + i * fit.F * 0.92 - METRIC.capTop * fit.F + fit.F * 0.02, w: measureEm(text, 700, -0.045) * fit.F };
1043            });
1044            item.capY = y0 + blockH + Math.max(12, L.h * 0.03);
1045            blockBottom = Math.max(blockBottom, capOn ? item.capY + capF * (L.wide ? 2 : 5) : y0 + blockH);
1046            apply(item.cap, { fontSize: px(capF), transform: pose(region.x, item.capY), maxWidth: px(region.w), display: capOn ? '' : 'none' });
1047        });
1048        if (L.dock) sv.gfx = { x: (L.dock.x1 + L.w) / 2 + L.w * 0.03, y: L.h * 0.56, r: Math.min(L.h * 0.3, (L.w - L.dock.x1) * 0.28) };
1049        else if (L.wide) sv.gfx = { x: L.w * 0.79, y: L.h * 0.52, r: Math.min(L.h * 0.3, L.w * 0.15) };
1050        else {
1051            const top = blockBottom + L.h * 0.03;
1052            const bottom = L.h - L.mb;
1053            sv.gfx = { x: L.w / 2, y: (top + bottom) / 2, r: Math.max(30, Math.min((bottom - top) * 0.47, L.w * 0.36)) };
1054        }
1055        sv.items.forEach((item) => item.plate && apply(item.plate.el, { '--pw': px(sv.gfx.r * 1.1) }));
1056    };
1057
1058    const layoutLockup = (S) => {
1059        const L = S.L;
1060        const K = S.lk;
1061        let R;
1062        let cx;
1063        let cy;
1064        if (L.dock || S.harness) {
1065            /* the page's own H1 sits beside/below the reel — the lockup is the mark, its orbit of brands and live stats */
1066            R = L.dock ? L.logoR * 1.05 : L.wide ? L.h * 0.16 : Math.min(L.w * 0.19, L.h * 0.14);
1067            cx = L.fx;
1068            cy = L.fy - L.h * 0.03;
1069            K.textOn = 'stats';
1070            const w = Math.min(L.w - 2 * L.m, Math.max(R * 1.62 * 2.6, 460));
1071            K.textBox = { x: cx - w / 2, w, F: 70, align: 'center', y: cy + R * 1.62 + Math.max(18, L.h * 0.045) };
1072        } else if (L.wide) {
1073            R = L.h * 0.165;
1074            const ringR = R * 1.62;
1075            const textW = L.w * 0.4;
1076            const fit = Math.min(...K.lines.map((l) => textW / measureEm(l.text, 700, -0.035)), L.h * 0.085);
1077            const tw = Math.max(...K.lines.map((l) => measureEm(l.text, 700, -0.035) * fit));
1078            const gap = L.w * 0.06;
1079            const groupW = ringR * 2 + gap + Math.max(tw, L.w * 0.26);
1080            cx = (L.w - groupW) / 2 + ringR;
1081            cy = L.h / 2;
1082            K.textOn = 'full';
1083            K.textBox = { x: cx + ringR + gap, w: Math.max(tw, L.w * 0.26), F: fit, align: 'left' };
1084        } else {
1085            R = Math.min(L.w * 0.16, L.h * 0.11);
1086            cx = L.w / 2;
1087            cy = L.mt + R * 1.62 + L.h * 0.03;
1088            const textW = L.w - 2 * L.m;
1089            const fit = Math.min(...K.lines.map((l) => textW / measureEm(l.text, 700, -0.035)), L.h * 0.055);
1090            K.textOn = 'full';
1091            K.textBox = { x: L.m, w: textW, F: fit, align: 'center', y: cy + R * 1.62 + L.h * 0.05 };
1092        }
1093        K.logoPos = placeLogo(K.logo, cx, cy, R);
1094        K.ringPos = placeRing(K.ring, cx, cy, R * 1.62, sz(L, L.u * 1.3, 9, 13));
1095        if (K.textOn) {
1096            const tb = K.textBox;
1097            const F = tb.F;
1098            const strapF = sz(L, F * 0.3, 13, 20);
1099            const statsF = sz(L, F * 0.17, 9, 12);
1100            const blockH = K.lines.length * F * 1.0 + F * 0.35 + strapF * 1.5 + statsF * 3;
1101            const y = tb.y != null ? tb.y : cy - blockH / 2;
1102            const full = K.textOn === 'full';
1103            apply(K.text, { display: 'block', width: px(tb.w), transform: pose(tb.x, y), textAlign: tb.align });
1104            K.lines.forEach((l) => apply(l.mask, { fontSize: px(F), height: px(F * 1.08), display: full ? 'block' : 'none' }));
1105            apply(K.rule, { display: full ? 'block' : 'none' });
1106            apply(K.strap, { display: full ? 'block' : 'none' });
1107            apply(K.stats, { marginTop: full ? '' : '0', whiteSpace: full ? 'nowrap' : 'normal', lineHeight: full ? '' : '1.8' });
1108            apply(K.strap, { fontSize: px(strapF) });
1109            apply(K.stats, { fontSize: px(statsF) });
1110            apply(K.rule, { transformOrigin: tb.align === 'center' ? '50% 50%' : '0 50%' });
1111        } else {
1112            apply(K.text, { display: 'none' });
1113        }
1114    };
1115
1116    const layout = (S) => {
1117        const { ctx } = S;
1118        const w = ctx.layout.w;
1119        const hh = ctx.layout.h;
1120        const L = { w, h: hh, u: Math.min(w, hh) / 100, wide: w / hh >= 1.2 };
1121        S.L = L;
1122        L.m = L.wide ? Math.max(24, w * 0.045) : Math.max(18, w * 0.055);
1123        L.mt = L.wide ? Math.max(40, hh * 0.09) : Math.max(34, hh * 0.07);
1124        L.mb = L.wide ? Math.max(34, hh * 0.07) : Math.max(30, hh * 0.06);
1125        L.dock = measureDock(S, L);
1126        /* studio canvases are 1080–1920px wide but viewed at phone size: small type scales up with the canvas */
1127        L.s = S.studio ? (L.wide ? w / 1200 : w / 400) : 1;
1128        apply(ctx.root, { '--sg-s': String(Math.round(L.s * 100) / 100) });
1129        L.fx = L.dock ? (L.dock.x1 + w) / 2 : w / 2;
1130        L.fy = L.dock ? hh * 0.47 : hh / 2;
1131        L.logoR = L.dock ? Math.min((w - L.dock.x1) * 0.17, hh * 0.19) : L.wide ? Math.min(w, hh) * 0.185 : Math.min(w * 0.19, hh * 0.13);
1132        L.dotR = Math.max(5, L.u * 1.1);
1133        const corners = [
1134            [0, 0],
1135            [w, 0],
1136            [0, hh],
1137            [w, hh],
1138        ];
1139        L.cover = Math.max(...corners.map(([x, y]) => Math.hypot(x - L.fx, y - L.fy))) / Math.cos(30 * DEG) + 4;
1140        toggle(ctx.root, 'sg-narrow', !L.wide);
1141
1142        buildGrid(S);
1143        layoutIgnition(S);
1144        layoutStatement(S);
1145        CATEGORY_KEYS.forEach((key) => layoutCategory(S, S.cats[key]));
1146        layoutService(S);
1147        layoutAccount(S);
1148        layoutLockup(S);
1149    };
1150
1151    /* ------------------------------------------------------------------ shared scene pieces */
1152
1153    const renderCounter = (sc, t, a, b, outAt) => {
1154        const lay = sc.lay;
1155        const n = Math.round(sc.cat.count * E.outExpo(progress(t, a, b)));
1156        setText(sc.cntN, String(n));
1157        const inP = sc.cat.count > 0 ? progress(t, a - 0.1, a + 0.25) : 0;
1158        const outP = progress(t, outAt, outAt + 0.3);
1159        apply(sc.cnt, { opacity: op(inP * (1 - outP)), transform: pose(lay.cntX, lay.cntY + (1 - E.outExpo(inP)) * lay.F * 0.2 - E.inExpo(outP) * lay.F * 0.3) });
1160    };
1161
1162    const renderTicker = (S, sc, t, a, outAt) => {
1163        const lay = sc.lay;
1164        const tk = sc.ticker;
1165        const on = tk.reps > 0 && t >= a && t < outAt + 0.4;
1166        show(tk.el, on);
1167        if (!on) return;
1168        const p = E.outExpo(progress(t, a, a + 0.6));
1169        const q = E.inExpo(progress(t, outAt, outAt + 0.35));
1170        apply(tk.el, { transform: pose(lay.tk.x - q * S.L.w * 0.3, lay.tk.y + (1 - p) * 18), opacity: op(p * (1 - q)) });
1171        const frac = (((t - a) * 42) / Math.max(1, tk.unitW)) % 1;
1172        apply(tk.track, { transform: `translate3d(${(-frac * 50).toFixed(3)}%,0,0)` });
1173    };
1174
1175    const renderInfo = (S, sc, t, a, outAt, activeSubs) => {
1176        const lay = sc.lay;
1177        if (!lay.info) return;
1178        const on = t >= a - 0.05 && t < outAt + 0.5;
1179        show(sc.info.el, on);
1180        if (!on) return;
1181        const q = E.inExpo(progress(t, outAt, outAt + 0.4));
1182        apply(sc.info.el, { transform: pose(lay.info.x - q * S.L.w * 0.25, lay.info.y), opacity: op(1 - q) });
1183        sc.info.rows.forEach((row, i) => {
1184            const p = E.outExpo(progress(t, a + i * 0.06, a + 0.7 + i * 0.06));
1185            apply(row.row, { opacity: op(p), transform: `translate3d(${r1((1 - p) * -24)}px,0,0)` });
1186            apply(row.fill, { transform: `scaleX(${(row.frac * p).toFixed(3)})` });
1187            toggle(row.row, 'is-on', activeSubs.has(row.title));
1188        });
1189    };
1190
1191    /* one-at-a-time plate beats for narrow frames, by how many products are live */
1192    const BEATS_BY_COUNT = {
1193        lighting: {
1194            1: [[1.45, 6.35]],
1195            2: [
1196                [1.45, 3.9],
1197                [4.0, 6.35],
1198            ],
1199            3: [
1200                [1.45, 3.1],
1201                [3.2, 4.85],
1202                [4.95, 6.35],
1203            ],
1204        },
1205        effects: {
1206            1: [[0.75, 5.05]],
1207            2: [
1208                [0.75, 2.35],
1209                [2.4, 5.05],
1210            ],
1211            3: [
1212                [0.75, 2.3],
1213                [2.35, 3.85],
1214                [3.9, 5.05],
1215            ],
1216        },
1217    };
1218
1219    /** Plate flies in on an eased arc, lands with overshoot, then whips out up-left. Returns true when visible. */
1220    const plateArc = (S, pl, slot, t, tin, tout) => {
1221        const L = S.L;
1222        const vis = !!(pl && pl.product && t >= tin && t < tout + 0.5);
1223        if (!pl) return false;
1224        show(pl.el, vis);
1225        if (!vis) return false;
1226        const posAt = (tt) => {
1227            const pin = E.outExpo(progress(tt, tin, tin + 0.8));
1228            const pout = E.inExpo(progress(tt, tout, tout + 0.48));
1229            const sx = slot.x + L.w * 0.5;
1230            const sy = slot.y + L.h * 0.5;
1231            const cx = slot.x + L.w * 0.14;
1232            const cy = slot.y - L.h * 0.18;
1233            const u = 1 - pin;
1234            /* studio narrow frames exit sideways so a leaving plate never crosses the headline */
1235            const side = S.studio && !L.wide;
1236            const x = u * u * sx + 2 * u * pin * cx + pin * pin * slot.x - L.w * (side ? 1.05 : 0.6) * pout;
1237            const y = u * u * sy + 2 * u * pin * cy + pin * pin * slot.y - L.h * (side ? 0.04 : 0.55) * pout;
1238            return [x, y];
1239        };
1240        apply(pl.el, { '--pw': px(slot.pw) });
1241        const [x, y] = posAt(t);
1242        const [xp] = posAt(t - 1 / 120);
1243        const vx = (x - xp) * 120;
1244        const pb = E.outBack(progress(t, tin, tin + 0.8));
1245        const po = E.inExpo(progress(t, tout, tout + 0.48));
1246        apply(pl.el, { transform: pose(x, y, { center: true, r: lerp(26, 0, pb) - 18 * po, s: lerp(0.45, 1, pb) * (1 - 0.3 * po), sk: clamp(-vx / L.w, -1, 1) * 9 }), opacity: 1 });
1247        apply(pl.label, { opacity: op(progress(t, tin + 0.4, tin + 0.75) * (1 - progress(t, tout, tout + 0.15))) });
1248        return true;
1249    };
1250
1251    /* ------------------------------------------------------------------ 01 ignition (0–5) */
1252
1253    const STAMPS = [2.05, 2.14, 2.23];
1254
1255    const renderIgnition = (S, tr, g, f) => {
1256        const t = loc(tr, 'ignition');
1257        const I = S.ig;
1258        const L = S.L;
1259        const on = tr < SC.ignition.end;
1260        show(I.root, on);
1261        if (!on) return;
1262        const { fx, fy, logoR: R, dotR } = L;
1263        const antic = lerp(1, 0.92, E.inOutCubic(progress(t, 4.15, 4.5)));
1264        const push = lerp(1, 2.4, E.inExpo(progress(t, 4.5, 5)));
1265        const cam = antic * push;
1266
1267        let shake = 0;
1268        STAMPS.forEach((ts) => {
1269            if (t > ts && t < ts + 0.2) shake += Math.pow(1 - (t - ts) / 0.2, 2);
1270        });
1271        const sx = (noise(t * 70, 1) - 0.5) * 2 * shake * L.u * 0.9;
1272        const sy = (noise(t * 70, 2) - 0.5) * 2 * shake * L.u * 0.9;
1273
1274        /* the orange dot: breathes on the beat, then collapses into the hexagon's centre */
1275        let beat = 0;
1276        [0.5, 1, 1.5].forEach((tb) => {
1277            if (t >= tb) beat += Math.exp(-(t - tb) / 0.1);
1278        });
1279        const shrink = Math.max(0, 1 - E.inBack(progress(t, 1.7, 2.05)));
1280        drawDot(S, g, fx, fy, dotR * (1 + 0.5 * beat) * shrink, 1);
1281
1282        g.save();
1283        g.lineWidth = 1.25;
1284        [0.5, 1, 1.5].forEach((tb) => {
1285            const p = progress(t, tb, tb + 0.95);
1286            if (p <= 0 || p >= 1) return;
1287            g.strokeStyle = `rgba(${RGB.cream},${(0.75 * Math.pow(1 - p, 2)).toFixed(3)})`;
1288            g.beginPath();
1289            g.arc(fx, fy, dotR * 1.6 + R * 0.95 * E.outExpo(p), 0, Math.PI * 2);
1290            g.stroke();
1291        });
1292        g.restore();
1293
1294        /* hexagon ring draws on, then swaps to the filled mark */
1295        const d = E.inOutQuart(progress(t, 0.95, 2.0));
1296        const P = I.parts;
1297        apply(P['hex-stroke'], { opacity: t >= 0.95 ? op(1 - progress(t, 2.05, 2.2)) : 0, strokeDashoffset: (1 - d).toFixed(4) });
1298        apply(P.hex, { opacity: op(progress(t, 2.02, 2.16)) });
1299        ['c', 'e', 'g'].forEach((part, i) => {
1300            const p = progress(t, STAMPS[i], STAMPS[i] + 0.34);
1301            apply(P[part], { opacity: op(p * 5), transform: `scale(${(p <= 0 ? 0 : lerp(2.6, 1, E.outBack(p))).toFixed(3)})` });
1302        });
1303        const lp = I.logoPos;
1304        apply(I.logo, { transform: pose(lp.x + sx, lp.y + sy, { s: cam }) });
1305
1306        /* the drawing head: a hot spark travelling round the ring */
1307        if (t > 0.95 && t < 2.08) {
1308            const pts = [
1309                [30.52, 1.33],
1310                [60.02, 18.25],
1311                [60.02, 52.08],
1312                [30.52, 69],
1313                [1.02, 52.08],
1314                [1.02, 18.25],
1315            ];
1316            const pos = d * 6;
1317            const i = Math.min(5, Math.floor(pos));
1318            const fr = pos - i;
1319            const a = pts[i];
1320            const b = pts[(i + 1) % 6];
1321            const vx = (lerp(a[0], b[0], fr) - LOGO_CX) * lp.k * cam + fx + sx;
1322            const vy = (lerp(a[1], b[1], fr) - LOGO_CY) * lp.k * cam + fy + sy;
1323            sprite(g, S.spr.orange, vx, vy, L.u * 5, 0.9);
1324            sprite(f, S.spr.white, vx, vy, L.u * 1.4, 1);
1325        }
1326
1327        /* 6-fold starburst from the hexagon's vertices + shock rings */
1328        g.save();
1329        g.translate(fx + sx, fy + sy);
1330        g.scale(cam, cam);
1331        g.lineCap = 'butt';
1332        const p1 = progress(t, 2.28, 2.72);
1333        const p2 = progress(t, 2.5, 3.25);
1334        if (p1 > 0 && p2 < 1) {
1335            for (let k = 0; k < 12; k++) {
1336                const major = k % 2 === 0;
1337                const a = (-90 + 30 * k) * DEG;
1338                const r0 = R * (major ? 1.22 : 1.12);
1339                const len = R * (major ? 1.6 : 0.9);
1340                const q1 = major ? p1 : progress(t, 2.34, 2.74);
1341                const q2 = major ? p2 : progress(t, 2.52, 3.1);
1342                const head = r0 + len * E.outExpo(q1);
1343                const tail = r0 + len * E.inOutCubic(q2);
1344                if (head <= tail + 0.5) continue;
1345                g.strokeStyle = major ? COL.orange : `rgba(${RGB.cream},.6)`;
1346                g.lineWidth = major ? Math.max(2, L.u * 0.8) * (1 - 0.5 * q2) : 1.25;
1347                g.beginPath();
1348                g.moveTo(Math.cos(a) * tail, Math.sin(a) * tail);
1349                g.lineTo(Math.cos(a) * head, Math.sin(a) * head);
1350                g.stroke();
1351            }
1352        }
1353        /* a wide instrument circle sweeps round with cardinal ticks — gives the mark its scale */
1354        const cp = progress(t, 2.4, 3.3);
1355        const cf = 1 - progress(t, 4.25, 4.55);
1356        if (cp > 0 && cf > 0) {
1357            const cr = R * 2.55;
1358            const sweep = E.outExpo(cp) * Math.PI * 2;
1359            g.strokeStyle = `rgba(${RGB.cream},${(0.24 * cf).toFixed(3)})`;
1360            g.lineWidth = 1;
1361            g.beginPath();
1362            g.arc(0, 0, cr, -Math.PI / 2, -Math.PI / 2 + sweep);
1363            g.stroke();
1364            g.strokeStyle = `rgba(${RGB.cream},${(0.6 * cf * E.outCubic(cp)).toFixed(3)})`;
1365            g.beginPath();
1366            for (let k = 0; k < 4; k++) {
1367                const a = (k * 90 - 90) * DEG;
1368                g.moveTo(Math.cos(a) * (cr - 7), Math.sin(a) * (cr - 7));
1369                g.lineTo(Math.cos(a) * (cr + 7), Math.sin(a) * (cr + 7));
1370            }
1371            g.stroke();
1372        }
1373        [2.3, 2.46].forEach((ts, i) => {
1374            const p = progress(t, ts, ts + 1);
1375            if (p <= 0 || p >= 1) return;
1376            g.strokeStyle = `rgba(${i ? RGB.orange : RGB.cream},${(0.55 * Math.pow(1 - p, 1.5)).toFixed(3)})`;
1377            g.lineWidth = 1.5;
1378            g.beginPath();
1379            hexPath(g, 0, 0, R * (1.04 + 1.7 * E.outExpo(p)), 8 * p);
1380            g.stroke();
1381        });
1382        g.restore();
1383
1384        /* orbiting text ring */
1385        const rp = progress(t, 2.7, 3.45);
1386        const spin = -38 + 32 * (t - 2.7) + 150 * E.inExpo(progress(t, 4.45, 5));
1387        const rs = lerp(0.84, 1, E.outExpo(rp)) * cam;
1388        apply(I.ring.el, { opacity: op(E.outCubic(rp)), transform: pose(I.ringPos.x + sx, I.ringPos.y + sy, { r: spin, s: rs }) });
1389
1390        /* hexagon iris into the orange statement */
1391        const ip = progress(t, 4.5, 5);
1392        if (ip > 0) {
1393            f.fillStyle = COL.orange;
1394            f.beginPath();
1395            hexPath(f, fx, fy, L.cover * E.inExpo(ip), 30 * ip);
1396            f.fill();
1397        }
1398    };
1399
1400    const drawDot = (S, g, x, y, r, glow) => {
1401        if (r <= 0.3) return;
1402        sprite(g, S.spr.orange, x, y, r * 5.5, 0.32 * glow);
1403        g.fillStyle = COL.orange;
1404        g.beginPath();
1405        g.arc(x, y, r, 0, Math.PI * 2);
1406        g.fill();
1407    };
1408
1409    /* ------------------------------------------------------------------ 02 statement (5–10) */
1410
1411    const renderStatement = (S, t, g) => {
1412        const st = S.st;
1413        const L = S.L;
1414        const on = t >
1414= SC.statement.start && t < SC.statement.end;
1415        show(st.root, on);
1416        if (!on) return;
1417        const s = loc(t, 'statement');
1418        const lay = st.lay;
1419
1420        /* concentric hexagons turning slowly behind the type */
1421        g.save();
1422        g.lineWidth = 1.5;
1423        const grow = E.outExpo(progress(s, 0, 0.9));
1424        const fade = 1 - progress(s, 4.4, 4.9);
1425        for (let k = 0; k < 4; k++) {
1426            g.strokeStyle = `rgba(${RGB.black},${((0.11 - k * 0.022) * fade).toFixed(3)})`;
1427            g.beginPath();
1428            hexPath(g, st.hex.x, st.hex.y, st.hex.r * (1 - k * 0.2) * lerp(0.7, 1, grow), 6 + s * (3 + k) + k * 5);
1429            g.stroke();
1430        }
1431        g.restore();
1432
1433        const camS = lerp(1, 1.035, E.outSine(progress(s, 0, 4.4)));
1434        apply(st.root, { transform: `scale(${camS.toFixed(4)})`, transformOrigin: `${px(lay.x0)} ${px(L.h / 2)}` });
1435
1436        let bIndex = 0;
1437        st.words.forEach((w, i) => {
1438            const wl = w.lay;
1439            const F = wl.F;
1440            let tin;
1441            if (!w.lineB) tin = 0.05 + i * 0.26;
1442            else tin = 0.85 + bIndex++ * 0.16;
1443            const tout = 4.3 + i * 0.05;
1444            const fromLeft = !L.wide && i % 2 === 1;
1445            const posAt = (tt) => {
1446                const ss = loc(tt, 'statement');
1447                const pin = E.outExpo(progress(ss, tin, tin + 0.55));
1448                const pout = E.inExpo(progress(ss, tout, tout + 0.45));
1449                let x = -L.w * 1.3 * pout;
1450                let y = 0;
1451                if (w.lineB && L.wide) y = F * 0.9 * (1 - pin);
1452                else x += (fromLeft ? -1 : 1) * L.w * 0.75 * (1 - pin);
1453                return [x, y];
1454            };
1455            const [x, y] = posAt(t);
1456            const [xp, yp] = posAt(t - 1 / 120);
1457            const vx = (x - xp) * 120;
1458            const vy = (y - yp) * 120;
1459            const sm = smear(vx, L.w);
1460            const sy = 1 + Math.min(0.5, Math.abs(vy) / (L.h * 8));
1461            const visible = s >= tin;
1462            apply(w.el, {
1463                transform: pose(wl.x + x, wl.y + y, { sx: sm.sx, sy, sk: sm.sk }),
1464                opacity: visible ? op(w.lineB && L.wide ? progress(s, tin, tin + 0.12) : 1) : 0,
1465                textShadow: trail(vx, vy, RGB.black, F),
1466            });
1467            if (w.out) {
1468                const echo = progress(s, tin + 0.28, tin + 0.95);
1469                apply(w.out, { opacity: echo > 0 && echo < 1 ? op(0.85 * (1 - E.outCubic(echo))) : 0, transform: `scale(${(1 + 0.14 * E.outExpo(echo)).toFixed(4)})` });
1470            }
1471        });
1472
1473        if (lay.strapOn) {
1474            const so = 1 - progress(s, 4.25, 4.55);
1475            apply(st.strap, { transform: pose(lay.x0, lay.strapY), opacity: op(so) });
1476            st.strapWords.forEach((sw, j) => {
1477                const p = E.outExpo(progress(s, 1.85 + j * 0.045, 2.45 + j * 0.045));
1478                apply(sw, { transform: `translate3d(0,${r1((1 - p) * 110)}%,0)`, opacity: op(p) });
1479            });
1480            if (lay.tocOn) {
1481                const tp = E.outExpo(progress(s, 2.25, 3.0));
1482                apply(st.toc, { transform: pose(lay.x0, lay.tocY), opacity: op(so), '--sg-rule': tp.toFixed(3) });
1483                st.tocItems.forEach((el, i) => {
1484                    const p = E.outExpo(progress(s, 2.4 + i * 0.09, 3.0 + i * 0.09));
1485                    apply(el, { transform: `translate3d(0,${r1((1 - p) * 16)}px,0)`, opacity: op(p) });
1486                });
1487            }
1488        }
1489    };
1490
1491    /* ------------------------------------------------------------------ 03 lighting (10–17) */
1492
1493    const renderLighting = (S, t, g) => {
1494        const sc = S.cats.lighting;
1495        const L = S.L;
1496        const T0 = SC.lighting.start;
1497        const on = t >= T0 && t < SC.lighting.end;
1498        show(sc.root, on);
1499        if (!on) return;
1500        const s = loc(t, 'lighting');
1501        const lay = sc.lay;
1502
1503        /* the wall: outline rows of the live sub-categories whipping in from alternate sides */
1504        const dim = lerp(0.3, 0.075, E.inOutCubic(progress(s, 1.25, 1.85))) * (1 - progress(s, 6.4, 6.85));
1505        sc.rows.forEach((row, k) => {
1506            const rl = lay.rows[k];
1507            if (!rl) {
1508                show(row.el, false);
1509                return;
1510            }
1511            const dir = k % 2 ? 1 : -1;
1512            const a = 0.02 + k * 0.05;
1513            const xAt = (tt) => {
1514                const ss = loc(tt, 'lighting');
1515                const drift = (((ss * row.speed) % row.unitW) + row.unitW) % row.unitW;
1516                const base = dir > 0 ? -row.unitW + drift : -drift;
1517                return base - dir * L.w * (1 - E.outExpo(progress(ss, a, a + 0.7)));
1518            };
1519            const x = xAt(t);
1520            const sm = smear(velocity(xAt, t), L.w);
1521            show(row.el, s >= a);
1522            apply(row.el, { transform: pose(x, rl.top, { sx: sm.sx }), opacity: op(dim) });
1523        });
1524
1525        /* LIGHTING. slams in letter by letter — the solid row of the wall */
1526        apply(sc.word.el, { transform: pose(lay.x0, lay.top) });
1527        sc.word.chars.forEach((ch, i) => {
1528            const a = 0.22 + i * 0.036;
1529            const b = 6.36 + i * 0.022;
1530            const xAt = (tt) => {
1531                const ss = loc(tt, 'lighting');
1532                return L.w * 0.95 * (1 - E.outExpo(progress(ss, a, a + 0.55))) - L.w * 1.3 * E.inExpo(progress(ss, b, b + 0.42));
1533            };
1534            const x = xAt(t);
1535            const v = velocity(xAt, t);
1536            const sm = smear(v, L.w);
1537            apply(ch, { transform: pose(x, 0, { sx: sm.sx, sk: sm.sk }), opacity: s >= a ? 1 : 0, textShadow: trail(v, 0, RGB.cream, lay.F) });
1538        });
1539        renderCounter(sc, s, 0.6, 2.1, 6.3);
1540        renderTicker(S, sc, s, 1.3, 6.3);
1541
1542        const active = new Set();
1543        const live = sc.plates.filter((pl) => pl.product);
1544        sc.plates.forEach((pl) => !live.includes(pl) && show(pl.el, false));
1545        if (L.wide) {
1546            const waves = live.length > 3 ? [live.slice(0, 3), live.slice(3, 6)] : [live];
1547            waves.forEach((group, wave) =>
1548                group.forEach((pl, i) => {
1549                    const tin = (wave ? 4.15 : 1.45) + i * 0.12;
1550                    const tout = (waves.length === 1 || wave ? 6.28 : 3.92) + i * 0.07;
1551                    if (plateArc(S, pl, lay.rowSlotsBy[group.length][i], s, tin, tout) && s > tin + 0.3 && s < tout) active.add(pl.product.subcategory);
1552                }),
1553            );
1554        } else {
1555            const beats = BEATS_BY_COUNT.lighting[Math.min(3, live.length)] || [];
1556            live.forEach((pl, i) => {
1557                if (!beats[i]) return show(pl.el, false);
1558                const [tin, tout] = beats[i];
1559                if (plateArc(S, pl, lay.slots[0], s, tin, tout) && s > tin + 0.3 && s < tout) active.add(pl.product.subcategory);
1560            });
1561        }
1562        renderInfo(S, sc, s, 1.45, 6.3, active);
1563    };
1564
1565    /* ------------------------------------------------------------------ 04 rigging (17–21.5) */
1566
1567    const renderRigging = (S, t, g) => {
1568        const sc = S.cats.rigging;
1569        const L = S.L;
1570        const G = S.grid;
1571        const T0 = SC.rigging.start;
1572        const on = t >= T0 && t < SC.rigging.end;
1573        show(sc.root, on);
1574        if (!on) return;
1575        const s = loc(t, 'rigging');
1576        const lay = sc.lay;
1577
1578        /* hex grid builds from the top-right; a wave of orange runs through it, then floods the frame */
1579        const w1 = lerp(-0.3, 1.3, E.inOutSine(progress(s, 1.1, 3.2)));
1580        const flood = lerp(-0.25, 1.25, E.inOutCubic(progress(s, 3.65, 4.48)));
1581        g.save();
1582        g.lineWidth = 1;
1583        g.strokeStyle = `rgba(${RGB.black},.13)`;
1584        g.beginPath();
1585        G.cells.forEach((c) => {
1586            const a = progress(s, c.k * 0.85, c.k * 0.85 + 0.45);
1587            if (a > 0) hexPath(g, c.x, c.y, G.r * 0.9 * E.outBack(a), 0);
1588        });
1589        g.stroke();
1590        g.fillStyle = COL.orange;
1591        G.cells.forEach((c) => {
1592            const wave = c.hA > 0.45 ? Math.max(0, 1 - Math.abs(c.k - w1) / 0.1) : 0;
1593            const fl = clamp((flood - c.k) / 0.12);
1594            const amt = Math.max(wave * 0.85, fl);
1595            if (amt <= 0.01) return;
1596            g.beginPath();
1597            hexPath(g, c.x, c.y, G.r * (fl > 0 ? lerp(0.3, 1.04, E.outCubic(fl)) : 0.9 * E.outBack(amt)), 0);
1598            g.fill();
1599        });
1600        g.restore();
1601
1602        /* RIGGING. — letters drop in like loads on a hoist */
1603        apply(sc.word.el, { transform: pose(lay.x0, lay.top) });
1604        const F = lay.F;
1605        sc.word.chars.forEach((ch, i) => {
1606            const a = 0.08 + i * 0.05;
1607            const b = 3.4 + i * 0.03;
1608            const yAt = (tt) => {
1609                const ss = loc(tt, 'rigging');
1610                return -F * 1.5 * (1 - E.outBack(progress(ss, a, a + 0.55))) - F * 1.7 * E.inBack(progress(ss, b, b + 0.45));
1611            };
1612            const y = yAt(t);
1613            const v = velocity(yAt, t);
1614            const pin = progress(s, a, a + 0.55);
1615            apply(ch, {
1616                transform: pose(0, y, { r: (hash(i, 51) - 0.5) * 26 * (1 - E.outBack(pin)), sy: 1 + Math.min(0.35, Math.abs(v) / (L.h * 9)) }),
1617                opacity: s >= a ? 1 : 0,
1618                textShadow: trail(0, v, RGB.black, F),
1619            });
1620        });
1621        renderCounter(sc, s, 0.5, 1.8, 3.4);
1622        renderTicker(S, sc, s, 0.9, 3.4);
1623
1624        /* plates lock into a honeycomb column and callouts draw out to their labels */
1625        const active = new Set();
1626        const lines = [];
1627        const live = sc.plates.filter((pl) => pl.product).slice(0, 3);
1628        const cluster = lay.clusterBy[Math.max(1, live.length)];
1629        const list = cluster.list;
1630        const listOn = !!list;
1631        sc.plates.forEach((pl) => {
1632            const j = live.indexOf(pl);
1633            const slot = j >= 0 ? cluster.slots[j] : null;
1634            const tin = 0.7 + j * 0.1;
1635            const tout = 3.2 + j * 0.06;
1636            const vis = !!pl.product && !!slot && s >= tin && s < tout + 0.4;
1637            show(pl.el, vis);
1638            if (pl.callout) show(pl.callout.el, vis && listOn);
1639            if (!vis) return;
1640            const pin = progress(s, tin, tin + 0.5);
1641            const pout = E.inBack(progress(s, tout, tout + 0.38));
1642            const scale = E.outBack(pin) * (1 - pout);
1643            const rot = 60 * (1 - E.outBack(pin)) - 60 * pout;
1644            apply(pl.el, { transform: pose(slot.x, slot.y, { center: true, r: rot, s: Math.max(0, scale) }), opacity: 1 });
1645            const settled = s > tin + 0.35 && s < tout;
1646            if (settled) active.add(pl.product.subcategory);
1647            if (!listOn || !pl.callout) return;
1648            const it = list.items[j];
1649            const lp = E.outExpo(progress(s, tin + 0.3, tin + 0.75)) * (1 - E.inExpo(progress(s, tout - 0.1, tout + 0.1)));
1650            apply(pl.callout.el, { transform: pose(list.x + (1 - lp) * 30, it.y), opacity: op(lp) });
1651            if (lp > 0.01 && list.row) lines.push({ slot, it, lp });
1652        });
1653        if (lines.length) {
1654            g.save();
1655            g.strokeStyle = `rgba(${RGB.black},.55)`;
1656            g.fillStyle = COL.black;
1657            g.lineWidth = L.s;
1658            lines.forEach(({ slot, it, lp }) => {
1659                const narrow = !L.wide;
1660                const x0 = narrow ? slot.x : slot.x + slot.r * 0.88;
1661                const y0 = narrow ? slot.y + slot.r * 0.98 : slot.y;
1662                const x1 = list.x - 10;
1663                const y1 = it.y + list.fs * 0.75;
1664                const mx = narrow ? x0 : lerp(x0, x1, 0.5);
1665                const pts = narrow
1666                    ? [
1667                          [x0, y0],
1668                          [x0, y1],
1669                          [x1, y1],
1670                      ]
1671                    : [
1672                          [x0, y0],
1673                          [mx, y0],
1674                          [mx, y1],
1675                          [x1, y1],
1676                      ];
1677                let total = 0;
1678                for (let k = 1; k < pts.length; k++) total += Math.hypot(pts[k][0] - pts[k - 1][0], pts[k][1] - pts[k - 1][1]);
1679                let remain = total * lp;
1680                g.beginPath();
1681                g.moveTo(pts[0][0], pts[0][1]);
1682                for (let k = 1; k < pts.length && remain > 0; k++) {
1683                    const seg = Math.hypot(pts[k][0] - pts[k - 1][0], pts[k][1] - pts[k - 1][1]);
1684                    const f = Math.min(1, remain / Math.max(0.001, seg));
1685                    g.lineTo(lerp(pts[k - 1][0], pts[k][0], f), lerp(pts[k - 1][1], pts[k][1], f));
1686                    remain -= seg;
1687                }
1688                g.stroke();
1689                g.beginPath();
1690                g.arc(x0, y0, 2.5, 0, Math.PI * 2);
1691                g.fill();
1692            });
1693            g.restore();
1694        }
1695        renderInfo(S, sc, s, 1.0, 3.4, active);
1696    };
1697
1698    /* ------------------------------------------------------------------ 05 power (21.5–27.5) */
1699
1700    const renderPower = (S, t, g, f) => {
1701        const sc = S.cats.power;
1702        const L = S.L;
1703        const G = S.grid;
1704        const T0 = SC.power.start;
1705        const on = t >= T0 && t < SC.power.end;
1706        show(sc.root, on);
1707        if (!on) return;
1708        const s = loc(t, 'power');
1709        const lay = sc.lay;
1710
1711        /* the hex lattice carries current: pulses race along its edges, lighting nodes as they pass */
1712        g.save();
1713        g.lineWidth = 1;
1714        g.strokeStyle = `rgba(${RGB.black},.14)`;
1715        g.beginPath();
1716        G.edges.forEach(([a, b]) => {
1717            g.moveTo(G.verts[a].x, G.verts[a].y);
1718            g.lineTo(G.verts[b].x, G.verts[b].y);
1719        });
1720        g.stroke();
1721        const surge = 1 + 2.5 * E.inQuad(progress(s, 5.1, 5.95));
1722        g.lineCap = 'round';
1723        G.walks.forEach((walk) => {
1724            const tau = s - walk.t0;
1725            if (tau <= 0) return;
1726            const pos = Math.min(walk.path.length - 1.001, tau * walk.speed + Math.max(0, s - 5.1) * walk.speed * (surge - 1) * 0.5);
1727            const V = G.verts;
1728            const at = (p) => {
1729                const i = Math.max(0, Math.floor(p));
1730                const fr = p - i;
1731                const a = V[walk.path[i]];
1732                const b = V[walk.path[Math.min(walk.path.length - 1, i + 1)]];
1733                return [lerp(a.x, b.x, fr), lerp(a.y, b.y, fr)];
1734            };
1735            const segs = [
1736                [2.4, 1.5, 0.18],
1737                [1.5, 0.7, 0.45],
1738                [0.7, 0, 0.95],
1739            ];
1740            g.lineWidth = Math.max(2.5, L.u * 0.42);
1741            segs.forEach(([from, to, alpha]) => {
1742                const p0 = Math.max(0, pos - from);
1743                const p1 = Math.max(0, pos - to);
1744                if (p1 <= p0) return;
1745                g.strokeStyle = `rgba(${RGB.white},${alpha})`;
1746                g.beginPath();
1747                const s0 = at(p0);
1748                g.moveTo(s0[0], s0[1]);
1749                for (let k = Math.ceil(p0); k < p1; k++) {
1750                    const v = V[walk.path[k]];
1751                    g.lineTo(v.x, v.y);
1752                }
1753                const s1 = at(p1);
1754                g.lineTo(s1[0], s1[1]);
1755                g.stroke();
1756            });
1757            for (let k = Math.max(0, Math.floor(pos - 3)); k <= Math.floor(pos); k++) {
1758                const age = (pos - k) / 3;
1759                const v = V[walk.path[k]];
1760                g.fillStyle = `rgba(${RGB.white},${(0.8 * (1 - age)).toFixed(3)})`;
1761                g.beginPath();
1762                g.arc(v.x, v.y, Math.max(2, L.u * 0.35) * (1 - age * 0.5), 0, Math.PI * 2);
1763                g.fill();
1764            }
1765            const head = at(pos);
1766            sprite(g, S.spr.white, head[0], head[1], L.u * 4.2 * surge, 0.85);
1767        });
1768        g.restore();
1769
1770        /* POWER. flickers on like a sign drawing current, with a slice-glitch on the beat */
1771        apply(sc.word.el, { transform: pose(lay.x0, lay.top) });
1772        const glitch = (s > 0.62 && s < 0.8) || (s > 3.0 && s < 3.14) || (s > 5.3 && s < 5.4);
1773        const outX = -L.w * 1.3 * E.inExpo(progress(s, 5.35, 5.8));
1774        const xAt = (tt) => -L.w * 1.3 * E.inExpo(progress(loc(tt, 'power'), 5.35, 5.8));
1775        const v = velocity(xAt, t);
1776        const sm = smear(v, L.w);
1777        apply(sc.word.main, { transform: pose(outX, 0, { sx: sm.sx, sk: sm.sk }), opacity: glitch ? 0 : 1, textShadow: trail(v, 0, RGB.black, lay.F) });
1778        sc.word.chars.forEach((ch, i) => {
1779            const ti = 0.08 + hash(i, 5) * 0.34;
1780            const lit = s > ti + 0.24 || (s > ti && hash(Math.floor(s * 30), i + 7) > 0.42);
1781            apply(ch, { opacity: lit ? 1 : 0.06 });
1782        });
1783        sc.slices.forEach((el, i) => {
1784            const jx = (hash(Math.floor(s * 40), i + 3) - 0.5) * lay.F * 0.22;
1785            apply(el, { visibility: glitch ? 'inherit' : 'hidden', transform: pose(jx + outX, 0) });
1786        });
1787        renderCounter(sc, s, 0.35, 1.7, 5.3);
1788        renderTicker(S, sc, s, 0.9, 5.3);
1789
1790        /* product cards ride a stepping conveyor */
1791        const cv = lay.cv;
1792        const active = new Set();
1793        const live = sc.plates.filter((pl) => pl.product).slice(0, L.wide ? 5 : 3);
1794        const n = live.length;
1795        const steps = (L.wide ? [2.25, 3.75] : [2.05, 3.65]).slice(0, Math.max(0, n - (L.wide ? 3 : 1)));
1796        const first = L.wide && n < 3 ? (cv.box.w - (n * cv.pitch - (cv.pitch - cv.cw))) / 2 + cv.cw / 2 : cv.first;
1797        const offAt = (tt, i) => {
1798            const ss = loc(tt, 'power');
1799            let x = L.w * 0.8 * (1 - E.outExpo(progress(ss, 0.5 + i * 0.07, 1.25 + i * 0.07)));
1800            steps.forEach((st) => (x -= cv.pitch * E.outExpo(progress(ss, st + i * 0.03, st + 0.55 + i * 0.03))));
1801            x -= L.w * 1.2 * E.inExpo(progress(ss, 5.3 + i * 0.03, 5.75 + i * 0.03));
1802            return x;
1803        };
1804        sc.plates.forEach((pl) => {
1805            const i = live.indexOf(pl);
1806            const vis = i >= 0 && s >= 0.45;
1807            show(pl.el, vis);
1808            if (!vis) return;
1809            const x = first + i * cv.pitch + offAt(t, i);
1810            const vx = velocity((tt) => offAt(tt, i), t);
1811            apply(pl.el, { transform: pose(x, cv.cy, { center: true, sk: clamp(-vx / L.w, -1, 1) * 10 }), opacity: 1 });
1812            const cx = cv.box.x + x;
1813            if (cx > cv.box.x + cv.cw * 0.2 && cx < cv.box.x + cv.box.w - cv.cw * 0.2) active.add(pl.product.subcategory);
1814        });
1815        renderInfo(S, sc, s, 1.0, 5.3, active);
1816    };
1817
1818    /* ------------------------------------------------------------------ 06 effects (27.5–33) */
1819
1820    const renderEffects = (S, t, g, f) => {
1821        const sc = S.cats.effects;
1822        const L = S.L;
1823        const T0 = SC.effects.start;
1824        const on = t >= T0 && t < SC.effects.end;
1825        show(sc.root, on);
1826        if (!on) return;
1827        const s = loc(t, 'effects');
1828        const lay = sc.lay;
1829        const fade = progress(s, 0, 0.5);
1830        const bloom = 1 + 1.6 * E.inQuad(progress(s, 5.1, 5.5));
1831
1832        /* haze: slow drifting glows */
1833        for (let k = 0; k < 7; k++) {
1834            const x = L.w * (0.05 + 0.9 * noise(s * 0.16 + k * 7.3, k));
1835            const y = L.h * (0.15 + 0.8 * noise(s * 0.12 + k * 3.1, k + 20));
1836            const r = Math.max(L.w, L.h) * (0.28 + 0.2 * hash(k, 61));
1837            sprite(g, k % 3 ? S.spr.orange : S.spr.cream, x, y, r, (k % 3 ? 0.13 : 0.08) * fade * bloom);
1838        }
1839
1840        /* two beams sweeping through the haze, like fixtures in a smoky room */
1841        const beamX = L.dock ? [L.dock.x1 + (L.w - L.dock.x1) * 0.25, L.w * 0.93] : [L.w * 0.3, L.w * 0.78];
1842        beamX.forEach((bx, k) => {
1843            const ang = (Math.sin(s * 0.85 + k * 2.2) * 22 + (k ? -8 : 8)) * DEG;
1844            const len = L.h * 1.35;
1845            const spread = 0.13;
1846            const ex = bx + Math.sin(ang) * len;
1847            const ey = -L.h * 0.05 + Math.cos(ang) * len;
1848            const grad = g.createLinearGradient(bx, -L.h * 0.05, ex, ey);
1849            grad.addColorStop(0, `rgba(${RGB.cream},${(0.2 * fade * bloom).toFixed(3)})`);
1850            grad.addColorStop(1, `rgba(${RGB.cream},0)`);
1851            g.fillStyle = grad;
1852            g.beginPath();
1853            g.moveTo(bx, -L.h * 0.05);
1854            g.lineTo(bx + Math.sin(ang - spread) * len, -L.h * 0.05 + Math.cos(ang - spread) * len);
1855            g.lineTo(bx + Math.sin(ang + spread) * len, -L.h * 0.05 + Math.cos(ang + spread) * len);
1856            g.closePath();
1857            g.fill();
1858            sprite(g, S.spr.cream, bx, 0, L.u * 5, 0.5 * fade);
1859        });
1860
1861        /* sparks rising through the haze (some in front of the type for depth) */
1862        const count = L.wide ? 110 : 60;
1863        for (let i = 0; i < count; i++) {
1864            const speed = L.h * (0.06 + 0.16 * hash(i, 81));
1865            const span = L.h + 60;
1866            const y = L.h + 30 - ((((s + 3) * speed + hash(i, 82) * span) % span) + span) % span;
1867            const x = L.w * hash(i, 83) + Math.sin(s * (0.6 + hash(i, 84)) + i) * L.u * 3;
1868            const tw = 0.35 + 0.65 * noise(s * 3 + i, 85);
1869            const front = i % 5 === 0;
1870            const r = (front ? 2.2 : 1.1) + 1.6 * hash(i, 86);
1871            sprite(front ? f : g, i % 3 ? S.spr.orange : S.spr.cream, x, y, r * 3.2, tw * fade * (1 - progress(s, 5.2, 5.5)));
1872        }
1873
1874        /* EFFECTS. condenses out of the haze */
1875        apply(sc.word.el, { transform: pose(lay.x0, lay.top) });
1876        const F = lay.F;
1877        sc.word.chars.forEach((ch, i) => {
1878            const p = progress(s, 0.15 + i * 0.065, 1.0 + i * 0.065);
1879            const q = progress(s, 4.95 + i * 0.03, 5.4 + i * 0.03);
1880            const e = E.outCubic(p);
1881            const blur = Math.min(6, (1 - e) * F * 0.06 + q * F * 0.05);
1882            apply(ch, {
1883                transform: pose(0, (1 - E.outExpo(p)) * F * 0.22 - E.inCubic(q) * F * 0.2, { s: lerp(1.3, 1, E.outExpo(p)) }),
1884                opacity: op(e * (1 - q)),
1885                filter: blur > 0.3 ? `blur(${r1(blur)}px)` : 'none',
1886            });
1887        });
1888        renderCounter(sc, s, 0.7, 1.9, 4.95);
1889        renderTicker(S, sc, s, 1.0, 4.95);
1890
1891        /* plates surface from the haze and float */
1892        const active = new Set();
1893        const live = sc.plates.filter((pl) => pl.product).slice(0, 3);
1894        const beats = L.wide ? live.map((_, i) => [0.75 + i * 0.2, 4.95 + i * 0.05]) : BEATS_BY_COUNT.effects[live.length] || [];
1895        const slots = L.wide ? lay.rowSlotsBy[Math.max(1, live.length)] : lay.slots;
1896        sc.plates.forEach((pl) => {
1897            const i = live.indexOf(pl);
1898            const [tin, tout] = beats[i] || [99, 99];
1899            const slot = slots[L.wide ? i : 0];
1900            const vis = i >= 0 && !!slot && s >= tin && s < tout + 0.45;
1901            show(pl.el, vis);
1902            if (!vis) return;
1903            apply(pl.el, { '--pw': px(slot.pw) });
1904            const p = progress(s, tin, tin + 0.9);
1905            const q = progress(s, tout, tout + 0.45);
1906            const bob = Math.sin(s * 1.4 + i * 1.7) * L.h * 0.012;
1907            apply(pl.el, {
1908                transform: pose(slot.x, slot.y + bob + (1 - E.outExpo(p)) * L.h * 0.1 - E.inCubic(q) * L.h * 0.08, { center: true, s: lerp(0.82, 1, E.outExpo(p)) * (1 + 0.08 * E.inCubic(q)) }),
1909                opacity: op(E.inOutSine(p) * (1 - q)),
1910            });
1911            apply(pl.label, { opacity: op(progress(s, tin + 0.45, tin + 0.9)) });
1912            if (p > 0.5 && q === 0) active.add(pl.product.subcategory);
1913        });
1914        renderInfo(S, sc, s, 1.1, 4.95, active);
1915    };
1916
1917    /* ------------------------------------------------------------------ service graphics */
1918
1919    /** Colours for the service graphics, keyed by the slot's background tone. */
1920    const SV_PAL = {
1921        cream: { ink: COL.black, accent: COL.orange, deep: COL.dark, paper: COL.white, bg: COL.cream, stamp: COL.orange, stampInk: COL.black },
1922        dark: { ink: COL.cream, accent: COL.orange, deep: COL.dark, paper: COL.white, bg: COL.black, stamp: COL.orange, stampInk: COL.black },
1923        orange: { ink: COL.black, accent: COL.white, deep: COL.black, paper: COL.cream, bg: COL.orange, stamp: COL.black, stampInk: COL.cream },
1924        white: { ink: COL.black, accent: COL.orange, deep: COL.dark, paper: COL.cream, bg: COL.white, stamp: COL.orange, stampInk: COL.black },
1925    };
1926
1927    /* Great Britain coastline, simplified (lon, lat). */
1928    const UK_GB = [
1929        [-3.37, 58.67], [-3.03, 58.64], [-3.1, 58.44], [-3.9, 58.1], [-4.2, 57.55], [-3.0, 57.68], [-2.0, 57.69], [-1.78, 57.5],
1930        [-2.1, 57.15], [-2.47, 56.7], [-2.9, 56.45], [-2.58, 56.28], [-3.2, 56.02], [-2.52, 56.0], [-2.0, 55.77], [-1.42, 55.02],
1931        [-1.2, 54.69], [-0.61, 54.49], [-0.08, 54.12], [0.1, 53.58], [0.34, 53.14], [0.4, 52.8], [0.5, 52.95], [1.3, 52.93],
1932        [1.75, 52.5], [1.6, 52.1], [1.3, 51.95], [0.7, 51.53], [1.44, 51.38], [1.37, 51.13], [0.97, 50.92], [0.25, 50.74],
1933        [-0.14, 50.82], [-0.79, 50.73], [-1.1, 50.78], [-1.9, 50.7], [-2.46, 50.52], [-2.94, 50.72], [-3.4, 50.62], [-3.64, 50.22],
1934        [-4.14, 50.36], [-5.2, 49.96], [-5.72, 50.07], [-5.48, 50.21], [-5.0, 50.5], [-4.53, 51.02], [-4.1, 51.21], [-3.47, 51.21],
1935        [-2.7, 51.5], [-3.17, 51.45], [-3.94, 51.6], [-4.3, 51.56], [-4.7, 51.67], [-5.3, 51.9], [-4.98, 52.0], [-4.08, 52.41],
1936        [-4.05, 52.72], [-4.8, 52.77], [-4.35, 53.05], [-4.63, 53.31], [-4.05, 53.3], [-3.83, 53.33], [-3.05, 53.42], [-3.05, 53.82],
1937        [-2.9, 54.07], [-3.23, 54.1], [-3.64, 54.51], [-3.1, 54.95], [-3.6, 54.88], [-4.4, 54.72], [-4.86, 54.64], [-5.03, 54.9],
1938        [-4.63, 55.46], [-4.9, 55.95], [-5.6, 55.4], [-5.8, 55.3], [-5.47, 56.41], [-6.22, 56.73], [-5.83, 57.0], [-5.71, 57.28],
1939        [-5.7, 57.73], [-5.16, 57.9], [-5.0, 58.62], [-4.75, 58.57], [-3.52, 58.6],
1940    ];
1941    const UK_HUB = [-1.8, 52.85];
1942    const UK_DESTS = [
1943        [-0.12, 51.5], [-2.6, 51.45], [-4.14, 50.37], [-3.18, 51.48], [-2.24, 53.48], [-1.55, 53.8], [-1.6, 54.97],
1944        [-3.19, 55.95], [-4.25, 55.86], [-2.1, 57.15], [-4.22, 57.48], [1.3, 52.63], [-1.4, 50.91],
1945    ];
1946
1947    /** Great Britain projected to unit height, centred on its bounding box. */
1948    const UK = (() => {
1949        const kx = Math.cos(54.5 * DEG);
1950        const proj = ([lon, lat]) => [lon * kx, -lat];
1951        const all = UK_GB.map(proj);
1952        const xs = all.map((p) => p[0]);
1953        const ys = all.map((p) => p[1]);
1954        const hgt = Math.max(...ys) - Math.min(...ys);
1955        const cx = (Math.min(...xs) + Math.max(...xs)) / 2;
1956        const cy = (Math.min(...ys) + Math.max(...ys)) / 2;
1957        const norm = (ll) => {
1958            const p = proj(ll);
1959            return [(p[0] - cx) / hgt, (p[1] - cy) / hgt];
1960        };
1961        return { gb: UK_GB.map(norm), hub: norm(UK_HUB), dests: UK_DESTS.map(norm), width: (Math.max(...xs) - Math.min(...xs)) / hgt };
1962    })();
1963
1964    const polyPath = (g, pts, ox, oy, k) => {
1965        pts.forEach(([x, y], i) => (i ? g.lineTo(ox + x * k, oy + y * k) : g.moveTo(ox + x * k, oy + y * k)));
1966        g.closePath();
1967    };
1968
1969    const quad = (a, c, b, u) => [
1970        (1 - u) * (1 - u) * a[0] + 2 * (1 - u) * u * c[0] + u * u * b[0],
1971        (1 - u) * (1 - u) * a[1] + 2 * (1 - u) * u * c[1] + u * u * b[1],
1972    ];
1973
1974    const roundRect = (g, x, y, w, hgt, r) => {
1975        g.beginPath();
1976        if (g.roundRect) g.roundRect(x, y, w, hgt, r);
1977        else g.rect(x, y, w, hgt);
1978    };
1979
1980    const disc = (g, x, y, r, fill) => {
1981        g.fillStyle = fill;
1982        g.beginPath();
1983        g.arc(x, y, Math.max(0, r), 0, Math.PI * 2);
1984        g.fill();
1985    };
1986
1987    const text = (g, str, x, y, size, color, weight = 700, family = DISPLAY) => {
1988        g.font = `${weight} ${r1(size)}px ${family}`;
1989        g.textAlign = 'center';
1990        g.textBaseline = 'middle';
1991        g.fillStyle = color;
1992        g.fillText(str, x, y);
1993    };
1994
1995    /** Four-point glint. */
1996    const sparkle = (g, x, y, size, color, rot = 0) => {
1997        if (size <= 0.3) return;
1998        g.save();
1999        g.translate(x, y);
2000        g.rotate(rot);
2001        g.fillStyle = color;
2002        g.beginPath();
2003        g.moveTo(0, -size);
2004        g.quadraticCurveTo(0, 0, size, 0);
2005        g.quadraticCurveTo(0, 0, 0, size);
2006        g.quadraticCurveTo(0, 0, -size, 0);
2007        g.quadraticCurveTo(0, 0, 0, -size);
2008        g.fill();
2009        g.restore();
2010    };
2011
2012    /** A hexagonal rubber stamp that slams down with overshoot, then sends out a shock ring. */
2013    const hexStamp = (g, x, y, r, s, t0, { fill, ink, label, check = true, rot = -10 }) => {
2014        const p = progress(s, t0, t0 + 0.3);
2015        if (p <= 0) return;
2016        const land = s - (t0 + 0.12);
2017        if (land > 0 && land < 0.6) {
2018            const q = land / 0.6;
2019            g.save();
2020            g.globalAlpha = 0.7 * (1 - q);
2021            g.strokeStyle = fill;
2022            g.lineWidth = Math.max(1.5, r * 0.05);
2023            g.beginPath();
2024            hexPath(g, x, y, r * (1.05 + 0.9 * E.outExpo(q)), rot);
2025            g.stroke();
2026            g.restore();
2027        }
2028        const sc = lerp(2.4, 1, E.outBack(p));
2029        g.save();
2030        g.globalAlpha = clamp(p * 5);
2031        g.translate(x, y);
2032        g.rotate((rot - 22 * (1 - E.outBack(p))) * DEG);
2033        g.scale(sc, sc);
2034        g.fillStyle = fill;
2035        g.beginPath();
2036        hexPath(g, 0, 0, r, 0);
2037        g.fill();
2038        g.strokeStyle = ink;
2039        g.lineWidth = r * 0.05;
2040        g.beginPath();
2041        hexPath(g, 0, 0, r * 0.84, 0);
2042        g.stroke();
2043        if (check) {
2044            g.lineWidth = r * 0.15;
2045            g.lineCap = 'round';
2046            g.lineJoin = 'round';
2047            const oy = label ? -r * 0.12 : r * 0.04;
2048            g.beginPath();
2049            g.moveTo(-r * 0.3, oy);
2050            g.lineTo(-r * 0.08, oy + r * 0.2);
2051            g.lineTo(r * 0.32, oy - r * 0.24);
2052            g.stroke();
2053        }
2054        if (label) text(g, label, 0, check ? r * 0.36 : r * 0.02, r * (check ? 0.23 : 0.3), ink);
2055        g.restore();
2056    };
2057
2058    /** A flat delivery lorry, cab facing `dir` (1 right, -1 left); (x, y) is where the wheels touch the road. */
2059    const drawLorry = (g, x, y, L, dir) => {
2060        g.save();
2061        g.translate(x, y);
2062        g.scale(dir, 1);
2063        g.fillStyle = COL.orange;
2064        roundRect(g, -0.5 * L, -0.5 * L, 0.66 * L, 0.4 * L, 0.05 * L);
2065        g.fill();
2066        g.fillStyle = COL.white;
2067        g.beginPath();
2068        g.moveTo(0.19 * L, -0.38 * L);
2069        g.lineTo(0.37 * L, -0.38 * L);
2070        g.lineTo(0.5 * L, -0.22 * L);
2071        g.lineTo(0.5 * L, -0.08 * L);
2072        g.lineTo(0.19 * L, -0.08 * L);
2073        g.closePath();
2074        g.fill();
2075        g.fillStyle = COL.black;
2076        g.beginPath();
2077        g.moveTo(0.33 * L, -0.34 * L);
2078        g.lineTo(0.37 * L, -0.34 * L);
2079        g.lineTo(0.46 * L, -0.23 * L);
2080        g.lineTo(0.33 * L, -0.23 * L);
2081        g.closePath();
2082        g.fill();
2083        g.fillRect(-0.5 * L, -0.1 * L, L, 0.04 * L);
2084        [-0.34, -0.06, 0.36].forEach((wx) => {
2085            disc(g, wx * L, -0.06 * L, 0.09 * L, COL.cream);
2086            disc(g, wx * L, -0.06 * L, 0.04 * L, COL.black);
2087        });
2088        g.restore();
2089    };
2090
2091    /*
2092     * Delivery choreography (4s): two waves of routes fan out while two lorries each draw their own route as they
2093     * drive it, and each delivery lands with a check. Indices point into UK_DESTS.
2094     */
2095    const UK_WAVES = [
2096        [0, 1, 3, 4, 5, 11],
2097        [6, 7, 9, 10, 12],
2098    ];
2099    const UK_RUNS = [
2100        { dest: 8, t0: 0.85, t1: 2.2 },
2101        { dest: 2, t0: 2.3, t1: 3.3 },
2102    ];
2103
2104    /** Delivered anywhere in the UK — the map wipes in, routes fan out across Great Britain, lorries run their routes. */
2105    const drawDelivery = (S, g, f, b, s, pal) => {
2106        const M = b.r * 2.05;
2107        const at = (p) => [b.x + p[0] * M, b.y + p[1] * M];
2108        const rp = E.outExpo(progress(s, 0, 0.7));
2109        const top = b.y - M / 2 - 6;
2110        const span = (M + 12) * rp;
2111        g.save();
2112        g.beginPath();
2113        g.rect(b.x - M, top, M * 2, span);
2114        g.clip();
2115        g.fillStyle = pal.ink;
2116        g.beginPath();
2117        polyPath(g, UK.gb, b.x, b.y, M);
2118        g.fill();
2119        g.restore();
2120        if (rp > 0.001 && rp < 0.995) {
2121            g.fillStyle = pal.accent;
2122            g.fillRect(b.x - M * UK.width * 0.62, top + span - 1.5, M * UK.width * 1.24, 3);
2123        }
2124
2125        const hub = at(UK.hub);
2126        const route = (k) => {
2127            const end = at(UK.dests[k]);
2128            const dx = end[0] - hub[0];
2129            const dy = end[1] - hub[1];
2130            const bend = k % 2 ? 0.2 : -0.2;
2131            return { end, ctrl: [(hub[0] + end[0]) / 2 - dy * bend, (hub[1] + end[1]) / 2 + dx * bend] };
2132        };
2133        const strokeRoute = (rt, p) => {
2134            if (p <= 0) return;
2135            g.beginPath();
2136            g.moveTo(hub[0], hub[1]);
2137            for (let i = 1; i <= 24; i++) {
2138                const u = Math.min(p, i / 24);
2139                const q = quad(hub, rt.ctrl, rt.end, u);
2140                g.lineTo(q[0], q[1]);
2141                if (u >= p) break;
2142            }
2143            g.stroke();
2144        };
2145        const pin = (rt, pp, ph) => {
2146            if (pp <= 0) return;
2147            const r = b.r * 0.058 * E.outBack(pp);
2148            g.fillStyle = pal.accent;
2149            g.beginPath();
2150            hexPath(g, rt.end[0], rt.end[1], r, 0);
2151            g.fill();
2152            disc(g, rt.end[0], rt.end[1], r * 0.36, pal.ink);
2153            if (ph > 0) {
2154                const fr = (ph % 1.1) / 1.1;
2155                g.save();
2156                g.globalAlpha = 0.75 * (1 - fr);
2157                g.strokeStyle = pal.accent;
2158                g.lineWidth = 1.5;
2159                g.beginPath();
2160                hexPath(g, rt.end[0], rt.end[1], r * (1.2 + 1.8 * fr), 0);
2161                g.stroke();
2162                g.restore();
2163            }
2164        };
2165
2166        g.save();
2167        g.lineCap = 'round';
2168        g.strokeStyle = pal.accent;
2169        g.lineWidth = Math.max(2, b.r * 0.02);
2170        const waves = [];
2171        UK_WAVES.forEach((wave, wi) =>
2172            wave.forEach((k, j) => {
2173                const a = (wi ? 1.45 : 0.5) + j * 0.06;
2174                const rt = route(k);
2175                strokeRoute(rt, E.outCubic(progress(s, a, a + 0.5)));
2176                waves.push({ rt, a });
2177            }),
2178        );
2179        const runs = UK_RUNS.map((run) => ({ ...run, rt: route(run.dest), p: E.inOutCubic(progress(s, run.t0, run.t1)) }));
2180        g.lineWidth = Math.max(2.5, b.r * 0.028);
2181        runs.forEach((run) => strokeRoute(run.rt, run.p));
2182        g.restore();
2183
2184        waves.forEach(({ rt, a }) => pin(rt, progress(s, a + 0.42, a + 0.72), s - (a + 0.72)));
2185        runs.forEach((run) => {
2186            pin(run.rt, progress(s, run.t1 - 0.05, run.t1 + 0.25), s - (run.t1 + 0.25));
2187            hexStamp(g, run.rt.end[0] + b.r * 0.12, run.rt.end[1] - b.r * 0.1, b.r * 0.09, s, run.t1 + 0.05, { fill: pal.stamp, ink: pal.stampInk, rot: 0 });
2188        });
2189
2190        const hp = progress(s, 0.35, 0.7);
2191        if (hp > 0) {
2192            const r = b.r * 0.11 * E.outBack(hp);
2193            g.fillStyle = pal.accent;
2194            g.beginPath();
2195            hexPath(g, hub[0], hub[1], r, 0);
2196            g.fill();
2197            g.fillStyle = pal.ink;
2198            g.beginPath();
2199            hexPath(g, hub[0], hub[1], r * 0.5, 0);
2200            g.fill();
2201        }
2202
2203        runs.forEach((run) => {
2204            const pop = E.outBack(progress(s, run.t0 - 0.1, run.t0 + 0.15)) * (1 - E.inBack(progress(s, run.t1 + 0.25, run.t1 + 0.5))
2204);
2205            if (pop <= 0.01) return;
2206            const u = Math.max(0.001, run.p);
2207            const pos = quad(hub, run.rt.ctrl, run.rt.end, u);
2208            const prev = quad(hub, run.rt.ctrl, run.rt.end, Math.max(0, u - 0.02));
2209            drawLorry(g, pos[0], pos[1] + b.r * 0.02, b.r * 0.26 * pop, pos[0] >= prev[0] ? 1 : -1);
2210        });
2211    };
2212
2213    /** All equipment deep cleaned & tested — a live product is scanned both ways, glints, is stamped TESTED, then named. */
2214    const drawCleaned = (S, g, f, b, s, pal, item) => {
2215        const pl = item.plate;
2216        const pw = b.r * 1.1;
2217        const R0 = (pw * 1.1547) / 2;
2218        const cx = b.x;
2219        const cy = b.y - pw * 0.28 + Math.sin(s * 1.6) * b.r * 0.01;
2220        const pin = progress(s, 0, 0.42);
2221        let jx = 0;
2222        let jy = 0;
2223        const hit = s - 2.22;
2224        if (hit > 0 && hit < 0.2) {
2225            const k = Math.pow(1 - hit / 0.2, 2) * b.r * 0.035;
2226            jx = (noise(s * 80, 5) - 0.5) * 2 * k;
2227            jy = (noise(s * 80, 6) - 0.5) * 2 * k;
2228        }
2229        const sc = lerp(0.5, 1, E.outBack(pin));
2230        const rot = lerp(-40, 0, E.outBack(pin));
2231        if (pl && pl.product) {
2232            show(pl.el, true);
2233            apply(pl.el, { transform: pose(cx + jx, cy + jy, { center: true, s: sc, r: rot }), opacity: op(pin * 4) });
2234            const lp = E.outExpo(progress(s, 2.45, 2.95));
2235            apply(pl.label, { opacity: op(lp), transform: `translateX(-50%) translate3d(0,${r1((1 - lp) * 14)}px,0)` });
2236        } else {
2237            if (pl) show(pl.el, false);
2238            g.fillStyle = pal.paper;
2239            g.beginPath();
2240            hexPath(g, cx, cy, R0 * sc, rot);
2241            g.fill();
2242        }
2243
2244        /* two passes: an orange scan down, then a bright rinse back up */
2245        [
2246            { t0: 0.45, t1: 1.2, down: true, rgb: RGB.orange, line: COL.orange },
2247            { t0: 1.3, t1: 1.95, down: false, rgb: RGB.cream, line: COL.white },
2248        ].forEach((pass) => {
2249            const sp = E.inOutSine(progress(s, pass.t0, pass.t1));
2250            if (sp <= 0 || sp >= 1) return;
2251            const yl = pass.down ? cy - R0 + R0 * 2 * sp : cy + R0 - R0 * 2 * sp;
2252            const band = R0 * 0.6 * (pass.down ? -1 : 1);
2253            f.save();
2254            f.translate(jx, jy);
2255            f.beginPath();
2256            hexPath(f, cx, cy, R0, 0);
2257            f.clip();
2258            const grad = f.createLinearGradient(0, yl + band, 0, yl);
2259            grad.addColorStop(0, `rgba(${pass.rgb},0)`);
2260            grad.addColorStop(1, `rgba(${pass.rgb},${pass.down ? 0.38 : 0.6})`);
2261            f.fillStyle = grad;
2262            f.fillRect(cx - pw, Math.min(yl, yl + band), pw * 2, Math.abs(band));
2263            f.fillStyle = pass.line;
2264            f.fillRect(cx - pw, yl - 1.5, pw * 2, 3);
2265            f.restore();
2266            sprite(f, pass.down ? S.spr.orange : S.spr.cream, cx - pw * 0.5 + jx, yl + jy, b.r * 0.12, 0.9);
2267            sprite(f, pass.down ? S.spr.orange : S.spr.cream, cx + pw * 0.5 + jx, yl + jy, b.r * 0.12, 0.9);
2268        });
2269
2270        for (let k = 0; k < 12; k++) {
2271            const gx = cx + (hash(k, 301) - 0.5) * pw * 1.35;
2272            const gy = cy + (hash(k, 302) - 0.5) * R0 * 2.1;
2273            const frac = clamp((gy - (cy - R0)) / (R0 * 2));
2274            const ts = k < 7 ? 0.5 + 0.7 * frac : 1.35 + 0.6 * (1 - frac);
2275            const p = progress(s, ts, ts + 0.5);
2276            if (p <= 0 || p >= 1) continue;
2277            const inside = Math.abs(gx - cx) < pw * 0.4 && Math.abs(gy - cy) < R0 * 0.62;
2278            sparkle(f, gx + jx, gy + jy, b.r * (0.05 + 0.06 * hash(k, 303)) * Math.sin(p * Math.PI), inside ? COL.dark : pal.ink, p * 1.4);
2279        }
2280
2281        hexStamp(f, cx + pw * 0.46, cy + R0 * 0.55, b.r * 0.34, s, 2.1, { fill: pal.stamp, ink: pal.stampInk, label: 'TESTED' });
2282    };
2283
2284    /** Same day equipment availability — the clock whips round, then TODAY lands. */
2285    const drawSameDay = (S, g, f, b, s, pal) => {
2286        const R = b.r * 0.9;
2287        const pin = E.outBack(progress(s, 0, 0.4));
2288        if (pin <= 0) return;
2289        const turns = 1.75 * E.outExpo(progress(s, 0.25, 1.35));
2290        const minA = -Math.PI / 2 + turns * Math.PI * 2;
2291        const hourA = -Math.PI / 2 + (turns / 12) * Math.PI * 2 + Math.PI * 0.6;
2292        g.save();
2293        g.translate(b.x, b.y);
2294        g.scale(pin, pin);
2295        disc(g, 0, 0, R, pal.paper);
2296        const frac = turns - Math.floor(turns);
2297        if (turns > 0.001) {
2298            g.fillStyle = COL.orange;
2299            g.globalAlpha = 0.85;
2300            g.beginPath();
2301            g.moveTo(0, 0);
2302            g.arc(0, 0, R * 0.9, -Math.PI / 2, -Math.PI / 2 + (frac === 0 && turns >= 1 ? Math.PI * 2 : frac * Math.PI * 2));
2303            g.closePath();
2304            g.fill();
2305            g.globalAlpha = 1;
2306        }
2307        g.strokeStyle = COL.black;
2308        g.lineWidth = R * 0.07;
2309        g.beginPath();
2310        g.arc(0, 0, R, 0, Math.PI * 2);
2311        g.stroke();
2312        g.lineCap = 'round';
2313        for (let k = 0; k < 12; k++) {
2314            const tp = progress(s, 0.08 + k * 0.025, 0.3 + k * 0.025);
2315            if (tp <= 0) continue;
2316            const a = (k / 12) * Math.PI * 2 - Math.PI / 2;
2317            const major = k % 3 === 0;
2318            const r0 = R * (major ? 0.7 : 0.78);
2319            const r2 = lerp(R * 0.9, r0, E.outBack(tp));
2320            g.lineWidth = R * (major ? 0.07 : 0.035);
2321            g.beginPath();
2322            g.moveTo(Math.cos(a) * R * 0.9, Math.sin(a) * R * 0.9);
2323            g.lineTo(Math.cos(a) * r2, Math.sin(a) * r2);
2324            g.stroke();
2325        }
2326        g.lineWidth = R * 0.09;
2327        g.beginPath();
2328        g.moveTo(0, 0);
2329        g.lineTo(Math.cos(hourA) * R * 0.45, Math.sin(hourA) * R * 0.45);
2330        g.stroke();
2331        g.lineWidth = R * 0.055;
2332        g.beginPath();
2333        g.moveTo(-Math.cos(minA) * R * 0.12, -Math.sin(minA) * R * 0.12);
2334        g.lineTo(Math.cos(minA) * R * 0.76, Math.sin(minA) * R * 0.76);
2335        g.stroke();
2336        g.fillStyle = COL.black;
2337        g.beginPath();
2338        hexPath(g, 0, 0, R * 0.1, 0);
2339        g.fill();
2340        g.restore();
2341        hexStamp(g, b.x + R * 0.72, b.y + R * 0.66, b.r * 0.36, s, 1.25, { fill: pal.stamp, ink: pal.stampInk, label: 'TODAY', check: false, rot: -8 });
2342    };
2343
2344    /** 24/7 out-of-hours tech support — a marker runs the 24-hour ring round a headset; when it closes, the headset broadcasts. */
2345    const drawSupport = (S, g, f, b, s, pal) => {
2346        const R = b.r * 0.9;
2347        const w = R * 0.14;
2348        const run = progress(s, 0.4, 2.2);
2349        const closed = E.outBack(progress(s, 2.2, 2.5));
2350        const inkRGB = pal.ink === COL.black ? RGB.black : RGB.cream;
2351        g.save();
2352        g.translate(b.x, b.y);
2353        g.lineCap = 'butt';
2354        for (let k = 0; k < 24; k++) {
2355            const a0 = (k / 24) * Math.PI * 2 - Math.PI / 2 + 0.03;
2356            const a1 = ((k + 1) / 24) * Math.PI * 2 - Math.PI / 2 - 0.03;
2357            const appear = progress(s, k * 0.008, 0.25 + k * 0.008);
2358            if (appear <= 0) continue;
2359            g.globalAlpha = appear * 0.12;
2360            g.strokeStyle = pal.ink;
2361            g.lineWidth = w;
2362            g.beginPath();
2363            g.arc(0, 0, R, a0, a1);
2364            g.stroke();
2365            const lit = E.outBack(clamp((run * 24 - k) * 1.5));
2366            if (lit > 0) {
2367                g.globalAlpha = 1;
2368                g.strokeStyle = pal.accent;
2369                g.lineWidth = w * lit * (1 + 0.35 * (closed > 0 && closed < 1 ? Math.sin(closed * Math.PI) : 0));
2370                g.beginPath();
2371                g.arc(0, 0, R, a0, a1);
2372                g.stroke();
2373            }
2374        }
2375        g.globalAlpha = 1;
2376        if (run > 0 && run < 1) {
2377            const a = run * Math.PI * 2 - Math.PI / 2;
2378            g.fillStyle = pal.ink;
2379            g.beginPath();
2380            hexPath(g, Math.cos(a) * (R + w * 1.1), Math.sin(a) * (R + w * 1.1), w * 0.45, 0);
2381            g.fill();
2382        }
2383        ['00', '06', '12', '18'].forEach((lbl, k) => {
2384            const a = (k / 4) * Math.PI * 2 - Math.PI / 2;
2385            const tp = progress(s, 0.2 + k * 0.05, 0.5 + k * 0.05);
2386            const hot = run * 4 >= k ? 1 : 0.55;
2387            if (tp > 0) text(g, lbl, Math.cos(a) * R * 0.74, Math.sin(a) * R * 0.74, R * 0.1, `rgba(${inkRGB},${(0.8 * tp * hot).toFixed(3)})`, 500, "ui-monospace, 'SFMono-Regular', Menlo, monospace");
2388        });
2389
2390        const hp = E.outBack(progress(s, 0.15, 0.55));
2391        if (hp > 0) {
2392            g.save();
2393            g.scale(hp, hp);
2394            const H = R * 0.42;
2395            g.strokeStyle = pal.ink;
2396            g.fillStyle = pal.ink;
2397            g.lineWidth = H * 0.16;
2398            g.lineCap = 'round';
2399            g.beginPath();
2400            g.arc(0, H * 0.08, H * 0.82, Math.PI * 1.08, Math.PI * 1.92);
2401            g.stroke();
2402            roundRect(g, -H * 0.98, -H * 0.02, H * 0.36, H * 0.62, H * 0.14);
2403            g.fill();
2404            roundRect(g, H * 0.62, -H * 0.02, H * 0.36, H * 0.62, H * 0.14);
2405            g.fill();
2406            g.lineWidth = H * 0.1;
2407            g.beginPath();
2408            g.moveTo(-H * 0.8, H * 0.6);
2409            g.quadraticCurveTo(-H * 0.72, H * 0.98, -H * 0.12, H * 0.98);
2410            g.stroke();
2411            disc(g, -H * 0.06, H * 0.98, H * 0.13 * (1 + 0.25 * Math.max(0, Math.sin((s - 2.3) * 9)) * (s > 2.3 ? 1 : 0)), pal.accent);
2412            for (let k = 0; k < 3; k++) {
2413                const ph = s - 2.35 - k * 0.2;
2414                if (ph <= 0) continue;
2415                const fr = (ph % 0.6) / 0.6;
2416                g.globalAlpha = 1 - fr;
2417                g.strokeStyle = pal.accent;
2418                g.lineWidth = H * 0.08;
2419                [-1, 1].forEach((side) => {
2420                    g.beginPath();
2421                    g.arc(side * H * 0.8, H * 0.28, H * (0.45 + 0.55 * fr), side > 0 ? -0.6 : Math.PI - 0.6, side > 0 ? 0.6 : Math.PI + 0.6);
2422                    g.stroke();
2423                });
2424            }
2425            g.globalAlpha = 1;
2426            g.restore();
2427        }
2428        g.restore();
2429        hexStamp(g, b.x + R * 0.78, b.y + R * 0.72, b.r * 0.3, s, 2.5, { fill: pal.stamp, ink: pal.stampInk, label: '24/7', check: false, rot: -8 });
2430    };
2431
2432    /** Draw a live product image contain-fitted into a box (never reads its pixels). */
2433    const drawContain = (g, img, x, y, w, hgt) => {
2434        const iw = img.naturalWidth || img.width;
2435        const ih = img.naturalHeight || img.height;
2436        if (!iw || !ih) return;
2437        const k = Math.min(w / iw, hgt / ih);
2438        g.drawImage(img, x + (w - iw * k) / 2, y + (hgt - ih * k) / 2, iw * k, ih * k);
2439    };
2440
2441    /** Complimentary spares included — the kit box pops open and live catalogue products fly out into a fanned hand. */
2442    const drawSpares = (S, g, f, b, s, pal, item) => {
2443        const W = b.r * 1.2;
2444        const Hb = b.r * 0.5;
2445        const bx = b.x - W / 2;
2446        const by = b.y + b.r * 0.34;
2447        const pop = E.outBack(progress(s, 0, 0.35));
2448        if (pop <= 0) return;
2449        const imgs = S.ctx.images;
2450        const cards = (item.spares || []).filter((p) => !imgs.failed(p.image)).slice(0, 5);
2451        const n = cards.length;
2452        const cw = b.r * 0.5;
2453        const ch = cw * 1.22;
2454        const mid = (n - 1) / 2;
2455        cards.forEach((product, i) => {
2456            const t0 = 0.45 + i * 0.08;
2457            const u = progress(s, t0, t0 + 0.6);
2458            if (u <= 0) return;
2459            const e = E.outCubic(u);
2460            const d = i - mid;
2461            const sx = b.x + d * b.r * 0.12;
2462            const sy = by + Hb * 0.2;
2463            const tx = b.x + b.r * 0.08 + d * cw * 0.76;
2464            const ty = b.y - b.r * 0.4 + Math.abs(d) * b.r * 0.06;
2465            const x = lerp(sx, tx, e);
2466            const y = lerp(sy, ty, e) - b.r * 0.5 * Math.sin(Math.PI * u) * (1 - u * 0.5);
2467            const land = E.outBack(progress(s, t0 + 0.55, t0 + 0.8));
2468            const k = u < 1 ? lerp(0.35, 1, e) : lerp(1.12, 1, land);
2469            const rot = d * 8 + (1 - e) * (d >= 0 ? 220 : -220);
2470            g.save();
2471            g.translate(x, y);
2472            g.rotate(rot * DEG);
2473            g.scale(k, k);
2474            g.fillStyle = COL.white;
2475            roundRect(g, -cw / 2, -ch / 2, cw, ch, cw * 0.08);
2476            g.fill();
2477            g.fillStyle = COL.orange;
2478            g.fillRect(-cw / 2 + cw * 0.12, ch / 2 - ch * 0.1, cw * 0.76, ch * 0.03);
2479            const img = imgs.get(product.image);
2480            if (img) drawContain(g, img, -cw * 0.4, -ch * 0.42, cw * 0.8, ch * 0.66);
2481            else {
2482                g.fillStyle = 'rgba(32,32,32,.12)';
2483                g.beginPath();
2484                hexPath(g, 0, -ch * 0.08, cw * 0.18, 0);
2485                g.fill();
2486            }
2487            g.restore();
2488        });
2489
2490        g.save();
2491        g.translate(b.x, by + Hb);
2492        g.scale(pop, pop);
2493        g.translate(-b.x, -(by + Hb));
2494        const lid = -140 * E.outBack(progress(s, 0.3, 0.6));
2495        g.save();
2496        g.translate(bx, by);
2497        g.rotate(lid * DEG);
2498        g.fillStyle = COL.dark;
2499        roundRect(g, 0, -b.r * 0.12, W, b.r * 0.14, b.r * 0.03);
2500        g.fill();
2501        g.restore();
2502        g.fillStyle = COL.orange;
2503        roundRect(g, bx, by, W, Hb, b.r * 0.05);
2504        g.fill();
2505        g.fillStyle = COL.dark;
2506        g.fillRect(bx, by, W, Hb * 0.16);
2507        g.fillStyle = COL.black;
2508        roundRect(g, b.x - W * 0.12, by + Hb * 0.26, W * 0.24, Hb * 0.1, Hb * 0.05);
2509        g.fill();
2510        text(g, 'SPARES', b.x, by + Hb * 0.66, Hb * 0.26, COL.black);
2511        g.restore();
2512    };
2513
2514    /** Competitive wholesale rates — a price tag drops, swings, flips to the trade label, gets its check and glints. */
2515    const drawRates = (S, g, f, b, s, pal, item) => {
2516        const W = b.r * 0.95;
2517        const Ht = b.r * 1.3;
2518        const ax = b.x;
2519        const ay = b.y - b.r * 1.05;
2520        const drop = E.outBack(progress(s, 0, 0.45));
2521        if (drop <= 0) return;
2522        const swing = 26 * Math.exp(-1.5 * s) * Math.cos(7 * s) + 2.5 * Math.sin(s * 2.2) * progress(s, 2.2, 3.0);
2523        const fp = progress(s, 1.5, 1.88);
2524        const flip = Math.cos(Math.PI * E.inOutSine(fp));
2525        const back = flip < 0;
2526        const holeY = ay + b.r * 0.45 - (1 - drop) * b.r * 1.6;
2527        g.save();
2528        g.translate(ax, ay);
2529        g.rotate(swing * DEG);
2530        g.translate(-ax, -ay);
2531        g.strokeStyle = pal.ink;
2532        g.lineWidth = Math.max(1.5, b.r * 0.015);
2533        g.beginPath();
2534        g.moveTo(ax, ay);
2535        g.quadraticCurveTo(ax + b.r * 0.08, (ay + holeY) / 2, ax, holeY);
2536        g.stroke();
2537        disc(g, ax, ay, b.r * 0.03, pal.ink);
2538        g.save();
2539        g.translate(ax, holeY);
2540        g.scale(Math.max(0.02, Math.abs(flip)), 1);
2541        g.fillStyle = back ? COL.black : COL.orange;
2542        g.beginPath();
2543        g.moveTo(0, -b.r * 0.1);
2544        g.lineTo(W / 2, W * 0.28);
2545        g.lineTo(W / 2, Ht);
2546        g.lineTo(-W / 2, Ht);
2547        g.lineTo(-W / 2, W * 0.28);
2548        g.closePath();
2549        g.fill();
2550        disc(g, 0, b.r * 0.06, b.r * 0.045, pal.bg);
2551        if (!back) {
2552            text(g, '£', 0, Ht * 0.6, W * 0.62, COL.black);
2553        } else {
2554            const words = String(item.sv.short || item.sv.title).toUpperCase().split(/\s+/).slice(0, 2);
2555            const size = Math.min(W * 0.26, (W * 0.84) / Math.max(1, measureEm(words[0] || '', 700, 0) * 1.02));
2556            words.forEach((wd, i) => text(g, wd, 0, Ht * 0.5 + (i - (words.length - 1) / 2) * size * 1.05, size, i ? COL.orange : COL.cream));
2557            [2.7, 3.2].forEach((tg, k) => {
2558                const p = progress(s, tg, tg + 0.45);
2559                if (p > 0 && p < 1) sparkle(g, (k ? -1 : 1) * W * 0.28, Ht * (k ? 0.82 : 0.3), W * 0.09 * Math.sin(p * Math.PI), COL.orange, p);
2560            });
2561        }
2562        g.restore();
2563        g.restore();
2564        hexStamp(g, b.x + W * 0.55, b.y + b.r * 0.6, b.r * 0.28, s, 2.05, { fill: pal.stamp, ink: pal.stampInk, rot: 8 });
2565    };
2566
2567    /** Any other service: a big hex badge with a check. */
2568    const drawServiceBadge = (S, g, f, b, s, pal) => {
2569        hexStamp(g, b.x, b.y, b.r * 0.7, s, 0.1, { fill: pal.stamp, ink: pal.stampInk, rot: -6 });
2570    };
2571
2572    const SV_GFX = { delivery: drawDelivery, cleaned: drawCleaned, 'same-day': drawSameDay, support: drawSupport, spares: drawSpares, rates: drawRates };
2573
2574    /* ------------------------------------------------------------------ 07 service (33–53) */
2575
2576    const serviceSlot = (S, t) => {
2577        const items = S.sv.items;
2578        if (S.svForce) {
2579            const forced = items.findIndex((item) => item.sv.key === S.svForce.key);
2580            if (forced >= 0) return forced;
2581        }
2582        for (let i = items.length - 1; i > 0; i--) if (t - SV_START >= items[i].start) return i;
2583        return 0;
2584    };
2585
2586    const renderService = (S, t, g, f) => {
2587        const sv = S.sv;
2588        const L = S.L;
2589        const on = t >= SV_START && t < SV_END && sv.items.length > 0;
2590        show(sv.root, on);
2591        if (!on) return;
2592        const slot = serviceSlot(S, t);
2593        const base = S.svForce ? S.svForce.start - SV_START : sv.items[slot].start;
2594        const s = t - SV_START - base;
2595        sv.items.forEach((item, i) => show(item.el, i === slot));
2596        const item = sv.items[slot];
2597        const ink = SERVICE_INK[item.tone];
2598        const style = slot % 5;
2599
2600        item.lines.forEach((ln, j) => {
2601            const a = 0.02 + j * 0.06;
2602            let x = ln.x;
2603            let y = ln.y;
2604            let innerY = 0;
2605            let o = {};
2606            let v = 0;
2607            let opacity = 1;
2608            if (style === 0 || style === 3) {
2609                const dir = style === 0 ? 1 : -1;
2610                const xAt = (tt) => dir * L.w * 0.8 * (1 - E.outExpo(progress(tt - SV_START - base, a, a + 0.45)));
2611                x += xAt(t);
2612                v = velocity(xAt, t);
2613                o = smear(v, L.w);
2614            } else if (style === 1 || style === 4) {
2615                const p = E.outExpo(progress(s, a + j * 0.04, a + j * 0.04 + 0.55));
2616                innerY = (style === 1 ? 1 : -1) * (1 - p) * 105;
2617            } else {
2618                const p = progress(s, 0.02 + j * 0.05, 0.45 + j * 0.05);
2619                o = { s: lerp(1.45, 1, E.outExpo(p)) };
2620                opacity = op(p * 6);
2621            }
2622            apply(ln.line, { transform: pose(x, y, o), opacity, textShadow: trail(v, 0, ink, item.F), overflow: style === 1 || style === 4 ? 'hidden' : 'visible' });
2623            apply(ln.inner, { transform: `translate3d(0,${r1(innerY)}%,0)` });
2624        });
2625        const cp = E.outExpo(progress(s, 0.3, 0.8));
2626        apply(item.cap, { opacity: op(cp), transform: pose(sv.region.x + (1 - cp) * 20, item.capY) });
2627
2628        /* each service gets its own illustrated graphic */
2629        const draw = SV_GFX[item.sv.key] || drawServiceBadge;
2630        draw(S, g, f, sv.gfx, s, SV_PAL[item.tone] || SV_PAL.dark, item);
2631    };
2632
2633    /* ------------------------------------------------------------------ 08 my account (53–60) */
2634
2635    const ACC_KEYS = ['orders', 'quotes', 'invoices', 'deliveries', 'rate-card', 'tech-support'];
2636    const ACC_KEYS_NARROW = ['orders', 'deliveries', 'rate-card', 'tech-support'];
2637    const HEX_D = 'M12 1.5 21.1 6.75v10.5L12 22.5 2.9 17.25V6.75z';
2638    const CHECK_D = 'M7.2 12.4l3.1 3.1 6.5-6.6';
2639    const HEADSET_D = 'M4 15v-3a8 8 0 0 1 16 0v3M4 15h3v5H5.5A1.5 1.5 0 0 1 4 18.5zM20 15h-3v5h1.5a1.5 1.5 0 0 0 1.5-1.5zM17 20c0 1.4-2.2 2-5 2';
2640    const ACC_WIN = { wide: { W: 600, H: 360 }, narrow: { W: 320, H: 300 } };
2641    const ACC_LOGIN = { W: 280, H: 250, btnY: 81 };
2642
2643    const svgIcon = (d, cls) => {
2644        const el = svgEl('svg', { viewBox: '0 0 24 24', class: cls, 'aria-hidden': 'true' });
2645        el.appendChild(svgEl('path', { d }));
2646        return el;
2647    };
2648
2649    const hexCheck = (cls) => {
2650        const el = svgEl('svg', { viewBox: '0 0 24 24', class: cls, 'aria-hidden': 'true' });
2651        el.append(svgEl('path', { d: HEX_D, class: 'hx' }), svgEl('path', { d: CHECK_D, class: 'ck' }));
2652        return el;
2653    };
2654
2655    /** A tiny animated UI illustration for one account feature tile. */
2656    const buildAccountViz = (key) => {
2657        const el = h('div', { class: 'sg-av sg-av--' + key });
2658        const parts = {};
2659        if (key === 'orders') {
2660            parts.rows = [0, 1, 2].map(() => {
2661                const row = h('div', { class: 'sg-av-row' }, [h('i', { class: 't' }), h('i', { class: 'b' }), h('i', { class: 'p' })]);
2662                el.appendChild(row);
2663                return row;
2664            });
2665        } else if (key === 'quotes') {
2666            parts.doc = h('div', { class: 'sg-av-doc' }, [h('i'), h('i'), h('i'), h('i')]);
2667            parts.badge = hexCheck('sg-av-badge');
2668            el.append(parts.doc, parts.badge);
2669        } else if (key === 'invoices') {
2670            parts.bars = [0.45, 0.72, 0.56, 0.92, 0.66].map((hgt) => {
2671                const bar = h('i', { class: 'sg-av-bar', style: { height: hgt * 100 + '%' } });
2672                el.appendChild(bar);
2673                return bar;
2674            });
2675            parts.dl = svgIcon('M12 4v11m0 0 4.5-4.5M12 15l-4.5-4.5M5 20h14', 'sg-av-dl');
2676            el.appendChild(parts.dl);
2677        } else if (key === 'deliveries') {
2678            const pts = [
2679                [8, 60],
2680                [30, 44],
2681                [50, 52],
2682                [70, 26],
2683                [92, 14],
2684            ];
2685            const svg = svgEl('svg', { viewBox: '0 0 100 72', class: 'sg-av-map', 'aria-hidden': 'true' });
2686            svg.appendChild(svgEl('polyline', { points: pts.map((p) => p.join(',')).join(' '), class: 'rt' }));
2687            svg.appendChild(svgEl('circle', { cx: pts[0][0], cy: pts[0][1], r: 3.4, class: 'a' }));
2688            svg.appendChild(svgEl('path', { d: `M${pts[4][0]} ${pts[4][1] - 5.5} l4.8 2.75v5.5l-4.8 2.75-4.8-2.75v-5.5z`, class: 'b' }));
2689            parts.halo = svgEl('circle', { r: 6, class: 'halo' });
2690            parts.dot = svgEl('circle', { r: 3.2, class: 'dot' });
2691            svg.append(parts.halo, parts.dot);
2692            el.appendChild(svg);
2693            const seg = [0];
2694            for (let i = 1; i < pts.length; i++) seg.push(seg[i - 1] + Math.hypot(pts[i][0] - pts[i - 1][0], pts[i][1] - pts[i - 1][1]));
2695            parts.pts = pts;
2696            parts.seg = seg;
2697        } else if (key === 'rate-card') {
2698            parts.rows = [0.82, 0.56, 0.7].map((w) => {
2699                const bar = h('i', { style: { width: w * 100 + '%' } });
2700                const row = h('div', { class: 'sg-av-rr' }, [h('b', { text: '£' }), h('span', {}, [bar])]);
2701                el.appendChild(row);
2702                return bar;
2703            });
2704            parts.mask = h('i', { class: 'sg-av-mask' });
2705            el.appendChild(parts.mask);
2706        } else if (key === 'tech-support') {
2707            parts.icon = svgIcon(HEADSET_D, 'sg-av-hs');
2708            parts.live = h('i', { class: 'sg-av-live' });
2709            parts.bubbles = [h('i', { class: 'sg-av-bub' }), h('i', { class: 'sg-av-bub sg-av-bub--me' })];
2710            el.append(parts.icon, parts.live, ...parts.bubbles);
2711        } else {
2712            el.appendChild(hexCheck('sg-av-badge sg-av-badge--solo'));
2713        }
2714        return { el, parts, key };
2715    };
2716
2717    const buildAccount = (S) => {
2718        const A = S.D.account;
2719        const root = h('div', { class: 'sg-scene sg-ac', 'data-tone': 'dark' });
2720        const ac = { root, on: !!A, lines: [] };
2721        if (!A) return ac;
2722        ac.text = String(A.title || '').toUpperCase().replace(/[.!]*$/, '.');
2723        ac.linesEl = h('div', { class: 'sg-ac-lines' });
2724
2725        ac.typed = h('i', { class: 'sg-ac-typed' });
2726        ac.dots = Array.from({ length: 8 }, () => h('i'));
2727        ac.arrow = svgIcon('M5 12h13m0 0-5-5m5 5-5 5', 'sg-ac-ico sg-ac-arrow');
2728        ac.spin = svgIcon(HEX_D, 'sg-ac-ico sg-ac-spin');
2729        ac.ok = svgIcon(CHECK_D, 'sg-ac-ico sg-ac-ok');
2730        ac.ripple = h('i', { class: 'sg-ac-ripple' });
2731        ac.btn = h('div', { class: 'sg-ac-btn' }, [ac.ripple, ac.arrow, ac.spin, ac.ok]);
2732        ac.login = h('div', { class: 'sg-ac-login' }, [
2733            brand.logo({ ring: '#9A9A9A', letters: COL.orange, className: 'sg-ac-logo' }),
2734            h('div', { class: 'sg-ac-field' }, [ac.typed]),
2735            h('div', { class: 'sg-ac-field' }, [h('span', { class: 'sg-ac-dots' }, ac.dots)]),
2736            ac.btn,
2737        ]);
2738        ac.cursor = svgIcon('M5 2.5l13 8.6-5.9 1.2 3.1 7-2.5 1.1-3.1-7.1L5 17.5z', 'sg-ac-cursor');
2739
2740        const features = (A.features || []).filter((x) => x && x.title);
2741        const byKey = Object.fromEntries(features.map((x) => [x.key, x]));
2742        const picked = ACC_KEYS.filter((k) => byKey[k]).map((k) => byKey[k]);
2743        features.forEach((x) => picked.length < 6 && !picked.includes(x) && picked.push(x));
2744        const narrowKeys = ACC_KEYS_NARROW.filter((k) => byKey[k]);
2745        ac.tilesEl = h('div', { class: 'sg-ac-tiles' });
2746        ac.tiles = picked.map((feature, i) => {
2747            const viz = buildAccountViz(feature.key);
2748            const el = h('div', { class: 'sg-ac-tile' }, [viz.el, h('div', { class: 'sg-ac-tt', text: feature.title })]);
2749            ac.tilesEl.appendChild(el);
2750            return { el, viz, key: feature.key, narrow: narrowKeys.length >= 4 ? narrowKeys.includes(feature.key) : i < 4 };
2751        });
2752        ac.bens = (A.benefits || [])
2753            .filter(Boolean)
2754            .slice(0, 5)
2755            .map((text) => {
2756                const check = hexCheck('sg-ac-bc');
2757                const label = h('span', { text: String(text) });
2758                return { el: h('div', { class: 'sg-ac-ben' }, [check, label]), check, label };
2759            });
2760        ac.bensEl = h('div', { class: 'sg-ac-bens' }, ac.bens.map((b) => b.el));
2761        ac.win = h('div', { class: 'sg-ac-win' }, [
2762            h('div', { class: 'sg-ac-head' }, [svgIcon(HEX_D, 'sg-ac-hex'), h('span', { text: String(A.title || '') }), h('i', { class: 'sg-ac-online' })]),
2763            h('div', { class: 'sg-ac-body' }, [ac.tilesEl, ac.bensEl]),
2764        ]);
2765        root.append(ac.linesEl, ac.login, ac.win, ac.cursor);
2766        return ac;
2767    };
2768
2769    const layoutAccount = (S) => {
2770        const ac = S.ac;
2771        if (!ac.on) return;
2772        const L = S.L;
2773        const zone = L.dock;
2774        const wx0 = zone ? zone.x0 : L.m;
2775        const wW = L.w - L.m - wx0;
2776        const fit = fitBlock(ac.text, wW, L.h * (L.wide ? 0.22 : 0.2), 2, { lh: 0.92, maxF: L.h * (L.wide ? 0.15 : 0.1) });
2777        ac.F = fit.F;
2778        ac.linesEl.textContent = '';
2779        ac.lines = fit.lines.map((text, i) => {
2780            const el = h('div', { class: 'sg-ac-line', text });
2781            apply(el, { fontSize: px(fit.F) });
2782            ac.linesEl.appendChild(el);
2783            return { el, x: wx0, y: L.mt + i * fit.F * 0.92 - METRIC.capTop * fit.F };
2784        });
2785        const wordBottom = L.mt + (fit.lines.length - 1) * fit.F * 0.92 + METRIC.capH * fit.F;
2786        const reg = zone
2787            ? { x0: zone.x1 + L.w * 0.035, x1: L.w - L.m, y0: wordBottom + L.h * 0.05, y1: L.h - L.mb }
2788            : { x0: L.m, x1: L.w - L.m, y0: wordBottom + L.h * 0.04, y1: L.h - L.mb };
2789        reg.cx = (reg.x0 + reg.x1) / 2;
2790        reg.cy = (reg.y0 + reg.y1) / 2;
2791        const mode = L.wide ? 'wide' : 'narrow';
2792        const { W, H } = ACC_WIN[mode];
2793        toggle(ac.root, 'is-narrow', !L.wide);
2794        apply(ac.win, { width: W + 'px', height: H + 'px' });
2795        ac.mode = mode;
2796        ac.winK = Math.min((reg.x1 - reg.x0) / W, (reg.y1 - reg.y0) / H, 1.7 * L.s);
2797        ac.win0 = { x: reg.cx - W / 2, y: reg.cy - H / 2 };
2798        ac.loginK = Math.min(ac.winK * 1.05, ((reg.y1 - reg.y0) / ACC_LOGIN.H) * 0.95, ((reg.x1 - reg.x0) / ACC_LOGIN.W) * 0.95, 1.5 * L.s);
2799        ac.login0 = { x: reg.cx - ACC_LOGIN.W / 2, y: reg.cy - ACC_LOGIN.H / 2 };
2800        ac.reg = reg;
2801        ac.visible = ac.tiles.filter((tile) => (L.wide ? true : tile.narrow)).slice(0, L.wide ? 6 : 4);
2802        ac.tiles.forEach((tile) => apply(tile.el, { display: ac.visible.includes(tile) ? '' : 'none' }));
2803    };
2804
2805    const renderAccountViz = (tile, s, ts) => {
2806        const P = tile.viz.parts;
2807        switch (tile.key) {
2808            case 'orders':
2809                P.rows.forEach((row, j) => {
2810                    const p = E.outExpo(progress(ts, 0.2 + j * 0.1, 0.65 + j * 0.1));
2811                    apply(row, { transform: `translate3d(${r1((1 - p) * -18)}px,0,0)`, opacity: op(p) });
2812                });
2813                break;
2814            case 'quotes': {
2815                const p = E.outExpo(progress(ts, 0.1, 0.5));
2816                apply(P.doc, { transform: `translate3d(0,${r1((1 - p) * 10)}px,0)`, opacity: op(p) });
2817                const b = progress(ts, 0.45, 0.8);
2818                apply(P.badge, { transform: `scale(${E.outBack(b).toFixed(3)}) rotate(${r1((1 - E.outBack(b)) * -30)}deg)`, opacity: op(b * 4) });
2819                break;
2820            }
2821            case 'invoices':
2822                P.bars.forEach((bar, j) => apply(bar, { transform: `scaleY(${E.outBack(progress(ts, 0.2 + j * 0.06, 0.6 + j * 0.06)).toFixed(3)})` }));
2823                apply(P.dl, { transform: `translate3d(0,${r1(ts > 0.8 ? -Math.abs(Math.sin((ts - 0.8) * 5)) * 4 : 0)}px,0)`, opacity: op(progress(ts, 0.5, 0.7)) });
2824                break;
2825            case 'deliveries': {
2826                const run = ts - 0.3;
2827                const u = run > 0 ? E.inOutSine((run % 1.7) / 1.7) : 0;
2828                const total = P.seg[P.seg.length - 1];
2829                const d = u * total;
2830                let i = 1;
2831                while (i < P.seg.length - 1 && P.seg[i] < d) i++;
2832                const f = (d - P.seg[i - 1]) / Math.max(0.001, P.seg[i] - P.seg[i - 1]);
2833                const x = lerp(P.pts[i - 1][0], P.pts[i][0], f);
2834                const y = lerp(P.pts[i - 1][1], P.pts[i][1], f);
2835                setAttr(P.dot, 'cx', r1(x));
2836                setAttr(P.dot, 'cy', r1(y));
2837                setAttr(P.halo, 'cx', r1(x));
2838                setAttr(P.halo, 'cy', r1(y));
2839                setAttr(P.halo, 'r', r1(5 + 3 * Math.abs(Math.sin(s * 4))));
2840                break;
2841            }
2842            case 'rate-card':
2843                P.rows.forEach((bar, j) => apply(bar, { transform: `scaleX(${E.outExpo(progress(ts, 0.15 + j * 0.08, 0.6 + j * 0.08)).toFixed(3)})` }));
2844                apply(P.mask, { transform: `scaleX(${(1 - E.inOutCubic(progress(ts, 1.0, 1.5))).toFixed(3)})` });
2845                break;
2846            case 'tech-support':
2847                apply(P.live, { opacity: op(0.45 + 0.55 * Math.abs(Math.sin(s * 3.2))) });
2848                P.bubbles.forEach((bub, j) => {
2849                    const p = E.outBack(progress(ts, 0.4 + j * 0.45, 0.7 + j * 0.45));
2850                    apply(bub, { transform: `scale(${p.toFixed(3)})`, opacity: op(p * 3) });
2851                });
2852                break;
2853            default:
2854                break;
2855        }
2856    };
2857
2858    const setAttr = (el, name, value) => {
2859        const key = '__sgA_' + name;
2860        if (el[key] !== value) {
2861            el[key] = value;
2862            el.setAttribute(name, value);
2863        }
2864    };
2865
2866    /** My Account — the title slams in, a sign-in card logs in, the account dashboard assembles and the benefits tick off. */
2867    const renderAccount = (S, t, g) => {
2868        const ac = S.ac;
2869        const A = SC.account;
2870        const on = ac.on && t >
2870= A.start && t < A.end;
2871        show(ac.root, on);
2872        if (!on) return;
2873        const L = S.L;
2874        const s = t - A.start;
2875        const reg = ac.reg;
2876
2877        g.save();
2878        g.lineWidth = 1.5;
2879        const grow = E.outExpo(progress(s, 0, 1.2));
2880        for (let k = 0; k < 3; k++) {
2881            g.strokeStyle = `rgba(${RGB.cream},${(0.07 - k * 0.018).toFixed(3)})`;
2882            g.beginPath();
2883            hexPath(g, reg.cx, reg.cy, Math.max(reg.x1 - reg.x0, reg.y1 - reg.y0) * (0.62 - k * 0.12) * lerp(0.8, 1, grow), 8 + s * (4 + k * 2));
2884            g.stroke();
2885        }
2886        g.restore();
2887        sprite(g, S.spr.orange, reg.cx, reg.cy, Math.max(reg.x1 - reg.x0, reg.y1 - reg.y0) * 0.55, 0.12 * grow);
2888
2889        ac.lines.forEach((ln, i) => {
2890            const a = 0.05 + i * 0.1;
2891            const b = A.end - A.start - 0.55 + i * 0.05;
2892            const xAt = (tt) => {
2893                const ss = tt - A.start;
2894                return L.w * 0.8 * (1 - E.outExpo(progress(ss, a, a + 0.5))) - L.w * 1.25 * E.inExpo(progress(ss, b, b + 0.45));
2895            };
2896            const x = xAt(t);
2897            const v = velocity(xAt, t);
2898            const sm = smear(v, L.w);
2899            apply(ln.el, { transform: pose(ln.x + x, ln.y, { sx: sm.sx, sk: sm.sk }), opacity: s >= a ? 1 : 0, textShadow: trail(v, 0, RGB.cream, ac.F) });
2900        });
2901
2902        /* sign in: fields fill, the cursor presses the button, it spins and checks */
2903        const lp = E.outBack(progress(s, 0.3, 0.7));
2904        const lo = E.inCubic(progress(s, 1.85, 2.15));
2905        show(ac.login, s >= 0.3 && s < 2.15);
2906        apply(ac.login, {
2907            transform: pose(ac.login0.x, ac.login0.y + (1 - lp) * 30, { s: ac.loginK * lerp(0.85, 1, lp) * (1 + 0.3 * lo) }),
2908            opacity: op(clamp(lp * 3) * (1 - lo)),
2909        });
2910        apply(ac.typed, { transform: `scaleX(${(Math.floor(progress(s, 0.6, 1.0) * 14) / 14).toFixed(3)})` });
2911        ac.dots.forEach((d, i) => apply(d, { opacity: s >= 0.95 + i * 0.03 ? 1 : 0 }));
2912        const press = s > 1.2 && s < 1.36 ? Math.sin(((s - 1.2) / 0.16) * Math.PI) : 0;
2913        apply(ac.btn, { transform: `scale(${(1 - 0.07 * press).toFixed(3)})` });
2914        const rp = progress(s, 1.25, 1.8);
2915        apply(ac.ripple, { transform: `scale(${(0.2 + 7 * E.outCubic(rp)).toFixed(3)})`, opacity: rp > 0 && rp < 1 ? op(0.4 * (1 - rp)) : 0 });
2916        apply(ac.arrow, { opacity: s < 1.34 ? 1 : 0 });
2917        apply(ac.spin, { opacity: s >= 1.34 && s < 1.74 ? 1 : 0, transform: `rotate(${r1((s - 1.34) * 600)}deg)` });
2918        const okp = progress(s, 1.74, 1.95);
2919        apply(ac.ok, { opacity: okp > 0 ? 1 : 0, transform: `scale(${E.outBack(okp).toFixed(3)})` });
2920        const btnX = reg.cx;
2921        const btnY = reg.cy + ACC_LOGIN.btnY * ac.loginK;
2922        const cp = E.inOutCubic(progress(s, 0.55, 1.18));
2923        const cx0 = reg.cx + (reg.x1 - reg.x0) * 0.32;
2924        const cy0 = reg.cy + (reg.y1 - reg.y0) * 0.42;
2925        const ck = Math.max(0.8, ac.loginK) * (press ? 0.86 : 1);
2926        show(ac.cursor, s >= 0.5 && s < 1.9);
2927        apply(ac.cursor, { transform: pose(lerp(cx0, btnX + 10, cp) - 5 * ck, lerp(cy0, btnY + 6, cp) - 2.5 * ck, { s: ck }), opacity: op(progress(s, 0.5, 0.65) * (1 - progress(s, 1.7, 1.9))) });
2928
2929        /* the dashboard assembles from the account's own features */
2930        const wp = E.outExpo(progress(s, 1.9, 2.4));
2931        const wo = E.inBack(progress(s, A.end - A.start - 0.65, A.end - A.start - 0.2));
2932        show(ac.win, s >= 1.9);
2933        apply(ac.win, { transform: pose(ac.win0.x, ac.win0.y + wo * L.h * 0.25, { s: ac.winK * lerp(0.6, 1, wp) * (1 - 0.12 * wo) }), opacity: op(clamp(wp * 2) * (1 - wo)) });
2934        ac.visible.forEach((tile, i) => {
2935            const tin = 2.15 + i * 0.08;
2936            const p = progress(s, tin, tin + 0.45);
2937            apply(tile.el, { transform: `translate3d(0,${r1((1 - E.outExpo(p)) * 18)}px,0) scale(${lerp(0.7, 1, E.outBack(p)).toFixed(3)})`, opacity: op(p * 3) });
2938            renderAccountViz(tile, s, s - tin);
2939        });
2940
2941        /* benefits tick off: beside the tiles on wide screens, sliding over them on narrow ones */
2942        const narrow = ac.mode === 'narrow';
2943        const b0 = narrow ? 3.25 : 2.45;
2944        const bp = E.outExpo(progress(s, b0, b0 + 0.45));
2945        apply(ac.bensEl, { transform: `translate3d(${r1((1 - bp) * (narrow ? 110 : 40))}${narrow ? '%' : 'px'},0,0)`, opacity: op(narrow ? 1 : bp) });
2946        show(ac.bensEl, s >= b0);
2947        ac.bens.forEach((ben, i) => {
2948            const tb = b0 + 0.3 + i * (narrow ? 0.26 : 0.3);
2949            const p = progress(s, tb, tb + 0.35);
2950            apply(ben.check, { transform: `scale(${E.outBack(p).toFixed(3)})` });
2951            apply(ben.label, { transform: `translate3d(${r1((1 - E.outExpo(p)) * 12)}px,0,0)`, opacity: op(p * 2) });
2952        });
2953    };
2954
2955    /* ------------------------------------------------------------------ 09 lockup (60–66) */
2956
2957    const renderLockup = (S, t, g, f) => {
2958        const K = S.lk;
2959        const L = S.L;
2960        const on = t >= lockupStart(S);
2961        show(K.root, on);
2962        if (!on) return;
2963        const s = lockupLocal(S, t);
2964
2965        /* the orange field — closes as a hexagon iris back into the opening dot */
2966        const cp = progress(s, 5.0, 5.97);
2967        const r = lerp(L.cover, L.dotR, E.inOutExpo(cp));
2968        g.fillStyle = COL.orange;
2969        g.beginPath();
2970        irisPath(g, L.fx, L.fy, r, 60 * E.inOutCubic(cp), E.inOutCubic(progress(s, 5.5, 5.97)));
2971        g.fill();
2972        if (s > 5.55) sprite(g, S.spr.orange, L.fx, L.fy, L.dotR * 5.5, 0.32 * progress(s, 5.55, 6));
2973
2974        const lp = K.logoPos;
2975        const collapse = E.inExpo(progress(s, 4.9, 5.5));
2976        const settle = 1 - 0.06 * E.inOutCubic(progress(s, 4.45, 4.9));
2977        const ls = settle * (1 - collapse);
2978        const lx = lerp(lp.cx, L.fx, collapse);
2979        const ly = lerp(lp.cy, L.fy, collapse);
2980
2981        /* particle burst → the mark assembles */
2982        const pts = K.pts;
2983        if (s < 1.6) {
2984            const k = lp.k;
2985            const size = Math.max(1.5, k * 1.12);
2986            const alpha = 1 - progress(s, 1.3, 1.55);
2987            const spread = Math.max(L.w, L.h) * 0.55;
2988            f.fillStyle = `rgba(${RGB.black},${alpha.toFixed(3)})`;
2989            const q1 = E.outExpo(progress(s, 0, 0.5));
2990            for (let i = 0; i < pts.length; i++) {
2991                const ang = hash(i, 1) * Math.PI * 2;
2992                const rad = (0.2 + 0.85 * hash(i, 2)) * spread * q1;
2993                const dl = hash(i, 3) * 0.3;
2994                const q2 = E.inOutCubic(progress(s, 0.32 + dl, 0.95 + dl));
2995                const tx = lp.x + pts[i][0] * k;
2996                const ty = lp.y + pts[i][1] * k;
2997                const x = lerp(lp.cx + Math.cos(ang) * rad, tx, q2);
2998                const y = lerp(lp.cy + Math.sin(ang) * rad, ty, q2);
2999                f.fillRect(x - size / 2, y - size / 2, size, size);
3000            }
3001            for (let i = 0; i < 70; i++) {
3002                const ang = hash(i, 11) * Math.PI * 2;
3003                const p = progress(s, 0, 1.2 + hash(i, 12) * 0.4);
3004                const rad = spread * (0.4 + 1.1 * hash(i, 13)) * E.outExpo(p);
3005                const x = lp.cx + Math.cos(ang) * rad;
3006                const y = lp.cy + Math.sin(ang) * rad;
3007                const sz = 2 + 4 * hash(i, 14);
3008                const colr = i % 3 === 0 ? RGB.white : i % 3 === 1 ? RGB.black : RGB.dark;
3009                f.fillStyle = `rgba(${colr},${(1 - p).toFixed(3)})`;
3010                if (i % 4 === 0) {
3011                    f.beginPath();
3012                    f.arc(x, y, sz / 2, 0, Math.PI * 2);
3013                    f.fill();
3014                } else f.fillRect(x - sz / 2, y - sz / 2, sz, sz);
3015            }
3016        }
3017
3018        /* echo of the ignition burst as the mark lands */
3019        const b1 = progress(s, 1.15, 1.5);
3020        const b2 = progress(s, 1.3, 1.9);
3021        if (b1 > 0 && b2 < 1) {
3022            g.save();
3023            g.translate(lp.cx, lp.cy);
3024            g.strokeStyle = COL.black;
3025            g.lineWidth = Math.max(2, L.u * 0.55);
3026            for (let k = 0; k < 6; k++) {
3027                const a = (-90 + 60 * k) * DEG;
3028                const r0 = lp.R * 1.14;
3029                const head = r0 + lp.R * 0.55 * E.outExpo(b1);
3030                const tail = r0 + lp.R * 0.55 * E.inOutCubic(b2);
3031                if (head <= tail) continue;
3032                g.beginPath();
3033                g.moveTo(Math.cos(a) * tail, Math.sin(a) * tail);
3034                g.lineTo(Math.cos(a) * head, Math.sin(a) * head);
3035                g.stroke();
3036            }
3037            g.restore();
3038        }
3039
3040        apply(K.logo, { opacity: op(progress(s, 1.15, 1.4)), transform: pose(lx - (lp.cx - lp.x), ly - (lp.cy - lp.y), { s: ls }) });
3041        const rp = progress(s, 1.35, 2.0);
3042        const rp2 = K.ringPos;
3043        apply(K.ring.el, {
3044            opacity: op(E.outCubic(rp) * (1 - progress(s, 4.9, 5.3))),
3045            transform: pose(lx - (rp2.cx - rp2.x), ly - (rp2.cy - rp2.y), { r: -30 + 14 * s + 220 * E.inExpo(progress(s, 4.5, 5.4)), s: lerp(0.85, 1, E.outExpo(rp)) * ls }),
3046        });
3047
3048        if (K.textOn) {
3049            K.lines.forEach((ln, j) => {
3050                const p = E.outExpo(progress(s, 1.45 + j * 0.12, 2.1 + j * 0.12));
3051                const q = E.inExpo(progress(s, 4.4 + j * 0.05, 4.8 + j * 0.05));
3052                apply(ln.inner, { transform: `translate3d(0,${r1((1 - p) * 105 - q * 105)}%,0)` });
3053            });
3054            const rl = E.outExpo(progress(s, 1.8, 2.5)) * (1 - E.inExpo(progress(s, 4.4, 4.75)));
3055            apply(K.rule, { transform: `scaleX(${rl.toFixed(4)})` });
3056            const sp = E.outExpo(progress(s, 2.0, 2.6));
3057            const so = 1 - progress(s, 4.4, 4.7);
3058            apply(K.strap, { opacity: op(sp * so), transform: `translate3d(0,${r1((1 - sp) * 12)}px,0)` });
3059            const tp = progress(s, 2.25, 3.1);
3060            setText(K.stats, K.statsText.slice(0, Math.round(K.statsText.length * tp)));
3061            apply(K.stats, { opacity: op(so) });
3062        }
3063    };
3064
3065    /* ------------------------------------------------------------------ flash + hud */
3066
3067    const renderFlash = (S, t) => {
3068        if (S.studio) {
3069            apply(S.flash, { visibility: 'hidden' });
3070            return;
3071        }
3072        const cut = SC.effects.start;
3073        const up = E.inExpo(progress(t, cut - 0.3, cut));
3074        const down = 1 - E.outCubic(progress(t, cut, cut + 0.35));
3075        const a = t < cut ? up : t < cut + 0.35 ? down : 0;
3076        apply(S.flash, { opacity: op(a), visibility: a > 0.001 ? 'visible' : 'hidden' });
3077    };
3078
3079    /** Lockup timing: it absorbs the account slot when there is no account data. */
3080    const lockupStart = (S) => (S.ac && S.ac.on ? SC.lockup.start : SC.account.start);
3081    const lockupLocal = (S, t) => {
3082        const start = lockupStart(S);
3083        if (t < POSTER) return lerp(0, 3.3, (t - start) / (POSTER - start));
3084        return lerp(3.3, SC.lockup.design, (t - POSTER) / (SC.lockup.end - POSTER));
3085    };
3086
3087    const toneAt = (S, t) => {
3088        if (t < 5) return 'dark';
3089        if (t < 10) return 'orange';
3090        if (t < SC.rigging.start) return 'dark';
3091        if (t < SC.power.start) return 'cream';
3092        if (t < SC.effects.start) return 'orange';
3093        if (t < SV_START) return 'dark';
3094        if (t < SV_END) return (S.sv.items[serviceSlot(S, t)] || {}).tone || 'dark';
3095        if (t < lockupStart(S)) return 'dark';
3096        return lockupLocal(S, t) < 5.25 ? 'orange' : 'dark';
3097    };
3098
3099    const emitCategory = (S, t) => {
3100        const scene = SCENES[sceneIndex(t)];
3101        const key = scene.category || null;
3102        if (S.lastCat === key) return;
3103        S.lastCat = key;
3104        const cat = key ? S.D.cats[key] : null;
3105        S.ctx.emit('category', cat ? { key, title: cat.title, url: cat.url, count: cat.count } : null);
3106    };
3107
3108    /* ------------------------------------------------------------------ concept */
3109
3110    const concept = {
3111        duration: DURATION,
3112        poster: POSTER,
3113        scenes: SCENES,
3114        templates: TEMPLATES,
3115
3116        setup(ctx) {
3117            const options = ctx.options || {};
3118            const studio = !!(options.studio || options.template);
3119            const S = { ctx, D: prepareData(ctx.data, studio), dead: false, lastT: 0, lastCat: undefined, harness: !!ctx.root.dataset.signalDock, studio };
3120            const template = TEMPLATES.find((tpl) => tpl.id === options.template);
3121            if (template && template.id.startsWith('service-')) S.svForce = { key: template.id.slice(8), start: template.range[0] };
3122            ctx.__sg = S;
3123            ctx.root.classList.add('sg-reel');
3124            S.spr = { orange: makeSprite(RGB.orange), cream: makeSprite(RGB.cream), white: makeSprite(RGB.white, 128, 2.4) };
3125
3126            S.back = U.createCanvas(ctx.stage, 'reel-canvas sg-back');
3127            S.layers = h('div', { class: 'sg-layers' });
3128            ctx.stage.appendChild(S.layers);
3129            S.ig = buildIgnition(S);
3130            S.st = buildStatement(S);
3131            S.cats = {};
3132            CATEGORY_KEYS.forEach((key) => (S.cats[key] = buildCategory(S, key)));
3133            S.sv = buildService(S);
3134            S.ac = buildAccount(S);
3135            S.lk = buildLockup(S);
3136            [S.ig.root, S.st.root, ...CATEGORY_KEYS.map((k) => S.cats[k].root), S.sv.root, S.ac.root, S.lk.root].forEach((el) => S.layers.appendChild(el));
3137            S.front = U.createCanvas(ctx.stage, 'reel-canvas sg-front');
3138            S.flash = h('div', { class: 'sg-flash' });
3139            ctx.stage.appendChild(S.flash);
3140
3141            /* images in the order the reel needs them */
3142            const servicePlates = S.sv.items.map((item) => item.plate).filter(Boolean);
3143            loadPlates(S, [...S.cats.lighting.plates, ...S.cats.rigging.plates, ...S.cats.power.plates, ...S.cats.effects.plates, ...servicePlates]);
3144            ctx.images.preload(S.sv.items.flatMap((item) => (item.spares || []).map((p) => p.image)), 1);
3145
3146            /* re-measure once the display face is ready (type is fitted to exact glyph metrics) */
3147            const fonts = document.fonts;
3148            if (fonts && fonts.load) {
3149                Promise.all([fonts.load(`700 100px 'Space Grotesk'`), fonts.load(`500 100px 'Space Grotesk'`), fonts.load(`600 100px 'Space Grotesk'`)])
3150                    .catch(() => null)
3151                    .then(() => {
3152                        if (S.dead) return;
3153                        readMetrics();
3154                        layout(S);
3155                        concept.render(ctx, S.lastT);
3156                    });
3157            }
3158            readMetrics();
3159        },
3160
3161        resize(ctx) {
3162            const S = ctx.__sg;
3163            if (!S) return;
3164            S.back.resize(ctx.layout);
3165            S.front.resize(ctx.layout);
3166            layout(S);
3167        },
3168
3169        render(ctx, t) {
3170            const S = ctx.__sg;
3171            if (!S || !S.L) return;
3172            S.lastT = t;
3173            const tone = toneAt(S, t);
3174            apply(ctx.stage, { background: t >= lockupStart(S) ? COL.black : TONES[tone].bg });
3175            const g = S.back.ctx;
3176            const f = S.front.ctx;
3177            S.back.clear(ctx.layout);
3178            S.front.clear(ctx.layout);
3179            renderIgnition(S, t, g, f);
3180            renderStatement(S, t, g);
3181            renderLighting(S, t, g);
3182            renderRigging(S, t, g);
3183            renderPower(S, t, g, f);
3184            renderEffects(S, t, g, f);
3185            renderService(S, t, g, f);
3186            renderAccount(S, t, g);
3187            renderLockup(S, t, g, f);
3188            renderFlash(S, t);
3189            emitCategory(S, t);
3190        },
3191
3192        destroy(ctx) {
3193            if (ctx.__sg) ctx.__sg.dead = true;
3194        },
3195    };
3196
3197    Reel.concepts.signal = concept;
3198})();

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.