/** * Lightning strike API helper. * * The browser cannot talk directly to the Blitzortung feed (WebSocket + * binary protocol, no CORS), so this app reads strikes through a small CORS * relay at https://cors.thecookiejar.me. The relay is NOT deployed yet, so * every request here must fail gracefully: callers catch the error and show a * "lightning unavailable" state rather than crashing/blocking the radar. * * Expected relay contract (once it exists): * GET /lightning?lat=&lon=&radiusKm=&limit= * -> 200 { "source": "blitzortung", "generatedAt": , * "strikes": [ { "lat": , "lon": , * "time": , "strength": } ] } * * The relay is intended to hold a recent WS connection to Blitzortung and * answer point-in-radius queries for recent strikes in that area. */ const LIGHTNING_PROXY_BASE = 'https://cors.thecookiejar.me/lightning' /** * Fetch recent lightning strikes within radiusKm of a location. * * @param {Object} opts * @param {number} opts.lat * @param {number} opts.lon * @param {number} [opts.radiusKm=150] * @param {number} [opts.limit=200] * @returns {Promise>} * Resolves to the strike list. Throws if the relay is unreachable, returns a * non-OK status, or the response is malformed. */ export async function fetchLightningStrikes({ lat, lon, radiusKm = 150, limit = 200 } = {}) { const params = new URLSearchParams({ lat: lat.toString(), lon: lon.toString(), radiusKm: radiusKm.toString(), limit: limit.toString(), }) const url = `${LIGHTNING_PROXY_BASE}?${params.toString()}` // Abort after 8s so a dead/missing relay doesn't hang the map. const controller = new AbortController() const timer = setTimeout(() => controller.abort(), 8000) let res try { res = await fetch(url, { signal: controller.signal }) } finally { clearTimeout(timer) } if (!res.ok) { throw new Error(`Lightning relay error: ${res.status} ${res.statusText}`) } const data = await res.json() if (!data || !Array.isArray(data.strikes)) { throw new Error('Lightning relay returned an invalid response') } return data.strikes } /** * Project a lat/lon onto the radar tile layer's pixel coordinates. * * Mirrors the slippy-map math in PrecipitationRadar: the layer is a grid of * TILE_SIZE tiles centered on the location's center tile (cx, cy in tile * space). A strike at world coordinates is converted to floating tile * coordinates at the given zoom, then to pixels relative to the tile layer's * origin (the tile two rows/cols above-left of the center tile, since the grid * is 5x5 and centered). * * @param {number} lat * @param {number} lon * @param {Object} opts * @param {number} opts.zoom * @param {number} opts.cx - center tile X (float) * @param {number} opts.cy - center tile Y (float) * @param {number} [opts.tileSize=256] * @returns {{ x: number, y: number }} Pixel coords within the tile layer. */ export function projectStrikeToLayer(lat, lon, { zoom, cx, cy, tileSize = 256 }) { const fx = tileX(lon, zoom) const fy = tileY(lat, zoom) const tcx = Math.floor(cx) const tcy = Math.floor(cy) return { x: (fx - (tcx - 2)) * tileSize, y: (fy - (tcy - 2)) * tileSize, } } function tileX(lon, z) { return ((lon + 180) / 360) * Math.pow(2, z) } function tileY(lat, z) { const rad = (lat * Math.PI) / 180 return (1 - Math.log(Math.tan(rad) + 1 / Math.cos(rad)) / Math.PI) * Math.pow(2, z - 1) } /** * Great-circle distance in km between two lat/lon points (Haversine formula). * Used to filter lightning strikes by distance from the current location. * * @param {number} lat1 * @param {number} lon1 * @param {number} lat2 * @param {number} lon2 * @returns {number} Distance in kilometers. */ export function haversineKm(lat1, lon1, lat2, lon2) { const R = 6371 const toRad = (d) => (d * Math.PI) / 180 const dLat = toRad(lat2 - lat1) const dLon = toRad(lon2 - lon1) const a = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2 return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)) } /** * Keep only the strikes within `radiusKm` of the location. Distances use the * haversine formula so the filter is accurate on the curved surface of the * Earth. Boundary is inclusive (<=). * * @param {Array<{ lat: number, lon: number }>} strikes * @param {number} lat * @param {number} lon * @param {number} radiusKm - max distance to keep, in kilometers. * @returns {Array} Strikes at or inside the radius, preserving order. */ export function filterStrikesWithinKm(strikes, lat, lon, radiusKm) { return strikes.filter((s) => { const d = haversineKm(lat, lon, s.lat, s.lon) return d <= radiusKm }) }