enterprise-landing / scroll-world.js
aayu-outdooragi's picture
Autoplay loops back to the top instead of stopping, goes fullscreen on explicit start
4b9a56b verified
Raw
History Blame Contribute Delete
16 kB
/*
* 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);
})();