Spaces:
Running
Running
| /* | |
| * 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; | |
| 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-ledger": 1, "#world-reviewer": 2, "#world-arena": 3, "#world-frontier": 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; | |
| } | |
| window.scrollTo({ top: frac * scrollLen, behavior: reducedMotion ? "auto" : "smooth" }); | |
| }); | |
| }); | |
| window.addEventListener("scroll", readScroll, { passive: true }); | |
| window.addEventListener("resize", layout); | |
| layout(); | |
| requestAnimationFrame(tick); | |
| })(); | |