weather_app/src/lib/api/lightning.js
hermes-explorigin 86acc56143 feat(radar): only show lightning strikes within 25 miles
Add haversineKm helper and filterStrikesWithinKm filter in
src/lib/api/lightning.js. PrecipitationRadar now filters strikes by
distance from the current location before projecting them, so only
strikes within 25 miles render. No data-source change; relay-down
behavior is unchanged.

Fixes an incorrect haversine expectation in the test (0.3567 deg of
latitude ~= 39.66 km, not 25.7 km).
2026-09-20 19:47:37 +00:00

144 lines
4.8 KiB
JavaScript

/**
* 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=<lat>&lon=<lon>&radiusKm=<n>&limit=<n>
* -> 200 { "source": "blitzortung", "generatedAt": <unix>,
* "strikes": [ { "lat": <number>, "lon": <number>,
* "time": <unix>, "strength": <number|undefined> } ] }
*
* 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<Array<{ lat: number, lon: number, time: number, strength?: number }>>}
* 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
})
}