diff --git a/drb-frontend/components/MapView.tsx b/drb-frontend/components/MapView.tsx index a01ca90..d94cd08 100644 --- a/drb-frontend/components/MapView.tsx +++ b/drb-frontend/components/MapView.tsx @@ -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 | 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; +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) */} - + @@ -1823,7 +1848,7 @@ export default function MapView({ {(radarShown || wxShown) && (
{wxShown && } - {radarShown && } + {radarShown && }
)}