253 lines
11 KiB
HTML
253 lines
11 KiB
HTML
<script>
|
||
(() => {
|
||
"use strict";
|
||
const RM = matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||
const heroPin = document.getElementById("hero-pin");
|
||
const crt = document.getElementById("crt");
|
||
const crtbody = document.getElementById("crtbody");
|
||
const caret = document.getElementById("caret");
|
||
const cue = document.getElementById("cue");
|
||
const skip = document.getElementById("skip");
|
||
const titlewrap = document.querySelector(".titlewrap");
|
||
|
||
/* ---------- split the big title into 3D chars ---------- */
|
||
document.querySelectorAll("#bigtitle .x1, #bigtitle .x2").forEach(el => {
|
||
const t = el.textContent; el.textContent = "";
|
||
[...t].forEach(c => {
|
||
const s = document.createElement("span");
|
||
s.className = "ch"; s.textContent = c === " " ? " " : c;
|
||
el.appendChild(s);
|
||
});
|
||
});
|
||
const chars = document.querySelectorAll("#bigtitle .ch");
|
||
|
||
/* ---------- terminal script ---------- */
|
||
const LINES = [
|
||
["p1", "jonny@edge:~$ ", "docker build -t shop/app:0723R1 ."],
|
||
["dm", " => [1/4] FROM ubi9-minimal", " CACHED"],
|
||
["dm", " => [2/4] RUN microdnf install deps", " 0.8s"],
|
||
["dm", " => [3/4] COPY src/ /app", " 0.1s"],
|
||
["dm", " => [4/4] USER 10001", " done"],
|
||
["cy", " => exporting layers ", "sha256:9f2c11ab…"],
|
||
["p1", "jonny@edge:~$ ", "docker push shop/app:0723R1"],
|
||
["p1", "jonny@edge:~$ ", "kubectl apply -f app.yaml"],
|
||
["ok", "deployment.apps/shop-app created", ""],
|
||
["ok", "service/shop-app created", ""],
|
||
["p1", "jonny@edge:~$ ", "kubectl get pods -w"],
|
||
["dm", "shop-app-7d4b9c-x2x3g 0/1 ContainerCreating", ""],
|
||
["ok", "shop-app-7d4b9c-x2x3g 1/1 Running", ""],
|
||
["mg", "…but what actually just happened?", ""]
|
||
];
|
||
let li = 0, done = false;
|
||
function renderAll() { // instant final state (skip / reduced motion / no theatre)
|
||
crtbody.innerHTML = "";
|
||
LINES.forEach(L => {
|
||
const ln = document.createElement("span");
|
||
ln.className = "ln";
|
||
ln.innerHTML = '<span class="' + L[0] + '"></span><span></span>';
|
||
ln.children[0].textContent = L[1]; ln.children[1].textContent = L[2];
|
||
crtbody.appendChild(ln);
|
||
});
|
||
crtbody.appendChild(caret);
|
||
finishIntro();
|
||
}
|
||
function typeLine() {
|
||
if (done) return;
|
||
if (li >= LINES.length) { finishIntro(); return; }
|
||
const L = LINES[li++];
|
||
const ln = document.createElement("span");
|
||
ln.className = "ln";
|
||
const a = document.createElement("span"); a.className = L[0]; a.textContent = L[1];
|
||
const b = document.createElement("span");
|
||
ln.appendChild(a); ln.appendChild(b);
|
||
crtbody.insertBefore(ln, caret);
|
||
const isCmd = L[0] === "p1";
|
||
if (!isCmd) { b.textContent = L[2]; setTimeout(typeLine, 170); return; }
|
||
let i = 0;
|
||
(function tick() {
|
||
if (done) return;
|
||
b.textContent = L[2].slice(0, ++i);
|
||
if (i < L[2].length) setTimeout(tick, 26);
|
||
else setTimeout(typeLine, 320);
|
||
})();
|
||
}
|
||
function finishIntro() {
|
||
if (done) return; done = true;
|
||
skip.style.display = "none";
|
||
cue.style.opacity = 1;
|
||
}
|
||
skip.addEventListener("click", () => { done = false; renderAll(); });
|
||
|
||
/* ---------- tooltip (hover / focus / tap) ---------- */
|
||
const tip = document.getElementById("tip");
|
||
let tipFor = null;
|
||
function showTip(el) {
|
||
tipFor = el;
|
||
tip.innerHTML = "<b></b>";
|
||
tip.firstChild.textContent = el.textContent.trim();
|
||
tip.appendChild(document.createTextNode(el.dataset.def));
|
||
tip.classList.add("show"); tip.setAttribute("aria-hidden", "false");
|
||
const r = el.getBoundingClientRect(), tw = tip.offsetWidth, th = tip.offsetHeight;
|
||
let x = Math.max(10, Math.min(r.left + r.width / 2 - tw / 2, innerWidth - tw - 10));
|
||
let y = r.top - th - 10; if (y < 10) y = r.bottom + 10;
|
||
tip.style.left = x + "px"; tip.style.top = y + "px";
|
||
}
|
||
function hideTip() { tipFor = null; tip.classList.remove("show"); tip.setAttribute("aria-hidden", "true"); }
|
||
document.querySelectorAll(".term").forEach(el => {
|
||
el.addEventListener("mouseenter", () => showTip(el));
|
||
el.addEventListener("mouseleave", hideTip);
|
||
el.addEventListener("focus", () => showTip(el));
|
||
el.addEventListener("blur", hideTip);
|
||
el.addEventListener("click", ev => { ev.preventDefault();
|
||
(tipFor === el && tip.classList.contains("show")) ? hideTip() : showTip(el); });
|
||
});
|
||
addEventListener("scroll", () => { if (tipFor) hideTip(); }, { passive: true });
|
||
|
||
/* ---------- progress bar ---------- */
|
||
const bar = document.getElementById("bar");
|
||
let raf = 0;
|
||
addEventListener("scroll", () => {
|
||
if (raf) return;
|
||
raf = requestAnimationFrame(() => {
|
||
const h = document.documentElement;
|
||
bar.style.transform = "scaleX(" + (h.scrollTop / (h.scrollHeight - h.clientHeight || 1)) + ")";
|
||
raf = 0;
|
||
});
|
||
}, { passive: true });
|
||
|
||
/* ---------- section reveal + nav (IO, all contexts) ---------- */
|
||
const navLinks = document.querySelectorAll("nav a");
|
||
const io = new IntersectionObserver(ents => ents.forEach(e => {
|
||
if (!e.isIntersecting) return;
|
||
e.target.classList.add("live");
|
||
const id = e.target.id;
|
||
if (id) navLinks.forEach(a => a.classList.toggle("on", a.getAttribute("href") === "#" + id));
|
||
}), { threshold: 0.3 });
|
||
document.querySelectorAll("section").forEach(s => io.observe(s));
|
||
|
||
/* ================= THE THEATRE (motion-safe contexts only) ================= */
|
||
if (RM || typeof gsap === "undefined") { renderAll(); document.body.classList.remove("fx"); return; }
|
||
document.body.classList.add("fx");
|
||
gsap.registerPlugin(ScrollTrigger);
|
||
ScrollTrigger.config({ ignoreMobileResize: true });
|
||
|
||
/* particles: capped starfield with scroll parallax */
|
||
const cv = document.getElementById("stars"), cx = cv.getContext("2d");
|
||
let W, H, DPR, stars = [];
|
||
function sizeCv() {
|
||
DPR = Math.min(devicePixelRatio || 1, 2);
|
||
W = cv.width = innerWidth * DPR; H = cv.height = innerHeight * DPR;
|
||
cv.style.width = innerWidth + "px"; cv.style.height = innerHeight + "px";
|
||
stars = Array.from({ length: innerWidth < 720 ? 46 : 90 }, () => ({
|
||
x: Math.random() * W, y: Math.random() * H,
|
||
z: .3 + Math.random() * .7, r: (.6 + Math.random() * 1.5) * DPR,
|
||
tw: Math.random() * Math.PI * 2, c: Math.random() < .12 ? "232,121,249" : "63,186,245"
|
||
}));
|
||
}
|
||
sizeCv(); addEventListener("resize", sizeCv);
|
||
let lastY = 0, running = true;
|
||
document.addEventListener("visibilitychange", () => { running = !document.hidden; if (running) loop(); });
|
||
function loop(t) {
|
||
if (!running) return;
|
||
const dy = (scrollY - lastY) * .18; lastY = scrollY;
|
||
cx.clearRect(0, 0, W, H);
|
||
for (const s of stars) {
|
||
s.y -= dy * s.z * DPR; s.tw += .02;
|
||
if (s.y < -8) s.y += H + 16; else if (s.y > H + 8) s.y -= H + 16;
|
||
cx.globalAlpha = .25 + .45 * s.z * (0.7 + 0.3 * Math.sin(s.tw));
|
||
cx.fillStyle = "rgba(" + s.c + ",1)";
|
||
cx.beginPath(); cx.arc(s.x, s.y, s.r * s.z, 0, 7); cx.fill();
|
||
}
|
||
cx.globalAlpha = 1;
|
||
requestAnimationFrame(loop);
|
||
}
|
||
requestAnimationFrame(loop);
|
||
|
||
/* card tilt (hover-capable pointers only) */
|
||
if (matchMedia("(hover:hover)").matches) {
|
||
let tiltRaf = 0;
|
||
document.addEventListener("pointermove", ev => {
|
||
const card = ev.target.closest(".card"); if (!card) return;
|
||
if (tiltRaf) return;
|
||
tiltRaf = requestAnimationFrame(() => {
|
||
const r = card.getBoundingClientRect();
|
||
const px = (ev.clientX - r.left) / r.width, py = (ev.clientY - r.top) / r.height;
|
||
card.style.setProperty("--ry", ((px - .5) * 16).toFixed(2) + "deg");
|
||
card.style.setProperty("--rx", ((.5 - py) * 12).toFixed(2) + "deg");
|
||
card.style.setProperty("--gx", (px * 100).toFixed(1) + "%");
|
||
card.style.setProperty("--gy", (py * 100).toFixed(1) + "%");
|
||
tiltRaf = 0;
|
||
});
|
||
}, { passive: true });
|
||
document.addEventListener("pointerout", ev => {
|
||
const card = ev.target.closest(".card"); if (!card) return;
|
||
card.style.setProperty("--rx", "0deg"); card.style.setProperty("--ry", "0deg");
|
||
}, { passive: true });
|
||
}
|
||
|
||
/* start typing the terminal */
|
||
gsap.set(titlewrap, { autoAlpha: 0 });
|
||
gsap.set(chars, { z: -600, autoAlpha: 0, rotateX: -70 });
|
||
setTimeout(typeLine, 500);
|
||
|
||
/* COURSE 00 — pin: terminal tilts back, flies apart; title chars fly in from depth */
|
||
const mm = gsap.matchMedia();
|
||
mm.add("(min-width: 721px)", () => {
|
||
gsap.timeline({
|
||
scrollTrigger: { trigger: heroPin, start: "top top", end: "+=1600", scrub: .6, pin: true }
|
||
})
|
||
.to(crt, { rotateX: 38, z: -180, y: -60, ease: "power1.in" }, 0)
|
||
.to(crt, { autoAlpha: 0, scale: 1.25, rotateX: 62, y: -220, ease: "power2.in", duration: .6 }, .35)
|
||
.to(cue, { autoAlpha: 0, duration: .15 }, 0)
|
||
.to("#skip", { autoAlpha: 0, duration: .1 }, 0)
|
||
.to(titlewrap, { autoAlpha: 1, duration: .2 }, .45)
|
||
.to(chars, { z: 0, autoAlpha: 1, rotateX: 0, stagger: { each: .012, from: "random" }, ease: "back.out(1.4)" }, .5)
|
||
.to("#bigtitle", { scale: .96, y: -14, ease: "none" }, .9);
|
||
|
||
/* COURSE 01 — pin: layers rise from the deep into the tilted stack */
|
||
const layers = gsap.utils.toArray(".layer3d");
|
||
gsap.set(layers, { z: -650, autoAlpha: 0, y: 260 });
|
||
gsap.set(".laycard", { autoAlpha: 0, y: 40 });
|
||
gsap.timeline({
|
||
scrollTrigger: { trigger: "#lay-stage", start: "top top", end: "+=1800", scrub: .6, pin: true }
|
||
})
|
||
.to(layers[0], { z: 0, autoAlpha: 1, y: 150, ease: "power2.out" })
|
||
.to(layers[1], { z: 46, autoAlpha: 1, y: 75, ease: "power2.out" })
|
||
.to(layers[2], { z: 92, autoAlpha: 1, y: 0, ease: "power2.out" })
|
||
.to(layers[3], { z: 138, autoAlpha: 1, y: -75, ease: "power2.out" })
|
||
.to("#laystack", { rotateX: 24, rotateZ: -6, ease: "none" }, "<")
|
||
.to(".laycard", { autoAlpha: 1, y: 0, stagger: .15 })
|
||
.to("#laystack", { rotateX: 10, rotateZ: 0, ease: "none" });
|
||
|
||
/* COURSE 05 — pin: the ring of opinions spins around the core */
|
||
gsap.timeline({
|
||
scrollTrigger: { trigger: "#orbit-stage", start: "top top", end: "+=2000", scrub: .7, pin: true }
|
||
})
|
||
.fromTo("#orbit", { rotateY: -15, rotateX: 8 }, { rotateY: 285, rotateX: 8, ease: "none" }, 0)
|
||
.fromTo("#core > .card", { scale: .7, autoAlpha: 0 }, { scale: 1, autoAlpha: 1, duration: .12 }, 0);
|
||
|
||
return () => {}; /* matchMedia cleanup handled by GSAP */
|
||
});
|
||
|
||
/* small screens: no pins — everything just assembles via IO; terminal still types */
|
||
mm.add("(max-width: 720px)", () => {
|
||
gsap.set(titlewrap, { autoAlpha: 0 });
|
||
ScrollTrigger.create({
|
||
trigger: heroPin, start: "bottom 70%",
|
||
onEnter: () => {
|
||
gsap.to(crt, { autoAlpha: 0, y: -80, duration: .5 });
|
||
gsap.to(titlewrap, { autoAlpha: 1, duration: .5 });
|
||
gsap.to(chars, { z: 0, autoAlpha: 1, rotateX: 0, stagger: .01, duration: .6 });
|
||
}
|
||
});
|
||
gsap.set(".layer3d", { clearProps: "all" });
|
||
gsap.set(".laycard", { clearProps: "all" });
|
||
gsap.fromTo("#orbit", { rotateY: -15 }, {
|
||
rotateY: 285, ease: "none",
|
||
scrollTrigger: { trigger: "#orbit-stage", start: "top 80%", end: "bottom 20%", scrub: .7 }
|
||
});
|
||
return () => {};
|
||
});
|
||
})();
|
||
</script>
|