73 lines
2.7 KiB
TypeScript
73 lines
2.7 KiB
TypeScript
"use client";
|
|
|
|
import "leaflet/dist/leaflet.css";
|
|
import { useEffect } from "react";
|
|
import { CircleMarker, MapContainer, Pane, TileLayer, useMap } from "react-leaflet";
|
|
import type { RadarLayer } from "@/lib/radar/layers";
|
|
import type { RadarFrame } from "@/lib/weather/types";
|
|
|
|
/**
|
|
* The Leaflet map. Browser-only (Leaflet touches `window`), so RadarPanel
|
|
* loads it with next/dynamic and `ssr: false`.
|
|
*
|
|
* Layers, bottom to top:
|
|
* 1. dark basemap without labels (CARTO)
|
|
* 2. the radar/weather overlay
|
|
* 3. place-name labels (CARTO), so towns stay readable over the radar
|
|
* 4. a gold dot on the chosen place
|
|
*/
|
|
|
|
const BASEMAP = "/api/tiles/carto-dark-nolabels/{z}/{x}/{y}?r={r}";
|
|
const LABELS = "/api/tiles/carto-dark-labels/{z}/{x}/{y}?r={r}";
|
|
const BASEMAP_ATTRIBUTION =
|
|
'© <a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">OpenStreetMap</a> contributors, © <a href="https://carto.com/attributions" target="_blank" rel="noopener">CARTO</a>';
|
|
|
|
// Leaflet writes these straight onto SVG attributes, which accept theme variables.
|
|
const MARKER_STYLE = { color: "var(--color-gold-bright)", weight: 2, fillColor: "var(--color-black)", fillOpacity: 1 };
|
|
|
|
/** Moves the map when the chosen place changes, keeping the current zoom. */
|
|
function FollowPlace({ lat, lon }: { lat: number; lon: number }) {
|
|
const map = useMap();
|
|
useEffect(() => {
|
|
map.setView([lat, lon], map.getZoom());
|
|
}, [lat, lon, map]);
|
|
return null;
|
|
}
|
|
|
|
export default function RadarMap({ lat, lon, layer, frames, frameIndex }: {
|
|
lat: number;
|
|
lon: number;
|
|
layer: RadarLayer;
|
|
frames: RadarFrame[];
|
|
frameIndex: number;
|
|
}) {
|
|
// Only the visible frame and the next one are mounted. The next one loads
|
|
// invisibly, so stepping forward never flashes an empty map.
|
|
const nextIndex = (frameIndex + 1) % Math.max(frames.length, 1);
|
|
|
|
return (
|
|
<MapContainer center={[lat, lon]} zoom={7} minZoom={3} maxZoom={12} className="h-full w-full" scrollWheelZoom={false}>
|
|
<FollowPlace lat={lat} lon={lon} />
|
|
<TileLayer url={BASEMAP} attribution={BASEMAP_ATTRIBUTION} />
|
|
|
|
{frames.map((frame, i) =>
|
|
i === frameIndex || i === nextIndex ? (
|
|
<TileLayer
|
|
key={frame.url}
|
|
url={frame.url}
|
|
opacity={i === frameIndex ? 0.75 : 0}
|
|
maxNativeZoom={layer.maxNativeZoom}
|
|
attribution={layer.attribution}
|
|
zIndex={10}
|
|
/>
|
|
) : null,
|
|
)}
|
|
|
|
<Pane name="labels" style={{ zIndex: 450, pointerEvents: "none" }}>
|
|
<TileLayer url={LABELS} />
|
|
</Pane>
|
|
|
|
<CircleMarker center={[lat, lon]} radius={6} pathOptions={MARKER_STYLE} />
|
|
</MapContainer>
|
|
);
|
|
}
|