"use client";
import dynamic from "next/dynamic";
import { useEffect, useState } from "react";
import { formatClock } from "@/lib/display";
import { loadFrames, RADAR_LAYERS, type RadarLayerId } from "@/lib/radar/layers";
import { outlineButton, pixelLabel, sectionHeading } from "@/lib/styles";
import type { Place, RadarFrame } from "@/lib/weather/types";
import { Slot } from "../Slot";
import { ChoiceChips } from "../ui/ChoiceChips";
// Leaflet needs `window`, so the map only ever renders in the browser.
const RadarMap = dynamic(() => import("./RadarMap"), {
ssr: false,
loading: () =>
Loading map…
,
});
const FRAME_MS = 700; // animation speed
const REFRESH_MS = 5 * 60_000; // fetch fresh frames every 5 minutes
/** The radar section: layer picker, animated map, and playback controls. */
export function RadarPanel({ place, timeZone, hasOpenWeather }: { place: Place; timeZone: string | null; hasOpenWeather: boolean }) {
// US places start on the sharper NEXRAD radar; everywhere else on RainViewer.
const [layerId, setLayerId] = useState(place.country === "US" ? "nexrad" : "rainviewer");
const [frames, setFrames] = useState([]);
const [frameIndex, setFrameIndex] = useState(0);
const [playing, setPlaying] = useState(false);
const [error, setError] = useState(null);
// Switch between US-only NEXRAD and global RainViewer when crossing borders.
useEffect(() => {
setLayerId((current) => {
if (place.country !== "US" && current === "nexrad") return "rainviewer";
if (place.country === "US" && current === "rainviewer") return "nexrad";
if (!hasOpenWeather && current.startsWith("owm-")) return place.country === "US" ? "nexrad" : "rainviewer";
return current;
});
}, [place.country, hasOpenWeather]);
const layer = RADAR_LAYERS.find((l) => l.id === layerId)!;
const isAnimated = frames.length > 1;
// Load frames for the chosen layer, then keep them fresh.
useEffect(() => {
let cancelled = false;
async function refresh() {
try {
const loaded = await loadFrames(layerId);
if (cancelled) return;
setFrames(loaded);
setFrameIndex(loaded.length - 1); // start on the latest frame
setError(null);
} catch (err) {
if (!cancelled) setError((err as Error).message);
}
}
refresh();
const timer = setInterval(refresh, REFRESH_MS);
return () => {
cancelled = true;
clearInterval(timer);
};
}, [layerId]);
// Autoplay unless the person prefers reduced motion.
useEffect(() => {
setPlaying(!window.matchMedia("(prefers-reduced-motion: reduce)").matches);
}, []);
// Step through frames while playing.
useEffect(() => {
if (!playing || !isAnimated) return;
const timer = setInterval(() => setFrameIndex((i) => (i + 1) % frames.length), FRAME_MS);
return () => clearInterval(timer);
}, [playing, isAnimated, frames.length]);
const frame = frames[frameIndex];
const isLatest = frameIndex === frames.length - 1;
return (
Radar and maps
hasOpenWeather || !l.needsOpenWeather).map((l) => ({ value: l.id, label: l.label }))}
/>
{/* Height goes on the outer frame: the inner one is already h-full, and two
height utilities on one element would fight over which wins. */}
{isAnimated && frame && (