init
This commit is contained in:
commit
6250d23297
53 changed files with 6381 additions and 0 deletions
135
components/radar/RadarPanel.tsx
Normal file
135
components/radar/RadarPanel.tsx
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
"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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue