241 lines
11 KiB
TypeScript
241 lines
11 KiB
TypeScript
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: ≈10–11 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 1–3 curtains parked at the refresh snap then
|
||
// evicted CURTAIN tiles first («tile memory limits exceeded» ×100–600
|
||
// per 14s session; curtains flashed backdrop, rings showed through).
|
||
//
|
||
// The canvas collapses all of that to ONE cc TextureLayer: its buffers
|
||
// (~7 MB × 2–3 in flight, copy-on-write SharedImages) are client
|
||
// resources that NEVER enter the tile-manager budget, and the GL total
|
||
// is ~5–7× 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 —
|
||
// 37–53 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" />;
|
||
}
|