vojo/src/app/components/stream-header/RadarPulse.tsx

241 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useCallback, useEffect, useRef, useState } from 'react';
import { PULSE_EDGE_PX, PULSE_SPAN_PX } from './geometry';
import * as css from './StreamHeader.css';
// ─── The approved ring field, byte-for-byte ──────────────────────────
// 10 rings; each lives one 4s ease-out pass: scale 0.05 → 1 of a
// 760²-box circle (r 380, stroke 2 SCALING with the ring — 0.1px when
// tiny, a crisp 2px at full size), opacity 0 → 0.26 (at 10%) → 0.13
// (at 65%) → 0, spawn stagger 0.4s. DO NOT RETUNE — see geometry.ts::
// PULSE_SPAN_PX for why the box size is also load-bearing.
const LOOP_MS = 4000;
const RING_COUNT = 10;
const STAGGER_MS = 400;
const RING_RADIUS_PX = PULSE_SPAN_PX / 2;
const STROKE_PX = 2;
const STROKE_STYLE = 'rgb(248, 80, 160)';
const MIN_SCALE = 0.05;
// Lifetime of a one-shot echo ring — the 4s pulse plus a small buffer
// before its entry is pruned from React state.
const ECHO_LIFETIME_MS = 4100;
// CSS `ease-out` = cubic-bezier(0, 0, 0.58, 1). x(u) is strictly
// monotonic on [0, 1], so a fixed-depth bisection inverts time → u and
// the eased progress is y(u). 20 iterations ≈ 1e-6 — and a frame does
// ~2 solves per ring, noise next to the raster cost of the frame.
function easeOut(t: number): number {
if (t <= 0) return 0;
if (t >= 1) return 1;
let lo = 0;
let hi = 1;
for (let i = 0; i < 20; i += 1) {
const u = (lo + hi) / 2;
const x = 3 * (1 - u) * u * u * 0.58 + u * u * u;
if (x < t) lo = u;
else hi = u;
}
const u = (lo + hi) / 2;
return 3 * (1 - u) * u * u + u * u * u;
}
// The approved opacity keyframes. Per CSS keyframe semantics the
// timing function applies PER SEGMENT (between consecutive keyframes
// that specify the property), so each span below gets its own ease-out
// of the local progress — exactly what the old `ringKeyframes` did.
const OPACITY_STOPS: ReadonlyArray<readonly [number, number]> = [
[0, 0],
[0.1, 0.26],
[0.65, 0.13],
[1, 0],
];
function ringAlpha(phase: number): number {
for (let i = 1; i < OPACITY_STOPS.length; i += 1) {
const [k1, a1] = OPACITY_STOPS[i];
if (phase <= k1) {
const [k0, a0] = OPACITY_STOPS[i - 1];
return a0 + (a1 - a0) * easeOut((phase - k0) / (k1 - k0));
}
}
return 0;
}
// One-shot echo ring spawned by a mascot tap. `startedAt` is a
// performance.now() timestamp — the same timebase as the rAF clock the
// field draws from, so an echo's phase is absolute: a host remount
// mid-flight RESUMES the echo where it was instead of replaying it
// (the old CSS-animation circles restarted from 0 on remount; resuming
// is the closer match to «one pass per tap»).
export type EchoPulse = { id: number; startedAt: number };
// Tap-to-poke echo state. Each `spawnEcho` call appends a one-shot
// ring; a timer prunes it once its pulse has faded. Hosted by the
// component that renders the `RadarPulse` (StreamHeader, or
// PagerRefreshSingleton in pager mode) so the echoes live exactly as
// long as their host's reveal window.
export function useEchoes(): { echoes: EchoPulse[]; spawnEcho: () => void } {
const [echoes, setEchoes] = useState<EchoPulse[]>([]);
const echoIdRef = useRef(0);
const spawnEcho = useCallback(() => {
const id = echoIdRef.current;
echoIdRef.current += 1;
setEchoes((prev) => [...prev, { id, startedAt: performance.now() }]);
window.setTimeout(() => setEchoes((prev) => prev.filter((x) => x.id !== id)), ECHO_LIFETIME_MS);
}, []);
return { echoes, spawnEcho };
}
type RadarPulseProps = {
// One-shot echo ring ids + spawn timestamps (see `useEchoes`). Each
// renders a single non-looping pulse identical to an ambient ring.
echoes: EchoPulse[];
};
// The radar-pulse ring field — pink concentric rings rippling out from
// the mascot centre. ONE transparent 2D <canvas>, fully redrawn every
// rAF (10 stroked arcs + echoes).
//
// WHY A CANVAS (tile-memory postmortem, Jun 2026 — device-measured and
// verified against Chromium sources at tags 120/130/137):
//
// The previous live CSS-animated SVG circles blew the WebView tile
// budget. A running compositor animation is a DIRECT compositing
// reason on the animated element itself — SVG children included
// (`compositing_reason_finder.cc::DirectReasonsForSVGChildPaint
// Properties`, kActiveTransformAnimation; opacity keyframes alike via
// kActiveOpacityAnimation; on by default since M89). So EACH <circle>
// got its own cc layer, rastered at the animation's peak to-screen
// scale (scale 1 × dpr 3 ⇒ 2286² device px each; `picture_layer_impl
// .cc::AdjustRasterScaleForTransformAnimation` — and the viewport-area
// raster clamp sits at max(vw,vh)² = 2340², which 2286² legally fits
// under). cc's solid-colour analysis cannot cull a single tile of such
// a layer: the analyzer bails on ANY DrawOval/DrawPath op it sees
// (`solid_color_analyzer.cc`), and the big ring's op bbox covers the
// whole box — so all on-screen tiles (544×608×4B GPU-raster tiles on
// this device, NOT 256²) held real textures: ≈1011 MB × 10 rings ≈
// +110 MB against WebView's budget of exactly 20 × 4B × viewport px
// rounded up to 5 MiB ≈ 204.5 MB (`browser_view_renderer.cc`).
// Shuttle-swiping with 13 curtains parked at the refresh snap then
// evicted CURTAIN tiles first («tile memory limits exceeded» ×100600
// per 14s session; curtains flashed backdrop, rings showed through).
//
// The canvas collapses all of that to ONE cc TextureLayer: its buffers
// (~7 MB × 23 in flight, copy-on-write SharedImages) are client
// resources that NEVER enter the tile-manager budget, and the GL total
// is ~57× below the SVG field. Researched-and-rejected alternatives:
// animating `r`/`stroke-width`/`stroke-opacity` (keeps one layer but
// re-rasters the field's ~20 tiles every frame on the pending tree —
// 3753 MB while animating); inflating the box to trip cc's raster
// clamp (mathematically can't help — the clamp scales DOWN onto the
// 2340² threshold, ≥ today's raster, and any will-change ancestor
// restores native scale outright); `animation-play-state: paused` and
// pre-baked video (owner-vetoed on device). The svg root's own
// translateZ(0) was always irrelevant — promotion was per-circle.
//
// CONTRACT — every rule below guards a verified failure mode:
// • context options exactly `{alpha: true, willReadFrequently:
// false}`; NEVER add `desynchronized: true` — on WebView it swaps
// the TextureLayer for a SurfaceLayerBridge whose surface may
// never activate (the historical «opaque white canvas over
// everything»), and WebView has no low-latency overlay path anyway
// (kWebViewSurfaceControl off).
// • never call getImageData/putImageData/toDataURL on this canvas —
// two readbacks silently demote it to CPU raster permanently.
// • the backing store is device-pixel exact (css size × dpr), set
// only from the ResizeObserver — a CSS-px backing would GPU-
// upscale 3× and visibly blur the 2px strokes.
// • the rAF loop lives on the main thread: a long route-commit task
// freezes the field for the task's duration, then it catches up to
// wall clock (rings advance ~2.5% of their loop per 100ms, so
// commit-length stalls read as continuous; the multi-second
// freezes the owner vetoed came from `animation-play-state:
// paused`, a different class). If stalls ever grow past ~150ms,
// the escalation path is moving THIS SAME draw loop into a worker
// via OffscreenCanvas — not back to DOM animations.
//
// Geometry contract lives on `stagePulseCanvas` (StreamHeader.css.ts):
// the canvas spans the host's width from the screen top down to the
// field's bottom edge, and the draw centre is recovered from the box
// itself — cx = width/2, cy = height (PULSE_SPAN_PX/2 +
// PULSE_EDGE_PX) ⇒ exactly safe-top + PULSE_CENTER_Y_PX, the same
// centre the old svg box had in both hosts.
export function RadarPulse({ echoes }: RadarPulseProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
// Latest echoes for the draw loop without restarting it — the loop
// effect deliberately has no deps.
const echoesRef = useRef<EchoPulse[]>(echoes);
echoesRef.current = echoes;
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return undefined;
const ctx = canvas.getContext('2d', { alpha: true, willReadFrequently: false });
if (!ctx) return undefined;
const dpr = window.devicePixelRatio || 1;
let cssWidth = 0;
let cssHeight = 0;
const resize = () => {
const { clientWidth, clientHeight } = canvas;
if (!clientWidth || !clientHeight) return;
cssWidth = clientWidth;
cssHeight = clientHeight;
canvas.width = Math.round(clientWidth * dpr);
canvas.height = Math.round(clientHeight * dpr);
// Resizing resets all context state — restore the css-px
// coordinate space and the stroke colour.
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.strokeStyle = STROKE_STYLE;
};
// Sized once synchronously (the host mounts this visible, never
// display:none) + re-sized on rotation/viewport changes.
resize();
const observer = new ResizeObserver(resize);
observer.observe(canvas);
const drawRing = (cx: number, cy: number, phase: number) => {
const s = MIN_SCALE + (1 - MIN_SCALE) * easeOut(phase);
ctx.globalAlpha = ringAlpha(phase);
ctx.lineWidth = STROKE_PX * s;
ctx.beginPath();
ctx.arc(cx, cy, RING_RADIUS_PX * s, 0, Math.PI * 2);
// A full-sweep arc() is an OPEN contour in Blink (two 180° arcs
// with coincident butt caps) — closePath() joins it so the cap
// edges can't double-blend into a seam at the start angle.
ctx.closePath();
ctx.stroke();
};
let raf = 0;
let mountTs: number | null = null;
const frame = (now: number) => {
raf = window.requestAnimationFrame(frame);
if (!cssWidth || !cssHeight) return;
// Ambient ring k starts STAGGER_MS·k after the first frame, then
// loops — the same field the old per-circle animation-delays
// produced on mount.
if (mountTs === null) mountTs = now;
const t = now - mountTs;
ctx.clearRect(0, 0, cssWidth, cssHeight);
const cx = cssWidth / 2;
const cy = cssHeight - (RING_RADIUS_PX + PULSE_EDGE_PX);
for (let k = 0; k < RING_COUNT; k += 1) {
const local = t - k * STAGGER_MS;
if (local >= 0) drawRing(cx, cy, (local % LOOP_MS) / LOOP_MS);
}
echoesRef.current.forEach(({ startedAt }) => {
const local = now - startedAt;
if (local >= 0 && local < LOOP_MS) drawRing(cx, cy, local / LOOP_MS);
});
};
raf = window.requestAnimationFrame(frame);
return () => {
window.cancelAnimationFrame(raf);
observer.disconnect();
};
}, []);
return <canvas ref={canvasRef} className={css.stagePulseCanvas} aria-hidden="true" />;
}