返回 CodeWhale
whale-live.tsx
根目录 / web / components / whale-live.tsx
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
215 lines Plain Text