Files

102 lines
4.3 KiB
Vue

<!--
The one canvas behind the whole page. Client-only: the HTML ships complete without it,
WebGL is created after hydration and never blocks first paint.
GSAP's ticker drives the frame; ScrollTrigger measures the sections and keeps them in sync on refresh.
-->
<script setup lang="ts">
import { gsap } from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import { createAstrolabe } from "~/lib/astrolabe";
import { storyAt, ecoScrollFor } from "~/lib/story";
import { products, STEP } from "~/data/products";
const canvas = ref<HTMLCanvasElement | null>(null);
const labels = ref<HTMLElement[]>([]);
const story = useStory();
let scene: ReturnType<typeof createAstrolabe> | null = null;
let metrics: any = null;
const triggers: ScrollTrigger[] = [];
onMounted(async () => {
await nextTick();
if (!canvas.value) {
canvas.value = document.querySelector("canvas");
}
if (!canvas.value) return;
gsap.registerPlugin(ScrollTrigger);
const reduced = matchMedia("(prefers-reduced-motion: reduce)").matches;
// Lower-powered devices keep the same instrument, with half the engraving density and a capped DPR.
const lowPower = (navigator.hardwareConcurrency || 8) <= 4 || innerWidth < 600;
scene = createAstrolabe(canvas.value, { products, step: STEP, reducedMotion: reduced, lowPower });
if (!scene) return;
scene.set({ draw: 1 });
const eco = ScrollTrigger.create({ trigger: "#products", start: "top top", end: "bottom bottom" });
const anat = ScrollTrigger.create({ trigger: "#anatomy", start: "top top", end: "bottom bottom" });
const zij = ScrollTrigger.create({ trigger: "#zij", start: "top bottom", end: "bottom top" });
triggers.push(eco, anat, zij);
const measure = () => {
metrics = { vh: innerHeight, ecoTop: eco.start, ecoEnd: eco.end, zijTop: zij.start + innerHeight, anatTop: anat.start, anatEnd: anat.end };
story.value.mobile = innerWidth < 768 || innerWidth / innerHeight < 0.8;
scene?.resize();
};
ScrollTrigger.addEventListener("refresh", measure);
ScrollTrigger.refresh();
document.fonts?.ready?.then(() => ScrollTrigger.refresh());
scene.onFrame(({ center, stars }) => {
const s = story.value;
stars?.forEach(([x, y]: number[], i: number) => {
const el = labels.value[i]; if (!el) return;
let dx = x - center[0], dy = y - center[1]; const l = Math.hypot(dx, dy) || 1; dx /= l; dy /= l;
const show = s.inEco && (!s.mobile || i === s.active);
const off = i === s.active ? 26 : 16;
el.style.opacity = String(show ? (i === s.active ? 1 : 0.62) : 0);
el.style.pointerEvents = show ? "auto" : "none";
el.style.transform = `translate(${x + dx * off}px, ${y + dy * off}px) translate(${dx < -0.2 ? "-100%" : dx > 0.2 ? "0" : "-50%"}, -50%)`;
});
});
gsap.ticker.add(tick);
addEventListener("pointermove", onMove, { passive: true });
story.value.go = (i: number) => {
if (!metrics) return;
const n = Math.max(0, Math.min(14, i));
gsap.killTweensOf(window);
window.scrollTo({ top: ecoScrollFor(n, metrics), behavior: reduced ? "auto" : "smooth" });
};
});
function tick() {
if (!metrics || !scene) return;
const s = story.value;
const r = storyAt(window.scrollY, metrics, s.mobile);
scene.set({ ...r.target, active: r.inEco ? r.ecoIndex : s.hover, focus: r.focus });
if (r.inEco && r.ecoIndex !== s.active) { s.dir = r.ecoIndex > s.active ? 1 : -1; s.active = r.ecoIndex; }
if (r.inEco !== s.inEco) s.inEco = r.inEco;
if (r.focus !== s.focus) s.focus = r.focus;
}
const onMove = (e: PointerEvent) => scene?.pointer(e.clientX / innerWidth - 0.5, e.clientY / innerHeight - 0.5);
onBeforeUnmount(() => {
gsap.ticker.remove(tick);
removeEventListener("pointermove", onMove);
triggers.forEach((t) => t.kill());
scene?.dispose();
});
</script>
<template>
<canvas ref="canvas" aria-hidden="true" class="pointer-events-none fixed inset-0 z-0 h-[100lvh] w-full" />
<div class="pointer-events-none fixed inset-0 z-10" :aria-hidden="!story.inEco">
<button
v-for="(p, i) in products" :key="p.slug" :ref="(el) => (labels[i] = el as HTMLElement)"
tabindex="-1" class="star-label absolute left-0 top-0 whitespace-nowrap px-1 text-[14px] opacity-0"
:class="i === story.active ? 'font-bold text-verm' : 'text-ink'" @click="story.go?.(i)"
>{{ p.fa }}</button>
</div>
</template>