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.