/* * 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); })();