Files
exploded-cluster/pilot/mirror.js
T

106 lines
4.3 KiB
JavaScript

// The Mirror - scroll effects. External file: script-src 'self' holds, no inline JS.
// Design constraints: no perpetual work - scroll reads are coalesced into a single rAF,
// per-scene work only runs while that scene intersects the viewport, the pulse loop
// runs only while its scene is visible AND the tab is visible AND motion is allowed,
// and a change to prefers-reduced-motion takes effect live without a reload.
(function () {
"use strict";
var motionQuery = matchMedia("(prefers-reduced-motion: reduce)");
var bar = document.getElementById("bar");
var s20 = document.querySelector("#s20 img");
var ring = document.getElementById("ring");
var s22 = document.getElementById("s22");
var glows = s22 ? [].slice.call(s22.querySelectorAll(".s22-glow")) : [];
var s24 = document.querySelector("#s24 img");
var pulse = document.getElementById("pulse");
// visibility ledger: IntersectionObserver marks which scenes are on screen
var visible = {};
var watched = [
["s20", s20 && s20.closest(".scene")],
["s21", ring && ring.closest(".scene")],
["s22", s22 && s22.closest(".scene")],
["s23", pulse && pulse.closest(".scene")],
["s24", s24 && s24.closest(".scene")],
].filter(function (w) { return w[1]; });
var io = new IntersectionObserver(function (entries) {
entries.forEach(function (e) {
var hit = watched.find(function (w) { return w[1] === e.target; });
if (hit) visible[hit[0]] = e.isIntersecting;
});
if (visible.s23) startPulse(); // (re)arm the breather when its scene returns
onScroll(); // anchor jumps and observer-only transitions update without a scroll event
}, { rootMargin: "10% 0px" });
watched.forEach(function (w) { io.observe(w[1]); });
// progress of an element through the viewport, guarded against zero heights
function prog(el) {
var r = el.getBoundingClientRect();
var span = innerHeight + r.height;
if (!span) return 0;
return Math.max(0, Math.min(1, (innerHeight - r.top) / span));
}
// scroll work: one rAF per burst, only touching scenes that are on screen
var ticking = false;
function frame() {
ticking = false;
var d = document.documentElement;
var denom = d.scrollHeight - innerHeight;
if (bar) bar.style.width = (denom > 0 ? (100 * d.scrollTop / denom) : 0) + "%";
if (motionQuery.matches) return;
if (visible.s20 && s20) {
var p20 = Math.min(1, prog(s20) * 1.6);
s20.style.transform = "scale(" + (1.07 - 0.07 * p20) + ") translateY(" + (26 - 26 * p20) + "px)";
}
if (visible.s21 && ring) ring.style.transform = "rotate(" + (prog(ring) * 300) + "deg)";
if (visible.s22 && glows.length) {
var p22 = prog(s22);
glows.forEach(function (g, i) {
g.style.opacity = Math.max(0, Math.min(0.85, (p22 - (0.18 + i * 0.22)) * 6));
});
}
if (visible.s24 && s24) s24.style.transform = "translateY(" + ((prog(s24) - 0.5) * -26) + "px) scale(1.03)";
}
function onScroll() {
if (!ticking) { ticking = true; requestAnimationFrame(frame); }
}
addEventListener("scroll", onScroll, { passive: true });
addEventListener("resize", onScroll);
// the jam's breathing glow: runs only while worth running, stops itself otherwise
var breathing = false;
function startPulse() {
if (breathing || !pulse || motionQuery.matches || document.hidden || !visible.s23) return;
breathing = true;
var t0 = null;
requestAnimationFrame(function breathe(t) {
if (motionQuery.matches || document.hidden || !visible.s23) {
breathing = false;
pulse.style.opacity = 0.25;
return;
}
if (t0 === null) t0 = t;
pulse.style.opacity = 0.18 + 0.2 * (0.5 + 0.5 * Math.sin((t - t0) / 520));
requestAnimationFrame(breathe);
});
}
document.addEventListener("visibilitychange", function () { if (!document.hidden) { startPulse(); onScroll(); } });
// live preference change: clear transforms so the page settles to its static state
function motionChanged() {
if (motionQuery.matches) {
[s20, ring, s24].forEach(function (el) { if (el) el.style.transform = ""; });
glows.forEach(function (g) { g.style.opacity = 0.35; });
if (pulse) pulse.style.opacity = 0.25;
} else {
startPulse();
onScroll();
}
}
if (motionQuery.addEventListener) motionQuery.addEventListener("change", motionChanged);
startPulse();
onScroll();
})();