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).
144 lines
4.8 KiB
JavaScript
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
|
|
})
|
|
}
|