Radar loop: tick the frame outside MapView state (#237)

The 1 h loop set MapView state every 600 ms, re-rendering the whole map
component and rebuilding (setIcon) every marker icon on each tick. The
frame now lives in a small external store that only the radar frames and
the valid-time badge subscribe to.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Logan Cusano
2026-10-05 03:51:07 -04:00
co-authored by Claude Opus 5.5
parent 1a84bf46d7
commit da55114eef
+44 -19
View File
@@ -1,6 +1,6 @@
"use client";
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
import { createPortal } from "react-dom";
import {
FeatureGroup,
@@ -346,6 +346,38 @@ const LOOP_HOLD_FRAMES = 3; // the newest frame is held this many ticks before
const RADAR_LEGEND_DBZ = [5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55, 60, 65, 70, 75];
const RADAR_LEGEND_TICKS = [5, 40, 75]; // evenly spaced over RADAR_LEGEND_DBZ, so justify-between places them right
/**
* The loop's current frame, kept OUTSIDE MapView's state: it ticks every
* LOOP_FRAME_MS, and a MapView re-render rebuilds every marker icon on the map
* (setIcon on each), so only the radar frames and the badge subscribe to it.
*/
function createLoopClock() {
let frame = 0;
let id: ReturnType<typeof setInterval> | undefined;
const subs = new Set<() => void>();
const set = (f: number) => { if (f !== frame) { frame = f; subs.forEach((s) => s()); } };
return {
subscribe: (cb: () => void) => { subs.add(cb); return () => { subs.delete(cb); }; },
get: () => frame,
run(on: boolean) {
clearInterval(id);
id = undefined;
set(0);
if (!on) return;
const last = RADAR_LOOP_OFFSETS_MIN.length - 1;
const cycle = last + LOOP_HOLD_FRAMES;
let tick = 0;
id = setInterval(() => {
if (document.hidden) return;
tick = (tick + 1) % cycle;
set(Math.min(tick, last));
}, LOOP_FRAME_MS);
},
};
}
type LoopClock = ReturnType<typeof createLoopClock>;
const useLoopFrame = (c: LoopClock) => useSyncExternalStore(c.subscribe, c.get, c.get);
function radarBuster(valid: Date | null, clock: number): string {
return `?_=${valid ? valid.getTime() : clock}`;
}
@@ -363,7 +395,8 @@ function RadarFrame({ offsetMin, buster, opacity }: { offsetMin: number; buster:
* All frames stay loaded; only the shown one is opaque, so the loop plays
* without re-fetching tiles every cycle.
*/
function RadarLayer({ valid, loop, frame }: { valid: Date | null; loop: boolean; frame: number }) {
function RadarLayer({ valid, loop, loopClock }: { valid: Date | null; loop: boolean; loopClock: LoopClock }) {
const frame = useLoopFrame(loopClock);
const [clock, setClock] = useState(() => Math.floor(Date.now() / RADAR_REFRESH_MS));
useEffect(() => {
if (valid) return; // the valid time drives refreshes while it is known
@@ -387,9 +420,10 @@ function hhmmLocal(d: Date): string {
}
/** Radar valid-time badge + loop toggle: what time the map's radar shows, and whether that is stale. */
function RadarBadge({ meta, loop, frame, onLoop }: {
meta: RadarMeta; loop: boolean; frame: number; onLoop: (on: boolean) => void;
function RadarBadge({ meta, loop, loopClock, onLoop }: {
meta: RadarMeta; loop: boolean; loopClock: LoopClock; onLoop: (on: boolean) => void;
}) {
const frame = useLoopFrame(loopClock);
const [, tick] = useState(0);
useEffect(() => {
const id = setInterval(() => tick((t) => t + 1), 30_000);
@@ -1530,7 +1564,7 @@ export default function MapView({
const [radarShown, setRadarShown] = useState(() => startLayers.has("radar"));
const [wxShown, setWxShown] = useState(() => startLayers.has("wx"));
const [radarLoop, setRadarLoop] = useState(false); // off by default (#237); not persisted
const [loopFrame, setLoopFrame] = useState(0);
const loopClock = useMemo(createLoopClock, []);
// The altitude key only belongs in the legend while the opt-in Aircraft
// overlay is actually on (server-26#185); same for the outage key (#184).
@@ -1566,18 +1600,9 @@ export default function MapView({
const wxAlerts = useMapWeatherAlerts(mapInstance, wxShown);
const loopOn = radarShown && radarLoop;
useEffect(() => {
if (!loopOn) { setLoopFrame(0); return; }
const last = RADAR_LOOP_OFFSETS_MIN.length - 1;
let tick = 0;
const cycle = last + LOOP_HOLD_FRAMES;
setLoopFrame(0);
const id = setInterval(() => {
if (document.hidden) return;
tick = (tick + 1) % cycle;
setLoopFrame(Math.min(tick, last));
}, LOOP_FRAME_MS);
return () => clearInterval(id);
}, [loopOn]);
loopClock.run(loopOn);
return () => loopClock.run(false);
}, [loopClock, loopOn]);
// eslint-disable-next-line react-hooks/exhaustive-deps
const ago = useMemo(() => (lastUpdated ? timeAgo(lastUpdated) : null), [lastUpdated, agoClock]);
@@ -1791,7 +1816,7 @@ export default function MapView({
{/* Overlay: Weather Radar — NEXRAD n0q via Iowa Env Mesonet (server-26#237) */}
<LayersControl.Overlay checked={layerOn("radar")} name={LAYER_NAMES.radar}>
<FeatureGroup>
<RadarLayer valid={radarMeta.valid} loop={loopOn} frame={loopFrame} />
<RadarLayer valid={radarMeta.valid} loop={loopOn} loopClock={loopClock} />
</FeatureGroup>
</LayersControl.Overlay>
@@ -1823,7 +1848,7 @@ export default function MapView({
{(radarShown || wxShown) && (
<div className="absolute bottom-10 md:bottom-8 left-1/2 -translate-x-1/2 z-[1001] flex flex-col items-center gap-1 w-max max-w-[min(30rem,calc(100%-1.5rem))] pointer-events-none">
{wxShown && <WeatherWarningsStatus state={wxAlerts} />}
{radarShown && <RadarBadge meta={radarMeta} loop={radarLoop} frame={loopFrame} onLoop={setRadarLoop} />}
{radarShown && <RadarBadge meta={radarMeta} loop={radarLoop} loopClock={loopClock} onLoop={setRadarLoop} />}
</div>
)}