From 86acc5614316d179e884e15c8c697a00896da03e Mon Sep 17 00:00:00 2001 From: hermes-explorigin Date: Sun, 20 Sep 2026 19:47:37 +0000 Subject: [PATCH] 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). --- src/components/PrecipitationRadar.svelte | 7 +++- src/lib/api/lightning.js | 39 +++++++++++++++++ tests/lib/api/lightning.test.js | 53 +++++++++++++++++++++++- 3 files changed, 97 insertions(+), 2 deletions(-) diff --git a/src/components/PrecipitationRadar.svelte b/src/components/PrecipitationRadar.svelte index afe9483..f42ed2a 100644 --- a/src/components/PrecipitationRadar.svelte +++ b/src/components/PrecipitationRadar.svelte @@ -2,6 +2,7 @@ import { app } from '../lib/stores/app.svelte.js' import { saveSettings } from '../lib/storage/db.js' import { fetchLightningStrikes, projectStrikeToLayer } from '../lib/api/lightning.js' +import { filterStrikesWithinKm } from '../lib/api/lightning.js' // --- Radar data --- let radarData = $state(null) @@ -30,6 +31,10 @@ let lightningTimer = null 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 --- $effect(() => { if (loc) { @@ -113,7 +118,7 @@ const strikeMarkers = $derived( showLightning - ? strikes.map((s) => ({ + ? filterStrikesWithinKm(strikes, loc.lat, loc.lon, LIGHTNING_VIEW_RADIUS_KM).map((s) => ({ ...s, ...projectStrikeToLayer(s.lat, s.lon, { zoom, cx, cy, tileSize: TILE_SIZE }), })) diff --git a/src/lib/api/lightning.js b/src/lib/api/lightning.js index 6ded7d1..38a552b 100644 --- a/src/lib/api/lightning.js +++ b/src/lib/api/lightning.js @@ -102,3 +102,42 @@ 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 + }) +} diff --git a/tests/lib/api/lightning.test.js b/tests/lib/api/lightning.test.js index ccd3e58..1ea9a95 100644 --- a/tests/lib/api/lightning.test.js +++ b/tests/lib/api/lightning.test.js @@ -1,5 +1,5 @@ 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(() => { vi.restoreAllMocks() @@ -77,3 +77,54 @@ describe('projectStrikeToLayer', () => { 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) + }) +})