135 lines
5 KiB
TypeScript
135 lines
5 KiB
TypeScript
"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>
|
|
);
|
|
}
|