-
Precipitation Radar
+
+
Precipitation Radar
+ {#if loc}
+
+ {/if}
+
{#if error && !loc}
@@ -218,6 +306,19 @@
/>
{/if}
{/each}
+
+
+ {#if showLightning}
+
+ {#each strikeMarkers as s (s.time + '_' + s.lat + '_' + s.lon)}
+
+ {/each}
+
+ {/if}
@@ -246,6 +347,21 @@
{/if}
+
+ {#if showLightning && lightningLoading}
+
@@ -308,6 +424,38 @@
color: var(--color-text-secondary);
}
+ .radar-title-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+ }
+
+ .layer-toggle {
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ padding: 5px 11px;
+ font-size: 0.78rem;
+ font-weight: 600;
+ border-radius: 16px;
+ border: 1px solid var(--color-border);
+ background: var(--color-surface);
+ color: var(--color-text-muted);
+ cursor: pointer;
+ margin-bottom: 12px;
+ transition: background var(--transition), color var(--transition), border-color var(--transition);
+ }
+ .layer-toggle:hover {
+ border-color: var(--color-primary);
+ color: var(--color-text);
+ }
+ .layer-toggle.on {
+ background: rgba(250, 204, 21, 0.18);
+ border-color: #facc15;
+ color: #fde047;
+ }
+
.radar-status,
.radar-status-err {
display: flex;
@@ -411,6 +559,41 @@
z-index: 1;
}
+ /* ── Lightning strike layer ── */
+ .lightning-layer {
+ position: absolute;
+ inset: 0;
+ z-index: 2;
+ pointer-events: none;
+ }
+
+ .lightning-strike {
+ position: absolute;
+ width: 8px;
+ height: 8px;
+ margin-left: -4px;
+ margin-top: -4px;
+ border-radius: 50%;
+ background: #facc15;
+ box-shadow: 0 0 6px 2px rgba(250, 204, 21, 0.85);
+ pointer-events: auto;
+ }
+
+ .lightning-status {
+ position: absolute;
+ top: 12px;
+ right: 12px;
+ z-index: 10;
+ padding: 5px 10px;
+ background: rgba(10, 22, 40, 0.8);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-sm);
+ pointer-events: none;
+ }
+ .lightning-status-err {
+ border-color: rgba(239, 68, 68, 0.4);
+ }
+
/* ── Location marker ── */
.location-marker {
position: absolute;
diff --git a/src/lib/api/lightning.js b/src/lib/api/lightning.js
new file mode 100644
index 0000000..6ded7d1
--- /dev/null
+++ b/src/lib/api/lightning.js
@@ -0,0 +1,104 @@
+/**
+ * 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=
&lon=&radiusKm=&limit=
+ * -> 200 { "source": "blitzortung", "generatedAt": ,
+ * "strikes": [ { "lat": , "lon": ,
+ * "time": , "strength": } ] }
+ *
+ * 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>}
+ * 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)
+}
diff --git a/src/lib/storage/db.js b/src/lib/storage/db.js
index d7eec40..f15b9e6 100644
--- a/src/lib/storage/db.js
+++ b/src/lib/storage/db.js
@@ -126,6 +126,7 @@ const DEFAULT_SETTINGS = {
source: 'open-meteo',
refreshInterval: 30, // minutes; 0 = no auto-refresh
alertsEnabled: true,
+ showLightning: false, // toggleable lightning strike layer on the radar map
alertThresholds: {
precip: 70,
windGust: 40,
diff --git a/src/lib/stores/app.svelte.js b/src/lib/stores/app.svelte.js
index 893a724..becfd1a 100644
--- a/src/lib/stores/app.svelte.js
+++ b/src/lib/stores/app.svelte.js
@@ -35,6 +35,7 @@ export class AppStore {
source: 'open-meteo',
refreshInterval: 30,
alertsEnabled: true,
+ showLightning: false, // toggleable lightning strike layer on the radar map
alertThresholds: {
precip: 70,
windGust: 40,
diff --git a/tests/lib/api/lightning.test.js b/tests/lib/api/lightning.test.js
new file mode 100644
index 0000000..ccd3e58
--- /dev/null
+++ b/tests/lib/api/lightning.test.js
@@ -0,0 +1,79 @@
+import { describe, it, expect, vi, afterEach } from 'vitest'
+import { fetchLightningStrikes, projectStrikeToLayer } 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)
+ })
+})