| 1 | /** Runs homepage motion (marquee, mark drift, caret, parallax) only while its content is visible. */ |
| 2 | export function startHomeMotion({ |
| 3 | hero, |
| 4 | field, |
| 5 | tracks, |
| 6 | caret, |
| 7 | }: { |
| 8 | hero: HTMLElement; |
| 9 | field: HTMLElement; |
| 10 | tracks: HTMLElement[]; |
| 11 | caret: HTMLElement; |
| 12 | }) { |
| 13 | if (typeof IntersectionObserver === "undefined") return () => {}; |
| 14 | |
| 15 | const marks = Array.from(field.querySelectorAll<HTMLElement>(".hero-float-mark")); |
| 16 | const gated = [...marks, ...tracks, caret]; |
| 17 | const visible = new Set<Element>(); |
| 18 | const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)"); |
| 19 | const finePointer = window.matchMedia("(pointer: fine)"); |
| 20 | const events = new AbortController(); |
| 21 | const eventOptions = { signal: events.signal }; |
| 22 | let disposed = false; |
| 23 | let pointerFrame: number | undefined; |
| 24 | let pointer: { x: number; y: number } | null = null; |
| 25 | |
| 26 | const canMove = (element: Element) => |
| 27 | !disposed && |
| 28 | visible.has(element) && |
| 29 | document.visibilityState === "visible" && |
| 30 | !reducedMotion.matches; |
| 31 | const canParallax = () => finePointer.matches && marks.some(canMove); |
| 32 | |
| 33 | function resetPointer() { |
| 34 | if (pointerFrame !== undefined) cancelAnimationFrame(pointerFrame); |
| 35 | pointerFrame = undefined; |
| 36 | pointer = null; |
| 37 | field.style.setProperty("--px", "0px"); |
| 38 | field.style.setProperty("--py", "0px"); |
| 39 | } |
| 40 | |
| 41 | function update() { |
| 42 | for (const element of gated) { |
| 43 | element.style.setProperty("--home-motion-state", canMove(element) ? "running" : "paused"); |
| 44 | } |
| 45 | const parallax = canParallax(); |
| 46 | field.style.setProperty("--parallax-duration", parallax ? "0.7s" : "0s"); |
| 47 | if (!parallax) resetPointer(); |
| 48 | } |
| 49 | |
| 50 | const observer = new IntersectionObserver((entries) => { |
| 51 | if (disposed) return; |
| 52 | for (const entry of entries) { |
| 53 | if (entry.isIntersecting) visible.add(entry.target); |
| 54 | else visible.delete(entry.target); |
| 55 | } |
| 56 | update(); |
| 57 | }); |
| 58 | for (const element of gated) observer.observe(element); |
| 59 | |
| 60 | hero.addEventListener( |
| 61 | "pointermove", |
| 62 | (event) => { |
| 63 | if (!canParallax()) return; |
| 64 | pointer = { x: event.clientX, y: event.clientY }; |
| 65 | pointerFrame ??= requestAnimationFrame(() => { |
| 66 | pointerFrame = undefined; |
| 67 | if (!pointer || !canParallax()) return; |
| 68 | const bounds = hero.getBoundingClientRect(); |
| 69 | if (bounds.width === 0 || bounds.height === 0) return; |
| 70 | field.style.setProperty( |
| 71 | "--px", |
| 72 | `${(((pointer.x - bounds.left) / bounds.width - 0.5) * 36).toFixed(1)}px`, |
| 73 | ); |
| 74 | field.style.setProperty( |
| 75 | "--py", |
| 76 | `${(((pointer.y - bounds.top) / bounds.height - 0.5) * 28).toFixed(1)}px`, |
| 77 | ); |
| 78 | }); |
| 79 | }, |
| 80 | eventOptions, |
| 81 | ); |
| 82 | hero.addEventListener("pointerleave", resetPointer, eventOptions); |
| 83 | document.addEventListener("visibilitychange", update, eventOptions); |
| 84 | reducedMotion.addEventListener("change", update, eventOptions); |
| 85 | finePointer.addEventListener("change", update, eventOptions); |
| 86 | update(); |
| 87 | |
| 88 | return () => { |
| 89 | if (disposed) return; |
| 90 | disposed = true; |
| 91 | events.abort(); |
| 92 | observer.disconnect(); |
| 93 | update(); |
| 94 | }; |
| 95 | } |