Add toggleable lightning strike layer to the radar map
- Lightning layer on PrecipitationRadar: toggleable ⚡ button (persisted via showLightning setting). When on, fetches recent strikes from the planned CORS relay and renders them as markers that pan/zoom with the slippy map. - New src/lib/api/lightning.js: fetchLightningStrikes() reads https://cors.thecookiejar.me/lightning?lat&lon&radiusKm&limit (relay NOT deployed yet) with an 8s abort timeout and graceful failure - never blocks or crashes the map, shows an unavailable banner instead. Exports pure projectStrikeToLayer() for Mercator tile-layer marker projection. - showLightning setting added to DEFAULT_SETTINGS + app store; toggle persists. - Tests: fetch URL/parsing/error handling + layer projection. 71 total pass.
This commit is contained in:
parent
3635937085
commit
ff73e58d31
541
dist/index.html
vendored
541
dist/index.html
vendored
@ -5124,6 +5124,7 @@ var init_db = __esmMin((() => {
|
|||||||
source: "open-meteo",
|
source: "open-meteo",
|
||||||
refreshInterval: 30,
|
refreshInterval: 30,
|
||||||
alertsEnabled: true,
|
alertsEnabled: true,
|
||||||
|
showLightning: false,
|
||||||
alertThresholds: {
|
alertThresholds: {
|
||||||
precip: 70,
|
precip: 70,
|
||||||
windGust: 40,
|
windGust: 40,
|
||||||
@ -5674,6 +5675,7 @@ var AppStore = class {
|
|||||||
source: "open-meteo",
|
source: "open-meteo",
|
||||||
refreshInterval: 30,
|
refreshInterval: 30,
|
||||||
alertsEnabled: true,
|
alertsEnabled: true,
|
||||||
|
showLightning: false,
|
||||||
alertThresholds: {
|
alertThresholds: {
|
||||||
precip: 70,
|
precip: 70,
|
||||||
windGust: 40,
|
windGust: 40,
|
||||||
@ -7087,17 +7089,113 @@ 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;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* 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
|
//#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>`);
|
init_db();
|
||||||
var root_1$4 = /* @__PURE__ */ from_html(`<img class="radar-tile tile-overlay svelte-tjvfz7" alt="" loading="lazy" draggable="false"/>`);
|
var root$4 = /* @__PURE__ */ from_html(`<button role="switch" title="Toggle lightning strike layer">⚡ Lightning</button>`);
|
||||||
var root_2$3 = /* @__PURE__ */ from_html(`<img class="radar-tile tile-base svelte-tjvfz7" alt="" loading="lazy" draggable="false"/> <!>`, 1);
|
var root_1$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_3$3 = /* @__PURE__ */ from_html(`<div class="radar-layer svelte-tjvfz7"></div> <div class="location-marker svelte-tjvfz7"><span class="marker-dot svelte-tjvfz7"></span></div>`, 1);
|
var root_2$3 = /* @__PURE__ */ from_html(`<img class="radar-tile tile-overlay svelte-tjvfz7" alt="" loading="lazy" draggable="false"/>`);
|
||||||
var root_4$3 = /* @__PURE__ */ from_html(`<div class="radar-overlay svelte-tjvfz7"><span class="loading-spinner svelte-tjvfz7">⏳</span> <span class="text-xs text-muted svelte-tjvfz7">Loading radar...</span></div>`);
|
var root_3$3 = /* @__PURE__ */ from_html(`<img class="radar-tile tile-base svelte-tjvfz7" alt="" loading="lazy" draggable="false"/> <!>`, 1);
|
||||||
var root_5$3 = /* @__PURE__ */ from_html(`<div class="radar-error-banner svelte-tjvfz7"><span class="text-xs svelte-tjvfz7"> </span> <button class="btn btn-ghost btn-sm svelte-tjvfz7">Retry</button></div>`);
|
var root_4$3 = /* @__PURE__ */ from_html(`<span class="lightning-strike svelte-tjvfz7"></span>`);
|
||||||
var root_6$2 = /* @__PURE__ */ from_html(`<div class="timeline svelte-tjvfz7"><div class="tl-controls svelte-tjvfz7"><button class="ctrl-btn svelte-tjvfz7" aria-label="Previous" title="Previous">⏮</button> <button class="ctrl-btn play-btn svelte-tjvfz7"> </button> <button class="ctrl-btn svelte-tjvfz7" aria-label="Next" title="Next">⏭</button></div> <div class="tl-slider svelte-tjvfz7"><input type="range" min="0" aria-label="Radar timeline" class="svelte-tjvfz7"/></div> <div class="tl-info svelte-tjvfz7"><span class="tl-time svelte-tjvfz7"> </span> <span class="tl-date text-muted svelte-tjvfz7"> </span> <span> </span> <span class="text-muted text-xs svelte-tjvfz7"> </span></div></div>`);
|
var root_5$3 = /* @__PURE__ */ from_html(`<div class="lightning-layer svelte-tjvfz7" aria-label="Lightning strikes"></div>`);
|
||||||
var root_7$1 = /* @__PURE__ */ from_html(`<div class="radar-status svelte-tjvfz7"><p class="text-muted svelte-tjvfz7">No radar data available.</p> <button class="btn btn-ghost btn-sm svelte-tjvfz7">Reload</button></div>`);
|
var root_6$2 = /* @__PURE__ */ from_html(`<div class="radar-layer svelte-tjvfz7"><!> <!></div> <div class="location-marker svelte-tjvfz7"><span class="marker-dot svelte-tjvfz7"></span></div>`, 1);
|
||||||
var root_8 = /* @__PURE__ */ from_html(`<div style="touch-action: none" role="img" aria-label="Precipitation radar map"><!> <!> <!> <div class="zoom-ctrl svelte-tjvfz7"><button class="zoom-btn svelte-tjvfz7" aria-label="Zoom in">+</button> <span class="zoom-level text-xs text-muted svelte-tjvfz7"> </span> <button class="zoom-btn svelte-tjvfz7" aria-label="Zoom out">−</button></div></div> <!>`, 1);
|
var root_7$1 = /* @__PURE__ */ from_html(`<div class="radar-overlay svelte-tjvfz7"><span class="loading-spinner svelte-tjvfz7">⏳</span> <span class="text-xs text-muted svelte-tjvfz7">Loading radar...</span></div>`);
|
||||||
var root_9 = /* @__PURE__ */ from_html(`<div class="radar-section svelte-tjvfz7"><h3 class="section-title svelte-tjvfz7">Precipitation Radar</h3> <!></div>`);
|
var root_8 = /* @__PURE__ */ from_html(`<div class="radar-error-banner svelte-tjvfz7"><span class="text-xs svelte-tjvfz7"> </span> <button class="btn btn-ghost btn-sm svelte-tjvfz7">Retry</button></div>`);
|
||||||
|
var root_9 = /* @__PURE__ */ from_html(`<div class="lightning-status svelte-tjvfz7"><span class="text-xs svelte-tjvfz7">⏳ Loading lightning...</span></div>`);
|
||||||
|
var root_10 = /* @__PURE__ */ from_html(`<div class="lightning-status lightning-status-err svelte-tjvfz7"><span class="text-xs svelte-tjvfz7"> </span></div>`);
|
||||||
|
var root_11 = /* @__PURE__ */ from_html(`<div class="lightning-status svelte-tjvfz7"><span class="text-xs svelte-tjvfz7">⚡ No recent strikes nearby</span></div>`);
|
||||||
|
var root_12 = /* @__PURE__ */ from_html(`<div class="timeline svelte-tjvfz7"><div class="tl-controls svelte-tjvfz7"><button class="ctrl-btn svelte-tjvfz7" aria-label="Previous" title="Previous">⏮</button> <button class="ctrl-btn play-btn svelte-tjvfz7"> </button> <button class="ctrl-btn svelte-tjvfz7" aria-label="Next" title="Next">⏭</button></div> <div class="tl-slider svelte-tjvfz7"><input type="range" min="0" aria-label="Radar timeline" class="svelte-tjvfz7"/></div> <div class="tl-info svelte-tjvfz7"><span class="tl-time svelte-tjvfz7"> </span> <span class="tl-date text-muted svelte-tjvfz7"> </span> <span> </span> <span class="text-muted text-xs svelte-tjvfz7"> </span></div></div>`);
|
||||||
|
var root_13 = /* @__PURE__ */ from_html(`<div class="radar-status svelte-tjvfz7"><p class="text-muted svelte-tjvfz7">No radar data available.</p> <button class="btn btn-ghost btn-sm svelte-tjvfz7">Reload</button></div>`);
|
||||||
|
var root_14 = /* @__PURE__ */ from_html(`<div style="touch-action: none" role="img" aria-label="Precipitation radar map"><!> <!> <!> <!> <div class="zoom-ctrl svelte-tjvfz7"><button class="zoom-btn svelte-tjvfz7" aria-label="Zoom in">+</button> <span class="zoom-level text-xs text-muted svelte-tjvfz7"> </span> <button class="zoom-btn svelte-tjvfz7" aria-label="Zoom out">−</button></div></div> <!>`, 1);
|
||||||
|
var root_15 = /* @__PURE__ */ from_html(`<div class="radar-section svelte-tjvfz7"><div class="radar-title-row svelte-tjvfz7"><h3 class="section-title svelte-tjvfz7">Precipitation Radar</h3> <!></div> <!></div>`);
|
||||||
function PrecipitationRadar($$anchor, $$props) {
|
function PrecipitationRadar($$anchor, $$props) {
|
||||||
push($$props, true);
|
push($$props, true);
|
||||||
let radarData = /* @__PURE__ */ state(null);
|
let radarData = /* @__PURE__ */ state(null);
|
||||||
@ -7121,6 +7219,12 @@ 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 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(() => {
|
user_effect(() => {
|
||||||
if (get(loc)) {
|
if (get(loc)) {
|
||||||
set(panX, 0);
|
set(panX, 0);
|
||||||
@ -7146,6 +7250,69 @@ function PrecipitationRadar($$anchor, $$props) {
|
|||||||
set(loading, false);
|
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 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)]);
|
||||||
@ -7272,41 +7439,58 @@ function PrecipitationRadar($$anchor, $$props) {
|
|||||||
}
|
}
|
||||||
const isPast = /* @__PURE__ */ user_derived(() => get(currentIdx) < get(pastFrames).length);
|
const isPast = /* @__PURE__ */ user_derived(() => get(currentIdx) < get(pastFrames).length);
|
||||||
const frameLabel = /* @__PURE__ */ user_derived(() => get(isPast) ? "Past" : "Forecast");
|
const frameLabel = /* @__PURE__ */ user_derived(() => get(isPast) ? "Past" : "Forecast");
|
||||||
var div = root_9();
|
var div = root_15();
|
||||||
var node = sibling(child(div), 2);
|
var div_1 = child(div);
|
||||||
|
var node = sibling(child(div_1), 2);
|
||||||
var consequent = ($$anchor) => {
|
var consequent = ($$anchor) => {
|
||||||
var div_1 = root$4();
|
var button = root$4();
|
||||||
var span = child(div_1);
|
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);
|
var text = child(span);
|
||||||
reset(span);
|
reset(span);
|
||||||
var button = sibling(span, 2);
|
var button_1 = sibling(span, 2);
|
||||||
reset(div_1);
|
reset(div_2);
|
||||||
template_effect(() => set_text(text, `⚠️ ${get(error) ?? ""}`));
|
template_effect(() => set_text(text, `⚠️ ${get(error) ?? ""}`));
|
||||||
delegated("click", button, loadRadar);
|
delegated("click", button_1, loadRadar);
|
||||||
append($$anchor, div_1);
|
append($$anchor, div_2);
|
||||||
};
|
};
|
||||||
var alternate = ($$anchor) => {
|
var alternate = ($$anchor) => {
|
||||||
var fragment = root_8();
|
var fragment = root_14();
|
||||||
var div_2 = first_child(fragment);
|
var div_3 = first_child(fragment);
|
||||||
let classes;
|
let classes_1;
|
||||||
var node_1 = child(div_2);
|
var node_2 = child(div_3);
|
||||||
var consequent_2 = ($$anchor) => {
|
var consequent_4 = ($$anchor) => {
|
||||||
var fragment_1 = root_3$3();
|
var fragment_1 = root_6$2();
|
||||||
var div_3 = first_child(fragment_1);
|
var div_4 = first_child(fragment_1);
|
||||||
each(div_3, 21, () => get(tiles), (t) => t.col + "_" + t.row, ($$anchor, t) => {
|
var node_3 = child(div_4);
|
||||||
var fragment_2 = root_2$3();
|
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 img = first_child(fragment_2);
|
||||||
var node_2 = sibling(img, 2);
|
var node_4 = sibling(img, 2);
|
||||||
var consequent_1 = ($$anchor) => {
|
var consequent_2 = ($$anchor) => {
|
||||||
var img_1 = root_1$4();
|
var img_1 = root_2$3();
|
||||||
template_effect(($0) => {
|
template_effect(($0) => {
|
||||||
set_attribute(img_1, "src", $0);
|
set_attribute(img_1, "src", $0);
|
||||||
set_style(img_1, `grid-column: ${get(t).col + 3}; grid-row: ${get(t).row + 3}`);
|
set_style(img_1, `grid-column: ${get(t).col + 3}; grid-row: ${get(t).row + 3}`);
|
||||||
}, [() => radarUrl(get(t).tx, get(t).ty)]);
|
}, [() => radarUrl(get(t).tx, get(t).ty)]);
|
||||||
append($$anchor, img_1);
|
append($$anchor, img_1);
|
||||||
};
|
};
|
||||||
if_block(node_2, ($$render) => {
|
if_block(node_4, ($$render) => {
|
||||||
if (get(frame)) $$render(consequent_1);
|
if (get(frame)) $$render(consequent_2);
|
||||||
});
|
});
|
||||||
template_effect(($0) => {
|
template_effect(($0) => {
|
||||||
set_attribute(img, "src", $0);
|
set_attribute(img, "src", $0);
|
||||||
@ -7314,95 +7498,133 @@ function PrecipitationRadar($$anchor, $$props) {
|
|||||||
}, [() => osmUrl(get(t).tx, get(t).ty)]);
|
}, [() => osmUrl(get(t).tx, get(t).ty)]);
|
||||||
append($$anchor, fragment_2);
|
append($$anchor, fragment_2);
|
||||||
});
|
});
|
||||||
reset(div_3);
|
var node_5 = sibling(node_3, 2);
|
||||||
var div_4 = sibling(div_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(() => {
|
template_effect(() => {
|
||||||
set_style(div_3, `transform: translate(${get(panX) + 128 - get(pxOff)}px, ${get(panY) + 128 - get(pyOff)}px)`);
|
set_style(div_4, `transform: translate(${get(panX) + 128 - get(pxOff)}px, ${get(panY) + 128 - get(pyOff)}px)`);
|
||||||
set_attribute(div_4, "title", get(loc)?.name || "");
|
set_attribute(div_6, "title", get(loc)?.name || "");
|
||||||
set_style(div_4, `transform: translate(calc(-50% + ${get(panX) ?? ""}px), calc(-50% + ${get(panY) ?? ""}px))`);
|
set_style(div_6, `transform: translate(calc(-50% + ${get(panX) ?? ""}px), calc(-50% + ${get(panY) ?? ""}px))`);
|
||||||
});
|
});
|
||||||
append($$anchor, fragment_1);
|
append($$anchor, fragment_1);
|
||||||
};
|
};
|
||||||
if_block(node_1, ($$render) => {
|
if_block(node_2, ($$render) => {
|
||||||
if (get(loc)) $$render(consequent_2);
|
if (get(loc)) $$render(consequent_4);
|
||||||
});
|
});
|
||||||
var node_3 = sibling(node_1, 2);
|
var node_6 = sibling(node_2, 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 consequent_5 = ($$anchor) => {
|
var consequent_5 = ($$anchor) => {
|
||||||
var div_8 = root_6$2();
|
append($$anchor, root_7$1());
|
||||||
var div_9 = child(div_8);
|
};
|
||||||
var button_4 = child(div_9);
|
if_block(node_6, ($$render) => {
|
||||||
var button_5 = sibling(button_4, 2);
|
if (get(loading)) $$render(consequent_5);
|
||||||
var text_3 = child(button_5, true);
|
});
|
||||||
reset(button_5);
|
var node_7 = sibling(node_6, 2);
|
||||||
var button_6 = sibling(button_5, 2);
|
var consequent_6 = ($$anchor) => {
|
||||||
reset(div_9);
|
var div_8 = root_8();
|
||||||
var div_10 = sibling(div_9, 2);
|
var span_2 = child(div_8);
|
||||||
var input = child(div_10);
|
var text_1 = child(span_2);
|
||||||
remove_input_defaults(input);
|
reset(span_2);
|
||||||
reset(div_10);
|
var button_2 = sibling(span_2, 2);
|
||||||
var div_11 = sibling(div_10, 2);
|
reset(div_8);
|
||||||
var span_3 = child(div_11);
|
template_effect(() => set_text(text_1, `⚠️ ${get(error) ?? ""}`));
|
||||||
var text_4 = child(span_3, true);
|
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);
|
reset(span_3);
|
||||||
var span_4 = sibling(span_3, 2);
|
reset(div_10);
|
||||||
var text_5 = child(span_4, true);
|
template_effect(() => set_text(text_2, `⚡ ${get(lightningError) ?? ""} (relay not live yet)`));
|
||||||
reset(span_4);
|
append($$anchor, div_10);
|
||||||
var span_5 = sibling(span_4, 2);
|
};
|
||||||
let classes_1;
|
var consequent_9 = ($$anchor) => {
|
||||||
var text_6 = child(span_5, true);
|
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);
|
reset(span_5);
|
||||||
var span_6 = sibling(span_5, 2);
|
var span_6 = sibling(span_5, 2);
|
||||||
var text_7 = child(span_6);
|
var text_6 = child(span_6, true);
|
||||||
reset(span_6);
|
reset(span_6);
|
||||||
reset(div_11);
|
var span_7 = sibling(span_6, 2);
|
||||||
reset(div_8);
|
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) => {
|
template_effect(($0, $1) => {
|
||||||
set_attribute(button_5, "aria-label", get(playing) ? "Pause" : "Play");
|
set_attribute(button_6, "aria-label", get(playing) ? "Pause" : "Play");
|
||||||
set_text(text_3, get(playing) ? "⏸" : "▶");
|
set_text(text_4, get(playing) ? "⏸" : "▶");
|
||||||
set_attribute(input, "max", get(totalFrames) - 1);
|
set_attribute(input, "max", get(totalFrames) - 1);
|
||||||
set_value(input, get(currentIdx));
|
set_value(input, get(currentIdx));
|
||||||
set_text(text_4, $0);
|
set_text(text_5, $0);
|
||||||
set_text(text_5, $1);
|
set_text(text_6, $1);
|
||||||
classes_1 = set_class(span_5, 1, "tl-badge svelte-tjvfz7", null, classes_1, {
|
classes_2 = set_class(span_7, 1, "tl-badge svelte-tjvfz7", null, classes_2, {
|
||||||
"tl-past": get(isPast),
|
"tl-past": get(isPast),
|
||||||
"tl-future": !get(isPast)
|
"tl-future": !get(isPast)
|
||||||
});
|
});
|
||||||
set_text(text_6, get(frameLabel));
|
set_text(text_7, get(frameLabel));
|
||||||
set_text(text_7, `${get(currentIdx) + 1}/${get(totalFrames) ?? ""}`);
|
set_text(text_8, `${get(currentIdx) + 1}/${get(totalFrames) ?? ""}`);
|
||||||
}, [() => fmtTime(get(frame).time), () => fmtDate(get(frame).time)]);
|
}, [() => fmtTime(get(frame).time), () => fmtDate(get(frame).time)]);
|
||||||
delegated("click", button_4, stepBack);
|
delegated("click", button_5, stepBack);
|
||||||
delegated("click", button_5, togglePlay);
|
delegated("click", button_6, togglePlay);
|
||||||
delegated("click", button_6, stepForward);
|
delegated("click", button_7, stepForward);
|
||||||
delegated("input", input, (e) => {
|
delegated("input", input, (e) => {
|
||||||
set(playing, false);
|
set(playing, false);
|
||||||
if (animRef) {
|
if (animRef) {
|
||||||
@ -7411,35 +7633,35 @@ function PrecipitationRadar($$anchor, $$props) {
|
|||||||
}
|
}
|
||||||
set(currentIdx, Number(e.target.value), true);
|
set(currentIdx, Number(e.target.value), true);
|
||||||
});
|
});
|
||||||
append($$anchor, div_8);
|
append($$anchor, div_13);
|
||||||
};
|
};
|
||||||
var consequent_6 = ($$anchor) => {
|
var consequent_11 = ($$anchor) => {
|
||||||
var div_12 = root_7$1();
|
var div_17 = root_13();
|
||||||
var button_7 = sibling(child(div_12), 2);
|
var button_8 = sibling(child(div_17), 2);
|
||||||
reset(div_12);
|
reset(div_17);
|
||||||
delegated("click", button_7, loadRadar);
|
delegated("click", button_8, loadRadar);
|
||||||
append($$anchor, div_12);
|
append($$anchor, div_17);
|
||||||
};
|
};
|
||||||
if_block(node_5, ($$render) => {
|
if_block(node_9, ($$render) => {
|
||||||
if (get(frame)) $$render(consequent_5);
|
if (get(frame)) $$render(consequent_10);
|
||||||
else if (!get(loading)) $$render(consequent_6, 1);
|
else if (!get(loading)) $$render(consequent_11, 1);
|
||||||
});
|
});
|
||||||
template_effect(() => {
|
template_effect(() => {
|
||||||
classes = set_class(div_2, 1, "radar-viewport svelte-tjvfz7", null, classes, { dragging: get(dragging) });
|
classes_1 = set_class(div_3, 1, "radar-viewport svelte-tjvfz7", null, classes_1, { dragging: get(dragging) });
|
||||||
button_2.disabled = get(zoom) >= 11;
|
button_3.disabled = get(zoom) >= 11;
|
||||||
set_text(text_2, get(zoom));
|
set_text(text_3, get(zoom));
|
||||||
button_3.disabled = get(zoom) <= 2;
|
button_4.disabled = get(zoom) <= 2;
|
||||||
});
|
});
|
||||||
delegated("pointerdown", div_2, onPointerDown);
|
delegated("pointerdown", div_3, onPointerDown);
|
||||||
delegated("pointermove", div_2, onPointerMove);
|
delegated("pointermove", div_3, onPointerMove);
|
||||||
delegated("pointerup", div_2, onPointerUp);
|
delegated("pointerup", div_3, onPointerUp);
|
||||||
event("pointerleave", div_2, onPointerUp);
|
event("pointerleave", div_3, onPointerUp);
|
||||||
delegated("click", button_2, zoomIn);
|
delegated("click", button_3, zoomIn);
|
||||||
delegated("click", button_3, zoomOut);
|
delegated("click", button_4, zoomOut);
|
||||||
append($$anchor, fragment);
|
append($$anchor, fragment);
|
||||||
};
|
};
|
||||||
if_block(node, ($$render) => {
|
if_block(node_1, ($$render) => {
|
||||||
if (get(error) && !get(loc)) $$render(consequent);
|
if (get(error) && !get(loc)) $$render(consequent_1);
|
||||||
else $$render(alternate, -1);
|
else $$render(alternate, -1);
|
||||||
});
|
});
|
||||||
reset(div);
|
reset(div);
|
||||||
@ -9436,6 +9658,38 @@ input::placeholder {
|
|||||||
color: var(--color-text-secondary);
|
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.svelte-tjvfz7,
|
||||||
.radar-status-err.svelte-tjvfz7 {
|
.radar-status-err.svelte-tjvfz7 {
|
||||||
display: flex;
|
display: flex;
|
||||||
@ -9539,6 +9793,41 @@ input::placeholder {
|
|||||||
z-index: 1;
|
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 ── */
|
||||||
.location-marker.svelte-tjvfz7 {
|
.location-marker.svelte-tjvfz7 {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
<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 { fetchLightningStrikes, projectStrikeToLayer } from '../lib/api/lightning.js'
|
||||||
|
|
||||||
// --- Radar data ---
|
// --- Radar data ---
|
||||||
let radarData = $state(null)
|
let radarData = $state(null)
|
||||||
@ -20,6 +22,14 @@
|
|||||||
|
|
||||||
const loc = $derived(app.selectedLocation)
|
const loc = $derived(app.selectedLocation)
|
||||||
|
|
||||||
|
// --- Lightning strike layer ---
|
||||||
|
let showLightning = $state(app.settings.showLightning || false)
|
||||||
|
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) {
|
||||||
@ -46,6 +56,70 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Lightning layer ---
|
||||||
|
async function loadLightning() {
|
||||||
|
if (!loc || !showLightning) 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 toggleLightning() {
|
||||||
|
showLightning = !showLightning
|
||||||
|
// Persist the preference; the $effect below drives fetching/timing.
|
||||||
|
app.settings = { ...app.settings, showLightning }
|
||||||
|
saveSettings({ ...app.settings, alertThresholds: { ...app.settings.alertThresholds } }).catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
function startLightningTimer() {
|
||||||
|
stopLightningTimer()
|
||||||
|
lightningTimer = setInterval(loadLightning, LIGHTNING_REFRESH_MS)
|
||||||
|
}
|
||||||
|
function stopLightningTimer() {
|
||||||
|
if (lightningTimer) { clearInterval(lightningTimer); lightningTimer = null }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-fetch lightning when the selected location or the layer's data source changes.
|
||||||
|
$effect(() => {
|
||||||
|
const active = showLightning && loc
|
||||||
|
if (active) {
|
||||||
|
loadLightning()
|
||||||
|
startLightningTimer()
|
||||||
|
return () => { stopLightningTimer() }
|
||||||
|
} else {
|
||||||
|
stopLightningTimer()
|
||||||
|
strikes = []
|
||||||
|
lightningError = ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const strikeMarkers = $derived(
|
||||||
|
showLightning
|
||||||
|
? strikes.map((s) => ({
|
||||||
|
...s,
|
||||||
|
...projectStrikeToLayer(s.lat, s.lon, { zoom, cx, cy, tileSize: TILE_SIZE }),
|
||||||
|
}))
|
||||||
|
: []
|
||||||
|
)
|
||||||
|
|
||||||
// --- Frame data ---
|
// --- Frame data ---
|
||||||
const pastFrames = $derived(radarData?.radar?.past || [])
|
const pastFrames = $derived(radarData?.radar?.past || [])
|
||||||
const nowcastFrames = $derived(radarData?.radar?.nowcast || [])
|
const nowcastFrames = $derived(radarData?.radar?.nowcast || [])
|
||||||
@ -171,7 +245,21 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="radar-section">
|
<div class="radar-section">
|
||||||
<h3 class="section-title">Precipitation Radar</h3>
|
<div class="radar-title-row">
|
||||||
|
<h3 class="section-title">Precipitation Radar</h3>
|
||||||
|
{#if loc}
|
||||||
|
<button
|
||||||
|
class="layer-toggle"
|
||||||
|
class:on={showLightning}
|
||||||
|
onclick={toggleLightning}
|
||||||
|
role="switch"
|
||||||
|
aria-checked={showLightning}
|
||||||
|
title="Toggle lightning strike layer"
|
||||||
|
>
|
||||||
|
⚡ Lightning
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
{#if error && !loc}
|
{#if error && !loc}
|
||||||
<div class="radar-status radar-status-err">
|
<div class="radar-status radar-status-err">
|
||||||
@ -218,6 +306,19 @@
|
|||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
|
<!-- Lightning strike markers (pan/zoom with the map) -->
|
||||||
|
{#if showLightning}
|
||||||
|
<div class="lightning-layer" aria-label="Lightning strikes">
|
||||||
|
{#each strikeMarkers as s (s.time + '_' + s.lat + '_' + s.lon)}
|
||||||
|
<span
|
||||||
|
class="lightning-strike"
|
||||||
|
title={'Strike ' + new Date(s.time * 1000).toLocaleString()}
|
||||||
|
style="left: {s.x}px; top: {s.y}px"
|
||||||
|
></span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Location marker (scrolls with map via panX/panY) -->
|
<!-- Location marker (scrolls with map via panX/panY) -->
|
||||||
@ -246,6 +347,21 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<!-- Lightning layer status -->
|
||||||
|
{#if showLightning && lightningLoading}
|
||||||
|
<div class="lightning-status">
|
||||||
|
<span class="text-xs">⏳ Loading lightning...</span>
|
||||||
|
</div>
|
||||||
|
{:else if showLightning && lightningError}
|
||||||
|
<div class="lightning-status lightning-status-err">
|
||||||
|
<span class="text-xs">⚡ {lightningError} (relay not live yet)</span>
|
||||||
|
</div>
|
||||||
|
{:else if showLightning && strikes.length === 0 && !lightningLoading}
|
||||||
|
<div class="lightning-status">
|
||||||
|
<span class="text-xs">⚡ No recent strikes nearby</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- Zoom controls -->
|
<!-- Zoom controls -->
|
||||||
<div class="zoom-ctrl">
|
<div class="zoom-ctrl">
|
||||||
<button class="zoom-btn" onclick={zoomIn} disabled={zoom >= 11} aria-label="Zoom in">+</button>
|
<button class="zoom-btn" onclick={zoomIn} disabled={zoom >= 11} aria-label="Zoom in">+</button>
|
||||||
@ -308,6 +424,38 @@
|
|||||||
color: var(--color-text-secondary);
|
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,
|
||||||
.radar-status-err {
|
.radar-status-err {
|
||||||
display: flex;
|
display: flex;
|
||||||
@ -411,6 +559,41 @@
|
|||||||
z-index: 1;
|
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 ── */
|
||||||
.location-marker {
|
.location-marker {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|||||||
104
src/lib/api/lightning.js
Normal file
104
src/lib/api/lightning.js
Normal file
@ -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=<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)
|
||||||
|
}
|
||||||
@ -126,6 +126,7 @@ 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
|
||||||
alertThresholds: {
|
alertThresholds: {
|
||||||
precip: 70,
|
precip: 70,
|
||||||
windGust: 40,
|
windGust: 40,
|
||||||
|
|||||||
@ -35,6 +35,7 @@ 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
|
||||||
alertThresholds: {
|
alertThresholds: {
|
||||||
precip: 70,
|
precip: 70,
|
||||||
windGust: 40,
|
windGust: 40,
|
||||||
|
|||||||
79
tests/lib/api/lightning.test.js
Normal file
79
tests/lib/api/lightning.test.js
Normal file
@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
x
Reference in New Issue
Block a user