/* * Complide Enterprise — scroll-world engine. * * True scroll-world technique: one continuous generated camera-flight video * (5 Veo dive-in clips joined by first/last-frame connector clips, so every * seam is frame-identical — no cuts). Scroll position only drives time: the * video is encoded scrub-friendly and its currentTime is scrubbed by a * lerped scroll progress. * * If the flight video is missing or fails, the engine falls back to the * still-image mode: exponential zoom-through + crossfade connectors built * from the same diorama stills. */ (function () { "use strict"; var scenes = Array.prototype.slice.call(document.querySelectorAll(".scene")); var cards = Array.prototype.slice.call(document.querySelectorAll(".card")); var dots = Array.prototype.slice.call(document.querySelectorAll(".rail__dots li")); var rail = document.querySelector(".rail"); var railFill = document.getElementById("railFill"); var spacer = document.getElementById("worldSpacer"); var world = document.getElementById("world"); var video = document.getElementById("flightVideo"); var N = scenes.length; var SCENE_VH = 2.2; // scroll length per scene, in viewport heights var TAIL_VH = 0.4; // extra runway after the last scene // Segment layout of flight.mp4 (written by tools/assemble_flight.sh). // Dives carry the copy cards; connectors are the in-between flights. var TIMELINE = window.FLIGHT_TIMELINE || null; var reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches; var LERP = reducedMotion ? 1 : 0.1; var scrollLen = 1; var target = 0; var current = 0; var videoMode = false; var duration = 0; // A scrollTo we issue ourselves (autoplay, or a nav jump) must not be read // back as the reader taking control — set right before the call, cleared by // the very next 'scroll' event it produces. var programmaticScroll = false; scenes.forEach(function (s, i) { s.style.zIndex = String(N - i); }); /* ── shared helpers ─────────────────────────────────────────────────── */ var clamp01 = function (v) { return Math.min(1, Math.max(0, v)); }; var easeInCubic = function (t) { return t * t * t; }; var easeOutCubic = function (t) { return 1 - Math.pow(1 - t, 3); }; function layout() { scrollLen = Math.max(1, (N * SCENE_VH + TAIL_VH) * window.innerHeight); spacer.style.height = scrollLen + "px"; readScroll(); current = target; render(current); } function readScroll() { var max = Math.max(1, spacer.offsetTop + scrollLen - window.innerHeight); target = clamp01(window.scrollY / max); } /* ── video mode ─────────────────────────────────────────────────────── */ // Maps global progress → [segment index, local t within segment]. // Scroll is distributed over segments proportionally to their duration. function segmentAt(p) { var t = p * duration; for (var i = 0; i < TIMELINE.length; i++) { if (t <= TIMELINE[i].end || i === TIMELINE.length - 1) { var seg = TIMELINE[i]; return { seg: seg, i: i, local: clamp01((t - seg.start) / (seg.end - seg.start)) }; } } } function renderVideo(p) { var t = p * duration; // Never seek to the exact end (some browsers show a black frame there). if (video.readyState >= 1) video.currentTime = Math.min(t, duration - 0.05); var pos = segmentAt(p); var isDive = pos.seg.name.indexOf("dive_") === 0; var sceneIdx = isDive ? Math.floor(pos.i / 2) : Math.floor((pos.i + 1) / 2); // Cards live inside dive segments: in after 10%, out by 80%. for (var i = 0; i < N; i++) { var card = cards[i]; var o = 0, y = 30; if (isDive && Math.floor(pos.i / 2) === i) { var lt = pos.local; var fadeIn = i === 0 && p < 0.002 ? 1 : clamp01(lt / 0.12); var fadeOut = 1 - clamp01((lt - 0.72) / 0.16); o = Math.min(fadeIn, fadeOut); y = 30 * (1 - fadeIn) - 60 * (1 - fadeOut); } card.style.opacity = o.toFixed(4); card.style.transform = "translate(0, calc(-50% + " + y.toFixed(1) + "px))"; card.classList.toggle("is-live", o > 0.5); } updateChrome(p, sceneIdx); } /* ── still-image fallback mode ──────────────────────────────────────── */ var ZOOM_THROUGH = 3.1; var UNDER = 0.68; var CF = 0.22; function renderStills(p) { var seg = 1 / N; for (var i = 0; i < N; i++) { var scene = scenes[i]; var img = scene.firstElementChild; var isLast = i === N - 1; var t = (p - i * seg) / seg; if (t <= -CF || t >= 1 + (isLast ? 1 : 0.0001)) { if (scene.style.opacity !== "0") scene.style.opacity = "0"; continue; } var scale, opacity, blur = 0; if (t < 0) { var k = easeOutCubic(clamp01((t + CF) / CF)); scale = UNDER + (1 - UNDER) * k; opacity = k; } else { var tt = clamp01(isLast ? Math.min(t, 0.72) : t); scale = Math.exp(Math.log(ZOOM_THROUGH) * easeInCubic(tt)); if (!isLast && t > 1 - CF) { var k2 = clamp01((t - (1 - CF)) / CF); opacity = 1 - k2; blur = 6 * k2; } else { opacity = 1; } } scene.style.opacity = opacity.toFixed(4); img.style.transform = "scale(" + scale.toFixed(5) + ")"; scene.style.filter = blur > 0.05 ? "blur(" + blur.toFixed(2) + "px)" : "none"; } for (var j = 0; j < N; j++) { var card = cards[j]; var t2 = (p - j * seg) / seg; var o = 0, y = 30; if (t2 >= -0.02 && t2 <= 0.86) { var fadeIn = j === 0 && p < 0.002 ? 1 : clamp01(t2 / 0.04); var fadeOut = 1 - clamp01((t2 - 0.72) / 0.14); o = Math.min(fadeIn, fadeOut); y = 30 * (1 - fadeIn) - 60 * (1 - fadeOut); } card.style.opacity = o.toFixed(4); card.style.transform = "translate(0, calc(-50% + " + y.toFixed(1) + "px))"; card.classList.toggle("is-live", o > 0.5); } updateChrome(p, Math.min(N - 1, Math.floor(p * N))); } /* ── shared chrome (rail, visibility) ───────────────────────────────── */ function updateChrome(p, sceneIdx) { railFill.style.height = (p * 100).toFixed(2) + "%"; dots.forEach(function (d, i) { d.classList.toggle("is-active", i === sceneIdx); }); rail.classList.toggle("is-visible", p > 0.01 && p < 0.995); world.style.visibility = p >= 1 && window.scrollY > scrollLen ? "hidden" : "visible"; } function render(p) { if (videoMode) renderVideo(p); else renderStills(p); } function tick() { current += (target - current) * LERP; if (Math.abs(target - current) < 0.00005) current = target; render(current); requestAnimationFrame(tick); } /* ── boot: use the flight video when available ──────────────────────── */ function enableVideoMode() { duration = TIMELINE[TIMELINE.length - 1].end; videoMode = true; world.classList.add("world--video"); render(current); } if (video && TIMELINE) { // Big/hi-dpi screens get the 1440p build when the host serves it; // on a 404 (e.g. running from the repo, which only ships 1080p) the // engine drops down to flight.mp4, then to the stills fallback. var sources = ["assets/flight.mp4"]; var effectiveWidth = window.screen.width * (window.devicePixelRatio || 1); if (effectiveWidth >= 2200) sources.unshift("assets/flight_1440.mp4"); var tryNext = function () { var src = sources.shift(); if (!src) { videoMode = false; return; } video.src = src; video.load(); }; video.addEventListener("loadedmetadata", enableVideoMode); video.addEventListener("error", tryNext); tryNext(); } /* ── nav deep-links into the flight ─────────────────────────────────── */ var jumpMap = { "#world-gate": 1, "#world-vault": 2, "#world-bunker": 3, "#world-command": 4 }; document.querySelectorAll('a[href^="#world-"]').forEach(function (a) { a.addEventListener("click", function (e) { var idx = jumpMap[a.getAttribute("href")]; if (idx === undefined) return; e.preventDefault(); var frac; if (videoMode) { // Aim at 25% into the scene's dive segment. var seg = TIMELINE[idx * 2]; frac = (seg.start + (seg.end - seg.start) * 0.25) / duration; } else { frac = idx / N + 0.35 / N; } programmaticScroll = true; window.scrollTo({ top: frac * scrollLen, behavior: reducedMotion ? "auto" : "smooth" }); }); }); /* ── auto-play ───────────────────────────────────────────────────────── * On load, arm a 7-second idle timer; if the reader hasn't touched the * page by then, the flight scrolls itself at a natural reading pace. Any * real scroll input — wheel, touch, keys, or dragging the scrollbar — * cancels it immediately and switches the toggle off, so autoplay never * fights a reader who has taken the wheel. The toggle both cancels a * pending auto-start and stops or restarts it once running. */ var AUTOPLAY_IDLE_MS = 7000; var AUTOPLAY_VH_PER_SEC = 0.28; // ~3.6s of scroll per viewport height var autoplayToggle = document.getElementById("autoplayToggle"); var autoplayOn = false; // the toggle's on/off state — true through both // the idle countdown and the active scroll var autoplayActive = false; // animating scroll right now, this instant var autoplayTimer = null; // pending 7s idle timeout var autoplayLastTs = null; function updateAutoplayUI() { if (!autoplayToggle) return; autoplayToggle.classList.toggle("is-playing", autoplayOn); autoplayToggle.setAttribute("aria-pressed", autoplayOn ? "true" : "false"); autoplayToggle.title = autoplayOn ? "Auto-play is on — click to stop" : "Auto-play is off — click to fly through"; } function stopScrolling() { autoplayActive = false; autoplayLastTs = null; } function turnAutoplayOff() { stopScrolling(); if (autoplayTimer) { clearTimeout(autoplayTimer); autoplayTimer = null; } autoplayOn = false; updateAutoplayUI(); // autoplayOn is already false here, so the fullscreenchange handler // above won't loop back into this function when the exit completes. exitFullscreenIfNeeded(); } function armAutoplayTimer() { if (autoplayTimer) clearTimeout(autoplayTimer); autoplayTimer = setTimeout(function () { autoplayTimer = null; if (autoplayOn && !autoplayActive) beginScrolling(); }, AUTOPLAY_IDLE_MS); } function autoplayStep(ts) { if (!autoplayActive) { autoplayLastTs = null; return; } if (autoplayLastTs === null) autoplayLastTs = ts; var dt = (ts - autoplayLastTs) / 1000; autoplayLastTs = ts; var max = Math.max(1, spacer.offsetTop + scrollLen - window.innerHeight); var next = window.scrollY + AUTOPLAY_VH_PER_SEC * window.innerHeight * dt; // Reached the end of the flight: loop back to the top and keep playing, // rather than stopping — autoplay is meant to run as a continuous demo // reel, not a one-shot. if (next >= max - 0.5) { programmaticScroll = true; window.scrollTo(0, 0); autoplayLastTs = null; // restart the dt clock cleanly after the jump requestAnimationFrame(autoplayStep); return; } programmaticScroll = true; window.scrollTo(0, next); requestAnimationFrame(autoplayStep); } function enterFullscreen() { var el = document.documentElement; if (document.fullscreenElement || !el.requestFullscreen) return; // Succeeds when this call chain started from a real click (the toggle); // browsers silently reject it when the 7s idle timer fires with no // gesture behind it at all — nothing to catch that isn't already a // no-op for the reader. var p = el.requestFullscreen(); if (p && p.catch) p.catch(function () {}); } function exitFullscreenIfNeeded() { if (document.fullscreenElement && document.exitFullscreen) { document.exitFullscreen().catch(function () {}); } } // If the reader exits fullscreen themselves (Esc, or the browser's own // exit control), treat it the same as taking the wheel: stop autoplay // rather than leaving it silently running behind the browser chrome again. document.addEventListener("fullscreenchange", function () { if (!document.fullscreenElement && autoplayOn) turnAutoplayOff(); }); function beginScrolling() { if (reducedMotion) return; // Already past the flight (deep in the regular page content below it) — // nothing left to play, and jumping backward to the boundary would just // be a surprising side effect of a stray click. var max = Math.max(1, spacer.offsetTop + scrollLen - window.innerHeight); if (window.scrollY >= max - 0.5) return; enterFullscreen(); autoplayActive = true; requestAnimationFrame(autoplayStep); } if (autoplayToggle && !reducedMotion) { autoplayOn = true; autoplayToggle.classList.add("is-ready"); updateAutoplayUI(); armAutoplayTimer(); autoplayToggle.addEventListener("click", function () { if (autoplayOn) { turnAutoplayOff(); } else { autoplayOn = true; updateAutoplayUI(); beginScrolling(); // explicit request: start now, no delay } }); } // Autoplay scrolls every animation frame, so a "flag it just before our own // scrollTo, clear it on the next scroll event" test cannot tell a real wheel // input apart from our own once it is running — the flag is essentially // always true by the time any scroll event fires. So real input is caught // directly instead: wheel, touch and the scroll-relevant keys all mean the // reader took the wheel, immediately, regardless of what autoplay is doing. // Interactive targets (the toggle itself, nav links) are excluded so // operating them is never misread as "stop autoplay". function isInteractive(el) { return !!(el && el.closest && el.closest("a, button, input, select, textarea")); } function onUserScrollIntent(e) { if (isInteractive(e && e.target)) return; if (autoplayOn) turnAutoplayOff(); } var SCROLL_KEYS = ["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End", " "]; window.addEventListener("wheel", onUserScrollIntent, { passive: true }); window.addEventListener("touchstart", onUserScrollIntent, { passive: true }); window.addEventListener("keydown", function (e) { if (SCROLL_KEYS.indexOf(e.key) !== -1) onUserScrollIntent(e); }); // Backup path for sources that trigger neither (chiefly dragging the // scrollbar thumb): the flag technique still works here specifically // because it only has to catch scrolls that happen while autoplay is NOT // itself mid-step, i.e. during the idle countdown, when there is no // competing programmatic scroll to race against. function onScroll() { readScroll(); if (programmaticScroll) { programmaticScroll = false; } else if (autoplayOn && !autoplayActive) { turnAutoplayOff(); } } window.addEventListener("scroll", onScroll, { passive: true }); window.addEventListener("resize", layout); layout(); requestAnimationFrame(tick); })();