Drop lightning layer: remove Blitzortung CORS-relay feature entirely
Blitzortung exposes no browser-readable JSON endpoint, so a pure Caddy cors proxy (browser-to-cross-origin) was never going to work without an upstream that already sends permissive CORS + reshaped strikes. Rather than add that relay, delete the whole branch: - src/components/PrecipitationRadar.svelte: remove lightning import, $state (strikes/error/loading/timer), loadLightning/timers, and its location effect (704 -> 650 lines) - src/lib/api/lightning.js + tests/lib/api/lightning.test.js removed (15 test cases; suite 114 -> 99, all green) - db.js / app.svelte.js: drop dead showLightning settings key (unreferenced) Build re-bundled to dist/index.html; no lightning references remain in source or bundle.
This commit is contained in:
parent
ef4a8b4756
commit
f09fc5b1a3
105
dist/index.html
vendored
105
dist/index.html
vendored
@ -5211,7 +5211,6 @@ var init_db = __esmMin((() => {
|
|||||||
source: "open-meteo",
|
source: "open-meteo",
|
||||||
refreshInterval: 30,
|
refreshInterval: 30,
|
||||||
alertsEnabled: true,
|
alertsEnabled: true,
|
||||||
showLightning: false,
|
|
||||||
notificationTypes: {
|
notificationTypes: {
|
||||||
storm: true,
|
storm: true,
|
||||||
precip: true,
|
precip: true,
|
||||||
@ -5876,7 +5875,6 @@ var AppStore = class {
|
|||||||
source: "open-meteo",
|
source: "open-meteo",
|
||||||
refreshInterval: 30,
|
refreshInterval: 30,
|
||||||
alertsEnabled: true,
|
alertsEnabled: true,
|
||||||
showLightning: false,
|
|
||||||
notificationTypes: {
|
notificationTypes: {
|
||||||
storm: true,
|
storm: true,
|
||||||
precip: true,
|
precip: true,
|
||||||
@ -7721,59 +7719,6 @@ function WeatherDetail($$anchor, $$props) {
|
|||||||
pop();
|
pop();
|
||||||
}
|
}
|
||||||
//#endregion
|
//#endregion
|
||||||
//#region src/lib/api/lightning.js
|
|
||||||
/**
|
|
||||||
* 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.
|
|
||||||
*/
|
|
||||||
var 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.
|
|
||||||
*/
|
|
||||||
async function fetchLightningStrikes({ lat, lon, radiusKm = 150, limit = 200 } = {}) {
|
|
||||||
const url = `${LIGHTNING_PROXY_BASE}?${new URLSearchParams({
|
|
||||||
lat: lat.toString(),
|
|
||||||
lon: lon.toString(),
|
|
||||||
radiusKm: radiusKm.toString(),
|
|
||||||
limit: limit.toString()
|
|
||||||
}).toString()}`;
|
|
||||||
const controller = new AbortController();
|
|
||||||
const timer = setTimeout(() => controller.abort(), 8e3);
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
//#endregion
|
|
||||||
//#region src/components/PrecipitationRadar.svelte
|
//#region src/components/PrecipitationRadar.svelte
|
||||||
var root$4 = /* @__PURE__ */ from_html(`<div class="radar-status radar-status-err svelte-tjvfz7"><span class="svelte-tjvfz7"> </span> <button class="btn btn-ghost btn-sm svelte-tjvfz7">Retry</button></div>`);
|
var root$4 = /* @__PURE__ */ from_html(`<div class="radar-status radar-status-err svelte-tjvfz7"><span class="svelte-tjvfz7"> </span> <button class="btn btn-ghost btn-sm svelte-tjvfz7">Retry</button></div>`);
|
||||||
var root_1$4 = /* @__PURE__ */ from_html(`<img class="radar-tile tile-overlay svelte-tjvfz7" alt="" loading="lazy" draggable="false"/>`);
|
var root_1$4 = /* @__PURE__ */ from_html(`<img class="radar-tile tile-overlay svelte-tjvfz7" alt="" loading="lazy" draggable="false"/>`);
|
||||||
@ -7808,11 +7753,6 @@ function PrecipitationRadar($$anchor, $$props) {
|
|||||||
const TILE_SIZE = 256;
|
const TILE_SIZE = 256;
|
||||||
const RADAR_COLOR = 4;
|
const RADAR_COLOR = 4;
|
||||||
const loc = /* @__PURE__ */ user_derived(() => app$1.selectedLocation);
|
const loc = /* @__PURE__ */ user_derived(() => app$1.selectedLocation);
|
||||||
let strikes = /* @__PURE__ */ state(proxy([]));
|
|
||||||
let lightningError = /* @__PURE__ */ state("");
|
|
||||||
let lightningLoading = /* @__PURE__ */ state(false);
|
|
||||||
let lightningTimer = null;
|
|
||||||
const LIGHTNING_REFRESH_MS = 3e4;
|
|
||||||
user_effect(() => {
|
user_effect(() => {
|
||||||
if (get(loc)) {
|
if (get(loc)) {
|
||||||
set(panX, 0);
|
set(panX, 0);
|
||||||
@ -7838,49 +7778,6 @@ function PrecipitationRadar($$anchor, $$props) {
|
|||||||
set(loading, false);
|
set(loading, false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
async function loadLightning() {
|
|
||||||
if (!get(loc)) return;
|
|
||||||
set(lightningLoading, true);
|
|
||||||
set(lightningError, "");
|
|
||||||
try {
|
|
||||||
const radiusKm = Math.max(40, Math.min(400, 160 * Math.pow(2, 6 - get(zoom))));
|
|
||||||
set(strikes, await fetchLightningStrikes({
|
|
||||||
lat: get(loc).lat,
|
|
||||||
lon: get(loc).lon,
|
|
||||||
radiusKm,
|
|
||||||
limit: 400
|
|
||||||
}), true);
|
|
||||||
} catch (e) {
|
|
||||||
set(strikes, [], true);
|
|
||||||
set(lightningError, e.name === "AbortError" ? "Lightning layer timed out" : e.message || "Lightning layer unavailable", true);
|
|
||||||
console.warn("Lightning layer unavailable:", e.message);
|
|
||||||
} finally {
|
|
||||||
set(lightningLoading, false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
function startLightningTimer() {
|
|
||||||
stopLightningTimer();
|
|
||||||
lightningTimer = setInterval(loadLightning, LIGHTNING_REFRESH_MS);
|
|
||||||
}
|
|
||||||
function stopLightningTimer() {
|
|
||||||
if (lightningTimer) {
|
|
||||||
clearInterval(lightningTimer);
|
|
||||||
lightningTimer = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
user_effect(() => {
|
|
||||||
if (get(loc)) {
|
|
||||||
loadLightning();
|
|
||||||
startLightningTimer();
|
|
||||||
return () => {
|
|
||||||
stopLightningTimer();
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
stopLightningTimer();
|
|
||||||
set(strikes, [], true);
|
|
||||||
set(lightningError, "");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
const pastFrames = /* @__PURE__ */ user_derived(() => get(radarData)?.radar?.past || []);
|
const pastFrames = /* @__PURE__ */ user_derived(() => get(radarData)?.radar?.past || []);
|
||||||
const nowcastFrames = /* @__PURE__ */ user_derived(() => get(radarData)?.radar?.nowcast || []);
|
const nowcastFrames = /* @__PURE__ */ user_derived(() => get(radarData)?.radar?.nowcast || []);
|
||||||
const allFrames = /* @__PURE__ */ user_derived(() => [...get(pastFrames), ...get(nowcastFrames)]);
|
const allFrames = /* @__PURE__ */ user_derived(() => [...get(pastFrames), ...get(nowcastFrames)]);
|
||||||
@ -9195,7 +9092,7 @@ delegate([
|
|||||||
]);
|
]);
|
||||||
//#endregion
|
//#endregion
|
||||||
//#region src/main.js
|
//#region src/main.js
|
||||||
console.info({ commit_hash: "86acc5614316d179e884e15c8c697a00896da03e" });
|
console.info({ commit_hash: "ef4a8b4756296e88b382778369971e5db6e43331" });
|
||||||
registerServiceWorker();
|
registerServiceWorker();
|
||||||
mount(App, { target: document.getElementById("app") });
|
mount(App, { target: document.getElementById("app") });
|
||||||
//#endregion</script>
|
//#endregion</script>
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
<script>
|
<script>
|
||||||
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 } from '../lib/api/lightning.js'
|
|
||||||
|
|
||||||
// --- Radar data ---
|
// --- Radar data ---
|
||||||
let radarData = $state(null)
|
let radarData = $state(null)
|
||||||
@ -21,14 +20,6 @@
|
|||||||
const RADAR_COLOR = 4
|
const RADAR_COLOR = 4
|
||||||
|
|
||||||
const loc = $derived(app.selectedLocation)
|
const loc = $derived(app.selectedLocation)
|
||||||
|
|
||||||
// --- Lightning strikes (fetched for notifications only, never drawn on the map) ---
|
|
||||||
let strikes = $state([])
|
|
||||||
let lightningError = $state('')
|
|
||||||
let lightningLoading = $state(false)
|
|
||||||
let lightningTimer = null
|
|
||||||
const LIGHTNING_REFRESH_MS = 30_000
|
|
||||||
|
|
||||||
// --- Load radar metadata ---
|
// --- Load radar metadata ---
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (loc) {
|
if (loc) {
|
||||||
@ -55,51 +46,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Lightning layer ---
|
|
||||||
async function loadLightning() {
|
|
||||||
if (!loc) return
|
|
||||||
lightningLoading = true
|
|
||||||
lightningError = ''
|
|
||||||
try {
|
|
||||||
// radius scales with zoom so the fetched area roughly matches the view
|
|
||||||
const radiusKm = Math.max(40, Math.min(400, 160 * Math.pow(2, 6 - zoom)))
|
|
||||||
strikes = await fetchLightningStrikes({
|
|
||||||
lat: loc.lat,
|
|
||||||
lon: loc.lon,
|
|
||||||
radiusKm,
|
|
||||||
limit: 400,
|
|
||||||
})
|
|
||||||
} catch (e) {
|
|
||||||
// Relay not deployed yet (or down) — fail gracefully, don't break the map.
|
|
||||||
strikes = []
|
|
||||||
lightningError = e.name === 'AbortError'
|
|
||||||
? 'Lightning layer timed out'
|
|
||||||
: (e.message || 'Lightning layer unavailable')
|
|
||||||
console.warn('Lightning layer unavailable:', e.message)
|
|
||||||
} finally {
|
|
||||||
lightningLoading = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
function startLightningTimer() {
|
|
||||||
stopLightningTimer()
|
|
||||||
lightningTimer = setInterval(loadLightning, LIGHTNING_REFRESH_MS)
|
|
||||||
}
|
|
||||||
function stopLightningTimer() {
|
|
||||||
if (lightningTimer) { clearInterval(lightningTimer); lightningTimer = null }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Re-fetch lightning whenever the selected location or data source changes (notifications).
|
|
||||||
$effect(() => {
|
|
||||||
if (loc) {
|
|
||||||
loadLightning()
|
|
||||||
startLightningTimer()
|
|
||||||
return () => { stopLightningTimer() }
|
|
||||||
} else {
|
|
||||||
stopLightningTimer()
|
|
||||||
strikes = []
|
|
||||||
lightningError = ''
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// --- Frame data ---
|
// --- Frame data ---
|
||||||
const pastFrames = $derived(radarData?.radar?.past || [])
|
const pastFrames = $derived(radarData?.radar?.past || [])
|
||||||
|
|||||||
@ -1,143 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@ -126,7 +126,6 @@ const DEFAULT_SETTINGS = {
|
|||||||
source: 'open-meteo',
|
source: 'open-meteo',
|
||||||
refreshInterval: 30, // minutes; 0 = no auto-refresh
|
refreshInterval: 30, // minutes; 0 = no auto-refresh
|
||||||
alertsEnabled: true,
|
alertsEnabled: true,
|
||||||
showLightning: false, // toggleable lightning strike layer on the radar map
|
|
||||||
notificationTypes: {
|
notificationTypes: {
|
||||||
storm: true,
|
storm: true,
|
||||||
precip: true,
|
precip: true,
|
||||||
|
|||||||
@ -35,7 +35,6 @@ export class AppStore {
|
|||||||
source: 'open-meteo',
|
source: 'open-meteo',
|
||||||
refreshInterval: 30,
|
refreshInterval: 30,
|
||||||
alertsEnabled: true,
|
alertsEnabled: true,
|
||||||
showLightning: false, // toggleable lightning strike layer on the radar map
|
|
||||||
notificationTypes: {
|
notificationTypes: {
|
||||||
storm: true,
|
storm: true,
|
||||||
precip: true,
|
precip: true,
|
||||||
|
|||||||
@ -1,130 +0,0 @@
|
|||||||
import { describe, it, expect, vi, afterEach } from 'vitest'
|
|
||||||
import { fetchLightningStrikes, projectStrikeToLayer, haversineKm, filterStrikesWithinKm } from '../../../src/lib/api/lightning.js'
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
vi.restoreAllMocks()
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('fetchLightningStrikes', () => {
|
|
||||||
it('builds the relay URL with lat/lon/radius/limit', async () => {
|
|
||||||
const fetchMock = vi.fn().mockResolvedValue({
|
|
||||||
ok: true,
|
|
||||||
json: async () => ({ source: 'blitzortung', strikes: [] }),
|
|
||||||
})
|
|
||||||
globalThis.fetch = fetchMock
|
|
||||||
|
|
||||||
await fetchLightningStrikes({ lat: 36.35, lon: -95.81, radiusKm: 100, limit: 50 })
|
|
||||||
|
|
||||||
const [url] = fetchMock.mock.calls[0]
|
|
||||||
expect(url).toContain('https://cors.thecookiejar.me/lightning?')
|
|
||||||
expect(url).toContain('lat=36.35')
|
|
||||||
expect(url).toContain('lon=-95.81')
|
|
||||||
expect(url).toContain('radiusKm=100')
|
|
||||||
expect(url).toContain('limit=50')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns the strike list on a valid response', async () => {
|
|
||||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
|
||||||
ok: true,
|
|
||||||
json: async () => ({
|
|
||||||
source: 'blitzortung',
|
|
||||||
strikes: [
|
|
||||||
{ lat: 36.3, lon: -95.8, time: 1724671234, strength: -12 },
|
|
||||||
{ lat: 36.5, lon: -95.9, time: 1724671299 },
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
|
|
||||||
const strikes = await fetchLightningStrikes({ lat: 36.35, lon: -95.81 })
|
|
||||||
expect(strikes).toHaveLength(2)
|
|
||||||
expect(strikes[0]).toMatchObject({ lat: 36.3, lon: -95.8, strength: -12 })
|
|
||||||
})
|
|
||||||
|
|
||||||
it('throws on a non-OK relay response', async () => {
|
|
||||||
globalThis.fetch = vi.fn().mockResolvedValue({ ok: false, status: 502, statusText: 'Bad Gateway' })
|
|
||||||
await expect(fetchLightningStrikes({ lat: 0, lon: 0 })).rejects.toThrow(/Lightning relay error: 502/)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('throws on a malformed response (no strikes array)', async () => {
|
|
||||||
globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ foo: 1 }) })
|
|
||||||
await expect(fetchLightningStrikes({ lat: 0, lon: 0 })).rejects.toThrow(/invalid response/)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('throws when the relay is unreachable (network failure)', async () => {
|
|
||||||
globalThis.fetch = vi.fn().mockRejectedValue(new TypeError('Failed to fetch'))
|
|
||||||
await expect(fetchLightningStrikes({ lat: 0, lon: 0 })).rejects.toThrow()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('projectStrikeToLayer', () => {
|
|
||||||
// Center tile is (0.5, 0.5) at zoom 0 (lon=0,lat=0). A strike exactly at the
|
|
||||||
// location should land at the center of the 5x5 layer: (2.5, 2.5) tiles.
|
|
||||||
it('maps a strike at the center location to the layer center', () => {
|
|
||||||
const { x, y } = projectStrikeToLayer(0, 0, { zoom: 0, cx: 0.5, cy: 0.5, tileSize: 256 })
|
|
||||||
expect(x).toBeCloseTo(640)
|
|
||||||
expect(y).toBeCloseTo(640)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('scales with tile size', () => {
|
|
||||||
const { x } = projectStrikeToLayer(0, 0, { zoom: 0, cx: 0.5, cy: 0.5, tileSize: 128 })
|
|
||||||
expect(x).toBeCloseTo(320)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('moves the strike right as lon increases', () => {
|
|
||||||
const a = projectStrikeToLayer(0, 1, { zoom: 0, cx: 0.5, cy: 0.5, tileSize: 256 })
|
|
||||||
const b = projectStrikeToLayer(0, 5, { zoom: 0, cx: 0.5, cy: 0.5, tileSize: 256 })
|
|
||||||
expect(a.x).toBeGreaterThan(640)
|
|
||||||
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)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
Loading…
x
Reference in New Issue
Block a user