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:
co-authored by
Claude Opus 5.5
parent
1a84bf46d7
commit
da55114eef
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"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 { createPortal } from "react-dom";
|
||||||
import {
|
import {
|
||||||
FeatureGroup,
|
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_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
|
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 {
|
function radarBuster(valid: Date | null, clock: number): string {
|
||||||
return `?_=${valid ? valid.getTime() : clock}`;
|
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
|
* All frames stay loaded; only the shown one is opaque, so the loop plays
|
||||||
* without re-fetching tiles every cycle.
|
* 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));
|
const [clock, setClock] = useState(() => Math.floor(Date.now() / RADAR_REFRESH_MS));
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (valid) return; // the valid time drives refreshes while it is known
|
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. */
|
/** Radar valid-time badge + loop toggle: what time the map's radar shows, and whether that is stale. */
|
||||||
function RadarBadge({ meta, loop, frame, onLoop }: {
|
function RadarBadge({ meta, loop, loopClock, onLoop }: {
|
||||||
meta: RadarMeta; loop: boolean; frame: number; onLoop: (on: boolean) => void;
|
meta: RadarMeta; loop: boolean; loopClock: LoopClock; onLoop: (on: boolean) => void;
|
||||||
}) {
|
}) {
|
||||||
|
const frame = useLoopFrame(loopClock);
|
||||||
const [, tick] = useState(0);
|
const [, tick] = useState(0);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const id = setInterval(() => tick((t) => t + 1), 30_000);
|
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 [radarShown, setRadarShown] = useState(() => startLayers.has("radar"));
|
||||||
const [wxShown, setWxShown] = useState(() => startLayers.has("wx"));
|
const [wxShown, setWxShown] = useState(() => startLayers.has("wx"));
|
||||||
const [radarLoop, setRadarLoop] = useState(false); // off by default (#237); not persisted
|
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
|
// 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).
|
// 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 wxAlerts = useMapWeatherAlerts(mapInstance, wxShown);
|
||||||
const loopOn = radarShown && radarLoop;
|
const loopOn = radarShown && radarLoop;
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!loopOn) { setLoopFrame(0); return; }
|
loopClock.run(loopOn);
|
||||||
const last = RADAR_LOOP_OFFSETS_MIN.length - 1;
|
return () => loopClock.run(false);
|
||||||
let tick = 0;
|
}, [loopClock, loopOn]);
|
||||||
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]);
|
|
||||||
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
const ago = useMemo(() => (lastUpdated ? timeAgo(lastUpdated) : null), [lastUpdated, agoClock]);
|
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) */}
|
{/* Overlay: Weather Radar — NEXRAD n0q via Iowa Env Mesonet (server-26#237) */}
|
||||||
<LayersControl.Overlay checked={layerOn("radar")} name={LAYER_NAMES.radar}>
|
<LayersControl.Overlay checked={layerOn("radar")} name={LAYER_NAMES.radar}>
|
||||||
<FeatureGroup>
|
<FeatureGroup>
|
||||||
<RadarLayer valid={radarMeta.valid} loop={loopOn} frame={loopFrame} />
|
<RadarLayer valid={radarMeta.valid} loop={loopOn} loopClock={loopClock} />
|
||||||
</FeatureGroup>
|
</FeatureGroup>
|
||||||
</LayersControl.Overlay>
|
</LayersControl.Overlay>
|
||||||
|
|
||||||
@@ -1823,7 +1848,7 @@ export default function MapView({
|
|||||||
{(radarShown || wxShown) && (
|
{(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">
|
<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} />}
|
{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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user