/* 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 ( {tiles} {/* crosshair */} ); } 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 */}
Radar
{city.name}
{currentFrame &&
{frameTime}
}
{/* map area */}
{loading && (
Loading radar…
)} {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 */}
LightHeavy
)} {/* attribution */}
Map © OpenStreetMap · Radar © RainViewer
); } window.RadarScreen = RadarScreen;