/* WeatherMate — radar screen (RainViewer, no API key) */
const RAINVIEWER_API = "https://api.rainviewer.com/public/weather-maps.json";
/* Tile URL helpers */
function radarTile(host, path, z, x, y) {
return `${host}${path}/256/${z}/${x}/${y}/2/1_1.png`;
}
/* OSM tile URL */
function osmTile(z, x, y) {
const sub = ["a", "b", "c"][(x + y + z) % 3];
return `https://${sub}.tile.openstreetmap.org/${z}/${x}/${y}.png`;
}
/* Lat/lon → tile coords */
function latLonToTile(lat, lon, z) {
const x = Math.floor((lon + 180) / 360 * Math.pow(2, z));
const latRad = lat * Math.PI / 180;
const y = Math.floor((1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2 * Math.pow(2, z));
return { x, y };
}
/* Tile coord → pixel offset within a 3×3 grid centred on (cx, cy) */
function tileOffset(tx, ty, cx, cy, tileSize) {
return { dx: (tx - cx) * tileSize, dy: (ty - cy) * tileSize };
}
const ZOOM = 6;
const TILE = 256;
const GRID = 5; // render 5×5 tiles around centre
function MapTiles({ lat, lon, radarPath, radarHost, opacity }) {
const centre = latLonToTile(lat, lon, ZOOM);
const half = Math.floor(GRID / 2);
const tiles = [];
for (let dy = -half; dy <= half; dy++) {
for (let dx = -half; dx <= half; dx++) {
const tx = centre.x + dx;
const ty = centre.y + dy;
const px = (dx + half) * TILE;
const py = (dy + half) * TILE;
tiles.push(
);
if (radarPath) {
tiles.push(
);
}
}
}
/* sub-pixel offset so the city is exactly centred */
const latRad = lat * Math.PI / 180;
const n = Math.pow(2, ZOOM);
const fracX = (lon + 180) / 360 * n - centre.x;
const fracY = (1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2 * n - centre.y;
const totalSize = GRID * TILE;
const viewSize = totalSize;
const offsetX = -(fracX * TILE) + (viewSize / 2 - half * TILE);
const offsetY = -(fracY * TILE) + (viewSize / 2 - half * TILE);
return (
);
}
function RadarScreen({ city, onClose }) {
const [frames, setFrames] = React.useState([]);
const [host, setHost] = React.useState("");
const [frameIdx, setFrameIdx] = React.useState(0);
const [playing, setPlaying] = React.useState(true);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState(false);
const timerRef = React.useRef(null);
/* fetch available radar frames — start paused on most recent frame */
React.useEffect(() => {
let alive = true;
setLoading(true); setError(false);
fetch(RAINVIEWER_API)
.then(r => r.json())
.then(data => {
if (!alive) return;
const h = data.host;
const radar = data.radar?.past || [];
if (!radar.length) { setError(true); return; }
setHost(h);
setFrames(radar);
setFrameIdx(radar.length - 1);
setPlaying(false);
setLoading(false);
})
.catch(() => { if (alive) { setError(true); setLoading(false); } });
return () => { alive = false; };
}, []);
/* animation loop — play forward from current frame */
React.useEffect(() => {
clearInterval(timerRef.current);
if (!playing || frames.length === 0) return;
timerRef.current = setInterval(() => {
setFrameIdx(i => {
const next = i + 1;
if (next >= frames.length) { return 0; }
return next;
});
}, 700);
return () => clearInterval(timerRef.current);
}, [playing, frames.length]);
const currentFrame = frames[frameIdx];
const frameTime = currentFrame ? new Date(currentFrame.time * 1000).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) : "";
return (
{/* header */}
{currentFrame &&
{frameTime}
}
{/* map area */}
{loading && (
)}
{error && (
📡
Radar unavailable
Check your connection
)}
{!loading && !error && (
)}
{/* dark overlay for contrast */}
{!loading && !error && (
)}
{/* controls */}
{!loading && !error && frames.length > 0 && (
{/* timeline scrubber */}
{frames.map((f, i) => (
{/* play/pause */}
)}
{/* attribution */}
Map © OpenStreetMap · Radar © RainViewer
);
}
window.RadarScreen = RadarScreen;