102 lines
4.3 KiB
Vue
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>
|