wayfarer-weather/components/radar/RadarPanel.tsx

136 lines
5 KiB
TypeScript
Raw Normal View History

2026-10-31 21:20:16 -05:00
"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: () => <div className="grid h-full place-items-center font-pixel text-xs text-stone">Loading map…</div>,
});
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<RadarLayerId>(place.country === "US" ? "nexrad" : "rainviewer");
const [frames, setFrames] = useState<RadarFrame[]>([]);
const [frameIndex, setFrameIndex] = useState(0);
const [playing, setPlaying] = useState(false);
const [error, setError] = useState<string | null>(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 (
<section aria-labelledby="radar-heading" className="flex flex-col gap-3">
<div className="flex items-center gap-3">
<h2 id="radar-heading" className={sectionHeading}>
Radar and maps
</h2>
<span className="h-px flex-1 bg-linear-to-r from-gold-dim/70 to-transparent" aria-hidden="true" />
</div>
<ChoiceChips
legend="Map layer"
size="sm"
selected={layerId}
onChange={setLayerId}
choices={RADAR_LAYERS.filter((l) => 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. */}
<Slot className="h-[360px] [--cut:10px] sm:h-[460px]" innerClassName="">
<RadarMap lat={place.lat} lon={place.lon} layer={layer} frames={frames} frameIndex={frameIndex} />
</Slot>
{isAnimated && frame && (
<div className="flex items-center gap-3">
<button type="button" className={outlineButton} onClick={() => setPlaying((p) => !p)} aria-pressed={playing}>
{playing ? "Pause" : "Play"}
</button>
<label className="sr-only" htmlFor="radar-frame">
Radar frame
</label>
<input
id="radar-frame"
type="range"
min={0}
max={frames.length - 1}
value={frameIndex}
onChange={(e) => {
setPlaying(false);
setFrameIndex(Number(e.target.value));
}}
className="min-w-0 flex-1 accent-gold"
/>
<span className="w-[4.5rem] text-right font-pixel text-xs text-ink tabular-nums" aria-live="off">
{isLatest ? "Latest" : formatClock(frame.time, timeZone)}
</span>
</div>
)}
<p className={pixelLabel}>{error ?? layer.hint}</p>
</section>
);
}