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 = [ [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([]); 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 , 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 // 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(null); // Latest echoes for the draw loop without restarting it — the loop // effect deliberately has no deps. const echoesRef = useRef(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