diff --git a/dist/index.html b/dist/index.html index e0e1dfb..bda56b1 100644 --- a/dist/index.html +++ b/dist/index.html @@ -5124,6 +5124,7 @@ var init_db = __esmMin((() => { source: "open-meteo", refreshInterval: 30, alertsEnabled: true, + showLightning: false, alertThresholds: { precip: 70, windGust: 40, @@ -5674,6 +5675,7 @@ var AppStore = class { source: "open-meteo", refreshInterval: 30, alertsEnabled: true, + showLightning: false, alertThresholds: { precip: 70, windGust: 40, @@ -7087,17 +7089,113 @@ function WeatherDetail($$anchor, $$props) { pop(); } //#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=&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. +*/ +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>} +* 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; +} +/** +* 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. +*/ +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); +} +//#endregion //#region src/components/PrecipitationRadar.svelte -var root$4 = /* @__PURE__ */ from_html(`
`); -var root_1$4 = /* @__PURE__ */ from_html(``); -var root_2$3 = /* @__PURE__ */ from_html(` `, 1); -var root_3$3 = /* @__PURE__ */ from_html(`
`, 1); -var root_4$3 = /* @__PURE__ */ from_html(`
Loading radar...
`); -var root_5$3 = /* @__PURE__ */ from_html(`
`); -var root_6$2 = /* @__PURE__ */ from_html(`
`); -var root_7$1 = /* @__PURE__ */ from_html(`

No radar data available.

`); -var root_8 = /* @__PURE__ */ from_html(`
`, 1); -var root_9 = /* @__PURE__ */ from_html(`

Precipitation Radar

`); +init_db(); +var root$4 = /* @__PURE__ */ from_html(``); +var root_1$4 = /* @__PURE__ */ from_html(`
`); +var root_2$3 = /* @__PURE__ */ from_html(``); +var root_3$3 = /* @__PURE__ */ from_html(` `, 1); +var root_4$3 = /* @__PURE__ */ from_html(``); +var root_5$3 = /* @__PURE__ */ from_html(`
`); +var root_6$2 = /* @__PURE__ */ from_html(`
`, 1); +var root_7$1 = /* @__PURE__ */ from_html(`
Loading radar...
`); +var root_8 = /* @__PURE__ */ from_html(`
`); +var root_9 = /* @__PURE__ */ from_html(`
⏳ Loading lightning...
`); +var root_10 = /* @__PURE__ */ from_html(`
`); +var root_11 = /* @__PURE__ */ from_html(`
⚡ No recent strikes nearby
`); +var root_12 = /* @__PURE__ */ from_html(`
`); +var root_13 = /* @__PURE__ */ from_html(`

No radar data available.

`); +var root_14 = /* @__PURE__ */ from_html(`
`, 1); +var root_15 = /* @__PURE__ */ from_html(`

Precipitation Radar

`); function PrecipitationRadar($$anchor, $$props) { push($$props, true); let radarData = /* @__PURE__ */ state(null); @@ -7121,6 +7219,12 @@ function PrecipitationRadar($$anchor, $$props) { const TILE_SIZE = 256; const RADAR_COLOR = 4; const loc = /* @__PURE__ */ user_derived(() => app$1.selectedLocation); + let showLightning = /* @__PURE__ */ state(proxy(app$1.settings.showLightning || false)); + let strikes = /* @__PURE__ */ state(proxy([])); + let lightningError = /* @__PURE__ */ state(""); + let lightningLoading = /* @__PURE__ */ state(false); + let lightningTimer = null; + const LIGHTNING_REFRESH_MS = 3e4; user_effect(() => { if (get(loc)) { set(panX, 0); @@ -7146,6 +7250,69 @@ function PrecipitationRadar($$anchor, $$props) { set(loading, false); } } + async function loadLightning() { + if (!get(loc) || !get(showLightning)) 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 toggleLightning() { + set(showLightning, !get(showLightning)); + app$1.settings = { + ...app$1.settings, + showLightning: get(showLightning) + }; + saveSettings({ + ...app$1.settings, + alertThresholds: { ...app$1.settings.alertThresholds } + }).catch(() => {}); + } + function startLightningTimer() { + stopLightningTimer(); + lightningTimer = setInterval(loadLightning, LIGHTNING_REFRESH_MS); + } + function stopLightningTimer() { + if (lightningTimer) { + clearInterval(lightningTimer); + lightningTimer = null; + } + } + user_effect(() => { + if (get(showLightning) && get(loc)) { + loadLightning(); + startLightningTimer(); + return () => { + stopLightningTimer(); + }; + } else { + stopLightningTimer(); + set(strikes, [], true); + set(lightningError, ""); + } + }); + const strikeMarkers = /* @__PURE__ */ user_derived(() => get(showLightning) ? get(strikes).map((s) => ({ + ...s, + ...projectStrikeToLayer(s.lat, s.lon, { + zoom: get(zoom), + cx: get(cx), + cy: get(cy), + tileSize: TILE_SIZE + }) + })) : []); const pastFrames = /* @__PURE__ */ user_derived(() => get(radarData)?.radar?.past || []); const nowcastFrames = /* @__PURE__ */ user_derived(() => get(radarData)?.radar?.nowcast || []); const allFrames = /* @__PURE__ */ user_derived(() => [...get(pastFrames), ...get(nowcastFrames)]); @@ -7272,41 +7439,58 @@ function PrecipitationRadar($$anchor, $$props) { } const isPast = /* @__PURE__ */ user_derived(() => get(currentIdx) < get(pastFrames).length); const frameLabel = /* @__PURE__ */ user_derived(() => get(isPast) ? "Past" : "Forecast"); - var div = root_9(); - var node = sibling(child(div), 2); + var div = root_15(); + var div_1 = child(div); + var node = sibling(child(div_1), 2); var consequent = ($$anchor) => { - var div_1 = root$4(); - var span = child(div_1); + var button = root$4(); + let classes; + template_effect(() => { + classes = set_class(button, 1, "layer-toggle svelte-tjvfz7", null, classes, { on: get(showLightning) }); + set_attribute(button, "aria-checked", get(showLightning)); + }); + delegated("click", button, toggleLightning); + append($$anchor, button); + }; + if_block(node, ($$render) => { + if (get(loc)) $$render(consequent); + }); + reset(div_1); + var node_1 = sibling(div_1, 2); + var consequent_1 = ($$anchor) => { + var div_2 = root_1$4(); + var span = child(div_2); var text = child(span); reset(span); - var button = sibling(span, 2); - reset(div_1); + var button_1 = sibling(span, 2); + reset(div_2); template_effect(() => set_text(text, `⚠️ ${get(error) ?? ""}`)); - delegated("click", button, loadRadar); - append($$anchor, div_1); + delegated("click", button_1, loadRadar); + append($$anchor, div_2); }; var alternate = ($$anchor) => { - var fragment = root_8(); - var div_2 = first_child(fragment); - let classes; - var node_1 = child(div_2); - var consequent_2 = ($$anchor) => { - var fragment_1 = root_3$3(); - var div_3 = first_child(fragment_1); - each(div_3, 21, () => get(tiles), (t) => t.col + "_" + t.row, ($$anchor, t) => { - var fragment_2 = root_2$3(); + var fragment = root_14(); + var div_3 = first_child(fragment); + let classes_1; + var node_2 = child(div_3); + var consequent_4 = ($$anchor) => { + var fragment_1 = root_6$2(); + var div_4 = first_child(fragment_1); + var node_3 = child(div_4); + each(node_3, 17, () => get(tiles), (t) => t.col + "_" + t.row, ($$anchor, t) => { + var fragment_2 = root_3$3(); var img = first_child(fragment_2); - var node_2 = sibling(img, 2); - var consequent_1 = ($$anchor) => { - var img_1 = root_1$4(); + var node_4 = sibling(img, 2); + var consequent_2 = ($$anchor) => { + var img_1 = root_2$3(); template_effect(($0) => { set_attribute(img_1, "src", $0); set_style(img_1, `grid-column: ${get(t).col + 3}; grid-row: ${get(t).row + 3}`); }, [() => radarUrl(get(t).tx, get(t).ty)]); append($$anchor, img_1); }; - if_block(node_2, ($$render) => { - if (get(frame)) $$render(consequent_1); + if_block(node_4, ($$render) => { + if (get(frame)) $$render(consequent_2); }); template_effect(($0) => { set_attribute(img, "src", $0); @@ -7314,95 +7498,133 @@ function PrecipitationRadar($$anchor, $$props) { }, [() => osmUrl(get(t).tx, get(t).ty)]); append($$anchor, fragment_2); }); - reset(div_3); - var div_4 = sibling(div_3, 2); + var node_5 = sibling(node_3, 2); + var consequent_3 = ($$anchor) => { + var div_5 = root_5$3(); + each(div_5, 21, () => get(strikeMarkers), (s) => s.time + "_" + s.lat + "_" + s.lon, ($$anchor, s) => { + var span_1 = root_4$3(); + template_effect(($0) => { + set_attribute(span_1, "title", $0); + set_style(span_1, `left: ${get(s).x ?? ""}px; top: ${get(s).y ?? ""}px`); + }, [() => "Strike " + (/* @__PURE__ */ new Date(get(s).time * 1e3)).toLocaleString()]); + append($$anchor, span_1); + }); + reset(div_5); + append($$anchor, div_5); + }; + if_block(node_5, ($$render) => { + if (get(showLightning)) $$render(consequent_3); + }); + reset(div_4); + var div_6 = sibling(div_4, 2); template_effect(() => { - set_style(div_3, `transform: translate(${get(panX) + 128 - get(pxOff)}px, ${get(panY) + 128 - get(pyOff)}px)`); - set_attribute(div_4, "title", get(loc)?.name || ""); - set_style(div_4, `transform: translate(calc(-50% + ${get(panX) ?? ""}px), calc(-50% + ${get(panY) ?? ""}px))`); + set_style(div_4, `transform: translate(${get(panX) + 128 - get(pxOff)}px, ${get(panY) + 128 - get(pyOff)}px)`); + set_attribute(div_6, "title", get(loc)?.name || ""); + set_style(div_6, `transform: translate(calc(-50% + ${get(panX) ?? ""}px), calc(-50% + ${get(panY) ?? ""}px))`); }); append($$anchor, fragment_1); }; - if_block(node_1, ($$render) => { - if (get(loc)) $$render(consequent_2); + if_block(node_2, ($$render) => { + if (get(loc)) $$render(consequent_4); }); - var node_3 = sibling(node_1, 2); - var consequent_3 = ($$anchor) => { - append($$anchor, root_4$3()); - }; - if_block(node_3, ($$render) => { - if (get(loading)) $$render(consequent_3); - }); - var node_4 = sibling(node_3, 2); - var consequent_4 = ($$anchor) => { - var div_6 = root_5$3(); - var span_1 = child(div_6); - var text_1 = child(span_1); - reset(span_1); - var button_1 = sibling(span_1, 2); - reset(div_6); - template_effect(() => set_text(text_1, `⚠️ ${get(error) ?? ""}`)); - delegated("click", button_1, loadRadar); - append($$anchor, div_6); - }; - if_block(node_4, ($$render) => { - if (get(error)) $$render(consequent_4); - }); - var div_7 = sibling(node_4, 2); - var button_2 = child(div_7); - var span_2 = sibling(button_2, 2); - var text_2 = child(span_2, true); - reset(span_2); - var button_3 = sibling(span_2, 2); - reset(div_7); - reset(div_2); - var node_5 = sibling(div_2, 2); + var node_6 = sibling(node_2, 2); var consequent_5 = ($$anchor) => { - var div_8 = root_6$2(); - var div_9 = child(div_8); - var button_4 = child(div_9); - var button_5 = sibling(button_4, 2); - var text_3 = child(button_5, true); - reset(button_5); - var button_6 = sibling(button_5, 2); - reset(div_9); - var div_10 = sibling(div_9, 2); - var input = child(div_10); - remove_input_defaults(input); - reset(div_10); - var div_11 = sibling(div_10, 2); - var span_3 = child(div_11); - var text_4 = child(span_3, true); + append($$anchor, root_7$1()); + }; + if_block(node_6, ($$render) => { + if (get(loading)) $$render(consequent_5); + }); + var node_7 = sibling(node_6, 2); + var consequent_6 = ($$anchor) => { + var div_8 = root_8(); + var span_2 = child(div_8); + var text_1 = child(span_2); + reset(span_2); + var button_2 = sibling(span_2, 2); + reset(div_8); + template_effect(() => set_text(text_1, `⚠️ ${get(error) ?? ""}`)); + delegated("click", button_2, loadRadar); + append($$anchor, div_8); + }; + if_block(node_7, ($$render) => { + if (get(error)) $$render(consequent_6); + }); + var node_8 = sibling(node_7, 2); + var consequent_7 = ($$anchor) => { + append($$anchor, root_9()); + }; + var consequent_8 = ($$anchor) => { + var div_10 = root_10(); + var span_3 = child(div_10); + var text_2 = child(span_3); reset(span_3); - var span_4 = sibling(span_3, 2); - var text_5 = child(span_4, true); - reset(span_4); - var span_5 = sibling(span_4, 2); - let classes_1; - var text_6 = child(span_5, true); + reset(div_10); + template_effect(() => set_text(text_2, `⚡ ${get(lightningError) ?? ""} (relay not live yet)`)); + append($$anchor, div_10); + }; + var consequent_9 = ($$anchor) => { + append($$anchor, root_11()); + }; + if_block(node_8, ($$render) => { + if (get(showLightning) && get(lightningLoading)) $$render(consequent_7); + else if (get(showLightning) && get(lightningError)) $$render(consequent_8, 1); + else if (get(showLightning) && get(strikes).length === 0 && !get(lightningLoading)) $$render(consequent_9, 2); + }); + var div_12 = sibling(node_8, 2); + var button_3 = child(div_12); + var span_4 = sibling(button_3, 2); + var text_3 = child(span_4, true); + reset(span_4); + var button_4 = sibling(span_4, 2); + reset(div_12); + reset(div_3); + var node_9 = sibling(div_3, 2); + var consequent_10 = ($$anchor) => { + var div_13 = root_12(); + var div_14 = child(div_13); + var button_5 = child(div_14); + var button_6 = sibling(button_5, 2); + var text_4 = child(button_6, true); + reset(button_6); + var button_7 = sibling(button_6, 2); + reset(div_14); + var div_15 = sibling(div_14, 2); + var input = child(div_15); + remove_input_defaults(input); + reset(div_15); + var div_16 = sibling(div_15, 2); + var span_5 = child(div_16); + var text_5 = child(span_5, true); reset(span_5); var span_6 = sibling(span_5, 2); - var text_7 = child(span_6); + var text_6 = child(span_6, true); reset(span_6); - reset(div_11); - reset(div_8); + var span_7 = sibling(span_6, 2); + let classes_2; + var text_7 = child(span_7, true); + reset(span_7); + var span_8 = sibling(span_7, 2); + var text_8 = child(span_8); + reset(span_8); + reset(div_16); + reset(div_13); template_effect(($0, $1) => { - set_attribute(button_5, "aria-label", get(playing) ? "Pause" : "Play"); - set_text(text_3, get(playing) ? "⏸" : "▶"); + set_attribute(button_6, "aria-label", get(playing) ? "Pause" : "Play"); + set_text(text_4, get(playing) ? "⏸" : "▶"); set_attribute(input, "max", get(totalFrames) - 1); set_value(input, get(currentIdx)); - set_text(text_4, $0); - set_text(text_5, $1); - classes_1 = set_class(span_5, 1, "tl-badge svelte-tjvfz7", null, classes_1, { + set_text(text_5, $0); + set_text(text_6, $1); + classes_2 = set_class(span_7, 1, "tl-badge svelte-tjvfz7", null, classes_2, { "tl-past": get(isPast), "tl-future": !get(isPast) }); - set_text(text_6, get(frameLabel)); - set_text(text_7, `${get(currentIdx) + 1}/${get(totalFrames) ?? ""}`); + set_text(text_7, get(frameLabel)); + set_text(text_8, `${get(currentIdx) + 1}/${get(totalFrames) ?? ""}`); }, [() => fmtTime(get(frame).time), () => fmtDate(get(frame).time)]); - delegated("click", button_4, stepBack); - delegated("click", button_5, togglePlay); - delegated("click", button_6, stepForward); + delegated("click", button_5, stepBack); + delegated("click", button_6, togglePlay); + delegated("click", button_7, stepForward); delegated("input", input, (e) => { set(playing, false); if (animRef) { @@ -7411,35 +7633,35 @@ function PrecipitationRadar($$anchor, $$props) { } set(currentIdx, Number(e.target.value), true); }); - append($$anchor, div_8); + append($$anchor, div_13); }; - var consequent_6 = ($$anchor) => { - var div_12 = root_7$1(); - var button_7 = sibling(child(div_12), 2); - reset(div_12); - delegated("click", button_7, loadRadar); - append($$anchor, div_12); + var consequent_11 = ($$anchor) => { + var div_17 = root_13(); + var button_8 = sibling(child(div_17), 2); + reset(div_17); + delegated("click", button_8, loadRadar); + append($$anchor, div_17); }; - if_block(node_5, ($$render) => { - if (get(frame)) $$render(consequent_5); - else if (!get(loading)) $$render(consequent_6, 1); + if_block(node_9, ($$render) => { + if (get(frame)) $$render(consequent_10); + else if (!get(loading)) $$render(consequent_11, 1); }); template_effect(() => { - classes = set_class(div_2, 1, "radar-viewport svelte-tjvfz7", null, classes, { dragging: get(dragging) }); - button_2.disabled = get(zoom) >= 11; - set_text(text_2, get(zoom)); - button_3.disabled = get(zoom) <= 2; + classes_1 = set_class(div_3, 1, "radar-viewport svelte-tjvfz7", null, classes_1, { dragging: get(dragging) }); + button_3.disabled = get(zoom) >= 11; + set_text(text_3, get(zoom)); + button_4.disabled = get(zoom) <= 2; }); - delegated("pointerdown", div_2, onPointerDown); - delegated("pointermove", div_2, onPointerMove); - delegated("pointerup", div_2, onPointerUp); - event("pointerleave", div_2, onPointerUp); - delegated("click", button_2, zoomIn); - delegated("click", button_3, zoomOut); + delegated("pointerdown", div_3, onPointerDown); + delegated("pointermove", div_3, onPointerMove); + delegated("pointerup", div_3, onPointerUp); + event("pointerleave", div_3, onPointerUp); + delegated("click", button_3, zoomIn); + delegated("click", button_4, zoomOut); append($$anchor, fragment); }; - if_block(node, ($$render) => { - if (get(error) && !get(loc)) $$render(consequent); + if_block(node_1, ($$render) => { + if (get(error) && !get(loc)) $$render(consequent_1); else $$render(alternate, -1); }); reset(div); @@ -9436,6 +9658,38 @@ input::placeholder { color: var(--color-text-secondary); } + .radar-title-row.svelte-tjvfz7 { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + } + + .layer-toggle.svelte-tjvfz7 { + 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.svelte-tjvfz7:hover { + border-color: var(--color-primary); + color: var(--color-text); + } + .layer-toggle.on.svelte-tjvfz7 { + background: rgba(250, 204, 21, 0.18); + border-color: #facc15; + color: #fde047; + } + .radar-status.svelte-tjvfz7, .radar-status-err.svelte-tjvfz7 { display: flex; @@ -9539,6 +9793,41 @@ input::placeholder { z-index: 1; } + /* ── Lightning strike layer ── */ + .lightning-layer.svelte-tjvfz7 { + position: absolute; + inset: 0; + z-index: 2; + pointer-events: none; + } + + .lightning-strike.svelte-tjvfz7 { + 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.svelte-tjvfz7 { + 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.svelte-tjvfz7 { + border-color: rgba(239, 68, 68, 0.4); + } + /* ── Location marker ── */ .location-marker.svelte-tjvfz7 { position: absolute; diff --git a/src/components/PrecipitationRadar.svelte b/src/components/PrecipitationRadar.svelte index 4eb092c..afe9483 100644 --- a/src/components/PrecipitationRadar.svelte +++ b/src/components/PrecipitationRadar.svelte @@ -1,5 +1,7 @@
-

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} +
+ ⏳ Loading lightning... +
+ {:else if showLightning && lightningError} +
+ ⚡ {lightningError} (relay not live yet) +
+ {:else if showLightning && strikes.length === 0 && !lightningLoading} +
+ ⚡ No recent strikes nearby +
+ {/if} +
@@ -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) + }) +})