Spaces:
Running
Running
Autoplay loops back to the top instead of stopping, goes fullscreen on explicit start
4b9a56b verified | /* | |
| * 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); | |
| })(); | |