| 1 | "use client"; |
| 2 | |
| 3 | import { useEffect, useRef, useState } from "react"; |
| 4 | import { WhalePose } from "@/components/whale-pose"; |
| 5 | import { whaleStateLabels, type WhaleStateKey } from "@/lib/content/whale-states"; |
| 6 | import { |
| 7 | cueWhale, |
| 8 | loadWhaleReference, |
| 9 | onWhaleCue, |
| 10 | WHALE_PERFORMANCE, |
| 11 | type WhaleCue, |
| 12 | type WhaleDirector, |
| 13 | type WhaleReference, |
| 14 | } from "@/lib/whale-motion"; |
| 15 | |
| 16 | /** |
| 17 | * The live v2 whale: the desktop app's own Director and rig, drawn on a |
| 18 | * canvas. Until the reference loads (or if it fails) the static rest pose |
| 19 | * stands in, so the hero never shows an empty box. |
| 20 | * |
| 21 | * Motion budget: 30 fps while visible, nothing while hidden or off screen. |
| 22 | * Under prefers-reduced-motion the Director paints its poster pose once per |
| 23 | * change and never runs a clock. The gaze follows the pointer only at rest, |
| 24 | * and only with motion allowed. |
| 25 | * |
| 26 | * With `perform`, it acts out a short session on its own (WHALE_PERFORMANCE) |
| 27 | * and names each phase in a word beneath it. A reader's choice elsewhere on |
| 28 | * the page (a terminal view) takes over for a while, then the loop resumes. |
| 29 | * |
| 30 | * Known limits: one live whale per page (habitat gaze is module state in the |
| 31 | * vendored reference), and cues are illustrative — this component observes |
| 32 | * no real session. |
| 33 | */ |
| 34 | const CHOSEN_HOLD_MS = 15000; |
| 35 | /** Share of the canvas the 124-unit rig box fills; the rest is room for props. */ |
| 36 | const INSET = 0.72; |
| 37 | |
| 38 | export function WhaleLive({ |
| 39 | locale = "en", |
| 40 | className = "", |
| 41 | follow = true, |
| 42 | perform = false, |
| 43 | }: { |
| 44 | locale?: string; |
| 45 | className?: string; |
| 46 | follow?: boolean; |
| 47 | perform?: boolean; |
| 48 | }) { |
| 49 | const canvasRef = useRef<HTMLCanvasElement>(null); |
| 50 | const [ready, setReady] = useState(false); |
| 51 | const [state, setState] = useState<WhaleStateKey>("rest"); |
| 52 | const labels = whaleStateLabels(locale); |
| 53 | |
| 54 | useEffect(() => { |
| 55 | const canvas = canvasRef.current; |
| 56 | if (!canvas) return; |
| 57 | let cancelled = false; |
| 58 | let frame = 0; |
| 59 | let last = 0; |
| 60 | let visible = true; |
| 61 | let live: { reference: WhaleReference; director: WhaleDirector } | null = null; |
| 62 | const motion = window.matchMedia("(prefers-reduced-motion: reduce)"); |
| 63 | const cleanups: (() => void)[] = []; |
| 64 | |
| 65 | const paint = () => { |
| 66 | if (!live) return; |
| 67 | const box = canvas.getBoundingClientRect(); |
| 68 | if (!box.width || !box.height) return; |
| 69 | const dpr = Math.min(window.devicePixelRatio || 1, 2); |
| 70 | const width = Math.round(box.width * dpr), height = Math.round(box.height * dpr); |
| 71 | if (canvas.width !== width) canvas.width = width; |
| 72 | if (canvas.height !== height) canvas.height = height; |
| 73 | const ctx = canvas.getContext("2d"); |
| 74 | if (!ctx) return; |
| 75 | ctx.setTransform(dpr, 0, 0, dpr, 0, 0); |
| 76 | ctx.clearRect(0, 0, box.width, box.height); |
| 77 | // The rig's 124-unit box holds the body; props (page, pad, wrench, |
| 78 | // calves) reach past it, so the whale draws at INSET of the canvas. |
| 79 | const fit = (Math.min(box.width, box.height) / 124) * INSET; |
| 80 | ctx.translate(box.width / 2, box.height / 2); |
| 81 | ctx.scale(fit, fit); |
| 82 | live.reference.acting.render(ctx, live.director, { |
| 83 | theme: "paper", |
| 84 | size: Math.min(box.width, box.height), |
| 85 | dpr, |
| 86 | lod: 0, |
| 87 | pose: follow ? live.reference.habitat.pose(live.director) : undefined, |
| 88 | }); |
| 89 | }; |
| 90 | |
| 91 | const tick = (now: number) => { |
| 92 | frame = 0; |
| 93 | if (!live || live.director.reduced || !visible || document.hidden) return; |
| 94 | // Never replay time spent hidden: clamp the step like the app does. |
| 95 | const dt = last ? Math.min((now - last) / 1000, 0.1) : 1 / 30; |
| 96 | if (now - last >= 1000 / 31) { |
| 97 | last = now; |
| 98 | live.director.step(dt); |
| 99 | paint(); |
| 100 | } |
| 101 | frame = requestAnimationFrame(tick); |
| 102 | }; |
| 103 | const schedule = () => { |
| 104 | if (!frame && live && !live.director.reduced && visible && !document.hidden) { |
| 105 | last = 0; |
| 106 | frame = requestAnimationFrame(tick); |
| 107 | } |
| 108 | }; |
| 109 | |
| 110 | loadWhaleReference().then((reference) => { |
| 111 | if (cancelled) return; |
| 112 | const director = new reference.acting.Director({ seed: 11, reduced: motion.matches }); |
| 113 | live = { reference, director }; |
| 114 | setReady(true); |
| 115 | paint(); |
| 116 | schedule(); |
| 117 | |
| 118 | let turn = 0; |
| 119 | let step = 0; |
| 120 | let timer: ReturnType<typeof setTimeout> | undefined; |
| 121 | const autoplay = (delay: number) => { |
| 122 | clearTimeout(timer); |
| 123 | if (!perform || motion.matches) return; |
| 124 | timer = setTimeout(() => { |
| 125 | const { cue, seconds } = WHALE_PERFORMANCE[step % WHALE_PERFORMANCE.length]; |
| 126 | step += 1; |
| 127 | cueWhale(cue); |
| 128 | autoplay(seconds * 1000); |
| 129 | }, delay); |
| 130 | }; |
| 131 | const apply = (cue: WhaleCue) => { |
| 132 | if (cue.presence === "Done") turn += 1; |
| 133 | director.set( |
| 134 | cue.presence, |
| 135 | cue.kind ? { kind: cue.kind, observed: true, parallel: cue.parallel } : null, |
| 136 | cue.presence === "Done" |
| 137 | ? { freshness: "Live", turnId: `site-${turn}`, status: "completed" } |
| 138 | : { freshness: "Live" }, |
| 139 | ); |
| 140 | setState(cue.label); |
| 141 | paint(); |
| 142 | schedule(); |
| 143 | // A reader's choice holds the stage, then the session starts over. |
| 144 | if (cue.chosen) { step = 0; autoplay(CHOSEN_HOLD_MS); } |
| 145 | }; |
| 146 | cleanups.push(onWhaleCue(apply)); |
| 147 | autoplay(1200); |
| 148 | cleanups.push(() => clearTimeout(timer)); |
| 149 | |
| 150 | const onMotion = () => { director.setReduced(motion.matches); paint(); schedule(); autoplay(1200); }; |
| 151 | motion.addEventListener("change", onMotion); |
| 152 | cleanups.push(() => motion.removeEventListener("change", onMotion)); |
| 153 | }).catch(() => { /* The static pose stays in place. */ }); |
| 154 | |
| 155 | const onVisibility = () => schedule(); |
| 156 | document.addEventListener("visibilitychange", onVisibility); |
| 157 | cleanups.push(() => document.removeEventListener("visibilitychange", onVisibility)); |
| 158 | |
| 159 | if (typeof IntersectionObserver !== "undefined") { |
| 160 | const io = new IntersectionObserver((entries) => { |
| 161 | visible = entries.some((entry) => entry.isIntersecting); |
| 162 | schedule(); |
| 163 | }); |
| 164 | io.observe(canvas); |
| 165 | cleanups.push(() => io.disconnect()); |
| 166 | } |
| 167 | const resize = new ResizeObserver(() => paint()); |
| 168 | resize.observe(canvas); |
| 169 | cleanups.push(() => resize.disconnect()); |
| 170 | |
| 171 | if (follow) { |
| 172 | // The whale glances toward the pointer anywhere in the hero, not only |
| 173 | // over its own box. Passive: scrolling and clicks are untouched. |
| 174 | const move = (event: PointerEvent) => { |
| 175 | if (!live || motion.matches) return; |
| 176 | const box = canvas.getBoundingClientRect(); |
| 177 | const fit = (Math.min(box.width, box.height) / 124) * INSET; |
| 178 | if (!fit) return; |
| 179 | live.reference.habitat.observe( |
| 180 | (event.clientX - box.left - box.width / 2) / fit, |
| 181 | (event.clientY - box.top - box.height / 2) / fit, |
| 182 | ); |
| 183 | }; |
| 184 | const leave = () => live?.reference.habitat.leave(); |
| 185 | window.addEventListener("pointermove", move, { passive: true }); |
| 186 | document.documentElement.addEventListener("pointerleave", leave, { passive: true }); |
| 187 | cleanups.push(() => { |
| 188 | window.removeEventListener("pointermove", move); |
| 189 | document.documentElement.removeEventListener("pointerleave", leave); |
| 190 | }); |
| 191 | } |
| 192 | |
| 193 | return () => { |
| 194 | cancelled = true; |
| 195 | if (frame) cancelAnimationFrame(frame); |
| 196 | cleanups.forEach((cleanup) => cleanup()); |
| 197 | }; |
| 198 | }, [follow, perform]); |
| 199 | |
| 200 | return ( |
| 201 | <div className={`whale-live ${className}`.trim()} data-ready={ready || undefined}> |
| 202 | <WhalePose pose="rest" priority className="whale-live-poster" /> |
| 203 | <canvas ref={canvasRef} className="whale-live-canvas" aria-hidden="true" /> |
| 204 | {perform && ( |
| 205 | // Decorative narration: hidden from assistive technology so the loop |
| 206 | // never announces itself every few seconds. |
| 207 | <p className="whale-live-state" data-state={state} aria-hidden="true"> |
| 208 | <span className="whale-live-dot" /> |
| 209 | {labels[state]} |
| 210 | </p> |
| 211 | )} |
| 212 | </div> |
| 213 | ); |
| 214 | } |
| 215 |