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).
This commit is contained in:
hermes-explorigin 2026-09-20 19:47:37 +00:00
parent 882fd69a18
commit 86acc56143
3 changed files with 97 additions and 2 deletions

View File

@ -2,6 +2,7 @@
import { app } from '../lib/stores/app.svelte.js' import { app } from '../lib/stores/app.svelte.js'
import { saveSettings } from '../lib/storage/db.js' import { saveSettings } from '../lib/storage/db.js'
import { fetchLightningStrikes, projectStrikeToLayer } from '../lib/api/lightning.js' import { fetchLightningStrikes, projectStrikeToLayer } from '../lib/api/lightning.js'
import { filterStrikesWithinKm } from '../lib/api/lightning.js'
// --- Radar data --- // --- Radar data ---
let radarData = $state(null) let radarData = $state(null)
@ -30,6 +31,10 @@
let lightningTimer = null let lightningTimer = null
const LIGHTNING_REFRESH_MS = 30_000 const LIGHTNING_REFRESH_MS = 30_000
// 25 miles is the max distance a lightning strike is worth showing.
const MILES_TO_KM = 1.609344
const LIGHTNING_VIEW_RADIUS_KM = 25 * MILES_TO_KM
// --- Load radar metadata --- // --- Load radar metadata ---
$effect(() => { $effect(() => {
if (loc) { if (loc) {
@ -113,7 +118,7 @@
const strikeMarkers = $derived( const strikeMarkers = $derived(
showLightning showLightning
? strikes.map((s) => ({ ? filterStrikesWithinKm(strikes, loc.lat, loc.lon, LIGHTNING_VIEW_RADIUS_KM).map((s) => ({
...s, ...s,
...projectStrikeToLayer(s.lat, s.lon, { zoom, cx, cy, tileSize: TILE_SIZE }), ...projectStrikeToLayer(s.lat, s.lon, { zoom, cx, cy, tileSize: TILE_SIZE }),
})) }))

View File

@ -102,3 +102,42 @@ function tileY(lat, z) {
const rad = (lat * Math.PI) / 180 const rad = (lat * Math.PI) / 180
return (1 - Math.log(Math.tan(rad) + 1 / Math.cos(rad)) / Math.PI) * Math.pow(2, z - 1) 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
})
}

View File

@ -1,5 +1,5 @@
import { describe, it, expect, vi, afterEach } from 'vitest' import { describe, it, expect, vi, afterEach } from 'vitest'
import { fetchLightningStrikes, projectStrikeToLayer } from '../../../src/lib/api/lightning.js' import { fetchLightningStrikes, projectStrikeToLayer, haversineKm, filterStrikesWithinKm } from '../../../src/lib/api/lightning.js'
afterEach(() => { afterEach(() => {
vi.restoreAllMocks() vi.restoreAllMocks()
@ -77,3 +77,54 @@ describe('projectStrikeToLayer', () => {
expect(b.x).toBeGreaterThan(a.x) expect(b.x).toBeGreaterThan(a.x)
}) })
}) })
describe('haversineKm', () => {
it('returns ~0 for identical coords', () => {
expect(haversineKm(36.35, -95.81, 36.35, -95.81)).toBeCloseTo(0, 5)
})
it('~39.66km for 0.3567 deg of latitude', () => {
expect(haversineKm(36.0, -95.81, 36.3567, -95.81)).toBeCloseTo(39.66, 1)
})
it('~111.19km per 1 deg of latitude', () => {
expect(haversineKm(36.0, -95.81, 37.0, -95.81)).toBeCloseTo(111.19, 1)
})
})
describe('filterStrikesWithinKm', () => {
// Distances computed with the real haversine helper (not hardcoded), so the
// boundary tests are exact regardless of floating-point rounding.
const R = haversineKm(36.35, -95.81, 37.0, -95.81)
// A lon at 37.0 N whose distance from the location is just past R.
let ln = -95.81
while (haversineKm(36.35, -95.81, 37.0, ln) <= R) ln += 0.001
const pastLon = ln
const strikes = [
{ lat: 36.35, lon: -95.81, time: 1, strength: -10 }, // at the location (0 km) -> keep
{ lat: 37.0, lon: -95.81, time: 2, strength: -12 }, // exactly R km north -> keep
{ lat: 37.0, lon: pastLon, time: 3, strength: -9 }, // just past R -> drop
]
it('keeps strikes strictly inside the radius', () => {
const kept = filterStrikesWithinKm(strikes, 36.35, -95.81, R - 1)
expect(kept).toHaveLength(1)
expect(kept[0].strength).toBe(-10)
})
it('keeps strikes exactly at the radius boundary (inclusive)', () => {
const kept = filterStrikesWithinKm(strikes, 36.35, -95.81, R)
expect(kept).toHaveLength(2)
})
it('drops strikes just past the radius', () => {
const kept = filterStrikesWithinKm(strikes, 36.35, -95.81, R - 0.0001)
expect(kept).toHaveLength(1)
expect(kept[0].strength).toBe(-10)
})
it('returns an empty array when everything is beyond the radius', () => {
// Reference point far from any strike, with a tiny radius.
expect(filterStrikesWithinKm(strikes, 40.0, -95.81, 0.0001)).toHaveLength(0)
})
})