// Shared slide runtime. Every slide is a
with its own // deterministic timeline: CSS animations are paused and seeked, and JS hooks // registered with onTime handle text and state changes. All times are seconds. window.clamp = (v, a = 0, b = 1) => Math.min(b, Math.max(a, v)) window.prog = (t, t0, t1) => clamp((t - t0) / (t1 - t0)) window.ease = (x) => 1 - Math.pow(1 - x, 3) window.easeInOut = (x) => (x < 0.5 ? 4 * x * x * x : 1 - Math.pow(-2 * x + 2, 3) / 2) const icon = { signal: ``, wifi: ``, battery: ``, lock: ``, reload: ``, back: ``, fwd: ``, share: ``, book: ``, tabs: ``, chevL: ``, dots: ``, plus: ``, mic: ``, wave: ``, send: ``, flash: ``, camera: ``, check: ``, fork: ``, cube: ``, arrowUR: ``, menu: ``, } window.icon = icon function fillChrome(root) { for (const el of root.querySelectorAll('.statusbar')) { el.innerHTML = `${el.dataset.time ?? '9:41'}${icon.signal}${icon.wifi}${icon.battery}` } for (const el of root.querySelectorAll('.safari-top')) { el.innerHTML = `
AA${icon.lock}${el.dataset.url}${icon.reload}
` } for (const el of root.querySelectorAll('.safari-bottom')) { el.innerHTML = `${icon.back}${icon.fwd}${icon.share}${icon.book}${icon.tabs}` } for (const el of root.querySelectorAll('[data-icon]')) { el.innerHTML = icon[el.dataset.icon] } } function createTimeline(root) { const hooks = [] const rests = [] const onTime = (fn) => hooks.push(fn) const tl = { root, onTime, // Still beats between the stages of a slide, as [[time, seconds], ...]: anything that // starts at or after `time` starts `seconds` later, so the stage before it finishes and // can hold before the next one begins. CSS animations on the slide shift automatically // once its setup has run; hooks map their own times through at(). beats(list) { rests.push(...list) return tl.at }, at: (t) => t + rests.reduce((sum, [b, r]) => (t >= b - 1e-9 ? sum + r : sum), 0), shiftAnimations() { if (!rests.length) return for (const a of root.getAnimations({ subtree: true })) { a.effect.updateTiming({ delay: tl.at(a.effect.getTiming().delay / 1000) * 1000 }) } }, seek(t) { for (const h of hooks) h(t) for (const a of root.getAnimations({ subtree: true })) { a.pause() a.currentTime = t * 1000 } }, // Typewriter: reveals `text` into `el` from t0 at `cps` chars/sec. typer(el, text, t0, cps = 16) { onTime((t) => { const n = Math.floor(clamp((t - t0) * cps, 0, text.length)) el.textContent = text.slice(0, n) }) return t0 + text.length / cps }, // Swap element HTML between states at given times: [[t, html], ...] states(el, list) { onTime((t) => { let cur = list[0][1] for (const [ts, v] of list) if (t >= ts) cur = v if (el.__cur !== cur) { el.innerHTML = cur el.__cur = cur } }) }, // Toggle a class within [t0, t1) classAt: (el, cls, t0, t1 = Infinity) => onTime((t) => el.classList.toggle(cls, t >= t0 && t < t1)), // Touch indicator that tracks the center of `target` (selector or element). tapOn(target, t0, dur = 0.6) { const screen = root.querySelector('.screen') const tap = document.createElement('div') tap.className = 'tap' tap.style.animation = `tap ${dur}s ease ${t0}s both` screen.appendChild(tap) onTime((t) => { if (t < t0 - 0.05 || t > t0 + dur) return const el = typeof target === 'string' ? root.querySelector(target) : target const s = screen.getBoundingClientRect(), r = el.getBoundingClientRect() const k = s.width / screen.offsetWidth tap.style.left = (r.left + r.width / 2 - s.left) / k + 'px' tap.style.top = (r.top + r.height / 2 - s.top) / k + 'px' }) }, // Reveals `layer` by expanding a rounded clip from `target`'s rect to the full screen. openFrom(layer, target, t0, dur = 0.6) { const screen = root.querySelector('.screen') onTime((t) => { layer.style.opacity = t < t0 ? 0 : 1 if (t < t0) return const o = easeInOut(prog(t, t0, t0 + dur)) if (o >= 1) { layer.style.clipPath = 'none'; return } const el = typeof target === 'string' ? root.querySelector(target) : target const s = screen.getBoundingClientRect(), r = el.getBoundingClientRect(), k = s.width / screen.offsetWidth const top = (r.top - s.top) / k, left = (r.left - s.left) / k const right = screen.offsetWidth - (r.right - s.left) / k, bottom = screen.offsetHeight - (r.bottom - s.top) / k layer.style.clipPath = `inset(${top * (1 - o)}px ${right * (1 - o)}px ${bottom * (1 - o)}px ${left * (1 - o)}px round ${20 + o * 36}px)` }) }, mountFinder: (body, t0, tMax) => window.mountFinder(tl, body, t0, tMax), } return tl } const timelines = new Map() window.deck = { timeline(root) { let tl = timelines.get(root) if (!tl) { fillChrome(root) tl = createTimeline(root) timelines.set(root, tl) } return tl }, // Called from a