diff --git a/app/package-lock.json b/app/package-lock.json index afaa49f..34e1a58 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -8,7 +8,8 @@ "name": "weather-app", "version": "2.0.0", "dependencies": { - "express": "^4.18.2" + "express": "^4.18.2", + "web-push": "^3.6.7" }, "engines": { "node": ">=18" @@ -27,12 +28,39 @@ "node": ">= 0.6" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/array-flatten": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", "license": "MIT" }, + "node_modules/asn1.js": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz", + "integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==", + "license": "MIT", + "dependencies": { + "bn.js": "^4.0.0", + "inherits": "^2.0.1", + "minimalistic-assert": "^1.0.0", + "safer-buffer": "^2.1.0" + } + }, + "node_modules/bn.js": { + "version": "4.12.3", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz", + "integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==", + "license": "MIT" + }, "node_modules/body-parser": { "version": "1.20.4", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", @@ -57,6 +85,12 @@ "npm": "1.2.8000 || >= 1.4.16" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -173,6 +207,15 @@ "node": ">= 0.4" } }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -397,6 +440,15 @@ "node": ">= 0.4" } }, + "node_modules/http_ece": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz", + "integrity": "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==", + "license": "MIT", + "engines": { + "node": ">=16" + } + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -417,6 +469,42 @@ "url": "https://opencollective.com/express" } }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/https-proxy-agent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, "node_modules/iconv-lite": { "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", @@ -444,6 +532,27 @@ "node": ">= 0.10" } }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -513,6 +622,21 @@ "node": ">= 0.6" } }, + "node_modules/minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", + "license": "ISC" + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/ms": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", @@ -825,6 +949,25 @@ "engines": { "node": ">= 0.8" } + }, + "node_modules/web-push": { + "version": "3.6.7", + "resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz", + "integrity": "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==", + "license": "MPL-2.0", + "dependencies": { + "asn1.js": "^5.3.0", + "http_ece": "1.2.0", + "https-proxy-agent": "^7.0.0", + "jws": "^4.0.0", + "minimist": "^1.2.5" + }, + "bin": { + "web-push": "src/cli.js" + }, + "engines": { + "node": ">= 16" + } } } } diff --git a/app/package.json b/app/package.json index 2c615f6..52590b7 100644 --- a/app/package.json +++ b/app/package.json @@ -8,7 +8,8 @@ "dev": "node --watch server.js" }, "dependencies": { - "express": "^4.18.2" + "express": "^4.18.2", + "web-push": "^3.6.7" }, "engines": { "node": ">=18" diff --git a/app/public/app.js b/app/public/app.js index a5059f1..bf8df08 100644 --- a/app/public/app.js +++ b/app/public/app.js @@ -31,26 +31,65 @@ const STORAGE_KEYS = { autoDetect: 'weather.autoDetect', showLightning: 'weather.showLightning', showWind: 'weather.showWind', + defaultCity: 'weather.defaultCity', + highContrast: 'weather.highContrast', + radarOpacity: 'weather.radarOpacity', + lightningAgeMin: 'weather.lightningAgeMin', }; +function parseSavedCities(raw) { + try { + const parsed = JSON.parse(raw || '[]'); + if (!Array.isArray(parsed)) return []; + return parsed.map((entry) => { + if (entry && entry.city && typeof entry.city === 'object') { + return { + city: entry.city, + addedAt: entry.addedAt || Date.now(), + lastViewedAt: entry.lastViewedAt || null, + isDefault: Boolean(entry.isDefault), + }; + } + return { + city: entry, + addedAt: Date.now(), + lastViewedAt: null, + isDefault: false, + }; + }).filter(e => e.city && e.city.latitude != null && e.city.longitude != null); + } catch { + return []; + } +} + const state = { units: localStorage.getItem(STORAGE_KEYS.units) || 'us', autoDetect: localStorage.getItem(STORAGE_KEYS.autoDetect) !== 'false', showLightning: localStorage.getItem(STORAGE_KEYS.showLightning) !== 'false', showWind: localStorage.getItem(STORAGE_KEYS.showWind) !== 'false', + highContrast: localStorage.getItem(STORAGE_KEYS.highContrast) === 'true', + radarOpacity: Number(localStorage.getItem(STORAGE_KEYS.radarOpacity) || 65), + lightningAgeMin: Number(localStorage.getItem(STORAGE_KEYS.lightningAgeMin) || 15), currentCity: null, currentWeather: null, currentAir: null, currentSource: 'startup', - savedCities: JSON.parse(localStorage.getItem(STORAGE_KEYS.savedCities) || '[]'), + savedCities: parseSavedCities(localStorage.getItem(STORAGE_KEYS.savedCities)), alertIdsSeen: new Set(), autoRefreshTimer: null, lastRefreshAt: 0, - lightningMarkers: [], + lightningLayer: null, windArrows: [], dailyExpanded: false, + pushSupported: false, + pushEnabled: false, }; +const persistedDefaultKey = localStorage.getItem(STORAGE_KEYS.defaultCity); +if (persistedDefaultKey && state.savedCities.length && !state.savedCities.some(e => e.isDefault)) { + state.savedCities = state.savedCities.map(e => ({ ...e, isDefault: cityKey(e.city) === persistedDefaultKey })); +} + // ── DOM ────────────────────────────────────────────────────────────────────── const searchInput = document.getElementById('search-input'); const searchBtn = document.getElementById('search-btn'); @@ -76,6 +115,13 @@ const lightningStatus = document.getElementById('lightning-status'); const windStatus = document.getElementById('wind-status'); const dailyForecastLabel = document.getElementById('daily-forecast-label'); const dailyForecastToggle = document.getElementById('daily-forecast-toggle'); +const pushTestBtn = document.getElementById('push-test-btn'); +const hourlySummaryEl = document.getElementById('hourly-summary'); +const defaultCityEl = document.getElementById('default-city'); +const contrastToggle = document.getElementById('contrast-toggle'); +const contrastStatus = document.getElementById('contrast-status'); +const radarOpacityEl = document.getElementById('radar-opacity'); +const lightningAgeEl = document.getElementById('lightning-age'); // ── Helpers ────────────────────────────────────────────────────────────────── const show = el => el.classList.remove('hidden'); @@ -108,20 +154,17 @@ function formatClock(iso) { function tempDisplay(v) { if (v == null || Number.isNaN(v)) return '--'; - const n = state.units === 'us' ? v : ((v - 32) * 5) / 9; - return Math.round(n); + return Math.round(v); } function speedDisplay(v) { if (v == null || Number.isNaN(v)) return '--'; - const n = state.units === 'us' ? v : v * 1.60934; - return Math.round(n); + return Math.round(v); } function precipDisplay(v) { if (v == null || Number.isNaN(v)) return '--'; - const n = state.units === 'us' ? v : v * 25.4; - return n.toFixed(1); + return Number(v).toFixed(1); } function visDisplay(vMeters) { @@ -149,22 +192,91 @@ function updateNotifyButton() { if (!('Notification' in window)) { notifyBtn.textContent = 'Notifications unsupported'; notifyBtn.disabled = true; + if (pushTestBtn) pushTestBtn.disabled = true; return; } if (!window.isSecureContext) { notifyBtn.textContent = 'Needs HTTPS'; notifyBtn.disabled = true; + if (pushTestBtn) pushTestBtn.disabled = true; return; } notifyBtn.disabled = false; if (Notification.permission === 'granted') { - notifyBtn.textContent = 'Notifications enabled'; + notifyBtn.textContent = state.pushEnabled ? 'Push enabled' : 'Enable push'; } else if (Notification.permission === 'denied') { notifyBtn.textContent = 'Notifications blocked'; } else { notifyBtn.textContent = 'Enable notifications'; } + + if (pushTestBtn) { + pushTestBtn.disabled = !(Notification.permission === 'granted' && state.pushEnabled); + } +} + +function applyHighContrast() { + document.body.classList.toggle('high-contrast', state.highContrast); + if (contrastStatus) contrastStatus.textContent = state.highContrast ? 'On' : 'Off'; +} + +function urlBase64ToUint8Array(base64String) { + const padding = '='.repeat((4 - base64String.length % 4) % 4); + const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/'); + const rawData = atob(base64); + const outputArray = new Uint8Array(rawData.length); + for (let i = 0; i < rawData.length; ++i) outputArray[i] = rawData.charCodeAt(i); + return outputArray; +} + +async function ensurePushSubscription() { + if (!('serviceWorker' in navigator) || !('PushManager' in window)) return false; + + const keyResp = await fetch('/api/push/public-key').then(r => r.json()).catch(() => null); + const publicKey = keyResp?.publicKey; + if (!publicKey) return false; + + const reg = await navigator.serviceWorker.ready; + let sub = await reg.pushManager.getSubscription(); + if (!sub) { + sub = await reg.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: urlBase64ToUint8Array(publicKey), + }); + } + + await fetch('/api/push/subscribe', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + subscription: sub, + location: state.currentCity ? { + latitude: state.currentCity.latitude, + longitude: state.currentCity.longitude, + } : null, + }), + }); + + state.pushEnabled = true; + updateNotifyButton(); + return true; +} + +async function sendTestPush() { + const resp = await fetch('/api/push/test', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + title: 'Weather push test', + body: state.currentCity ? `Alerts enabled for ${cityLabel(state.currentCity) || 'your location'}` : 'Push notifications are active.', + url: window.location.pathname, + }), + }); + if (!resp.ok) { + const err = await resp.json().catch(() => ({})); + throw new Error(err.error || 'Push test failed'); + } } function moonPhase(dateLike) { @@ -196,6 +308,8 @@ function cityKey(city) { function persistSavedCities() { localStorage.setItem(STORAGE_KEYS.savedCities, JSON.stringify(state.savedCities)); + const defaultEntry = state.savedCities.find(e => e.isDefault); + localStorage.setItem(STORAGE_KEYS.defaultCity, defaultEntry ? cityKey(defaultEntry.city) : ''); } function persistLastCity(city) { @@ -211,6 +325,46 @@ function cityLabel(city) { return [city?.name, city?.admin1, city?.country].filter(Boolean).join(', '); } +function formatRelativeTime(ts) { + if (!ts) return 'Never opened'; + const mins = Math.max(1, Math.round((Date.now() - ts) / 60000)); + if (mins < 60) return `${mins}m ago`; + const hrs = Math.round(mins / 60); + if (hrs < 48) return `${hrs}h ago`; + const days = Math.round(hrs / 24); + return `${days}d ago`; +} + +function getDefaultSavedEntry() { + return state.savedCities.find(e => e.isDefault) || null; +} + +function updateDefaultCityLabel() { + if (!defaultCityEl) return; + const def = getDefaultSavedEntry(); + defaultCityEl.textContent = def?.city?.name || 'None'; +} + +function setDefaultCityByKey(key) { + state.savedCities = state.savedCities.map(e => ({ + ...e, + isDefault: cityKey(e.city) === key, + })); + persistSavedCities(); + updateDefaultCityLabel(); + renderSavedCities(); +} + +function moveSavedCity(index, direction) { + const next = index + direction; + if (next < 0 || next >= state.savedCities.length) return; + const arr = [...state.savedCities]; + [arr[index], arr[next]] = [arr[next], arr[index]]; + state.savedCities = arr; + persistSavedCities(); + renderSavedCities(); +} + function updateShareUrl(city) { if (!city) return; const params = new URLSearchParams(window.location.search); @@ -230,10 +384,12 @@ function renderSavedCities() { savedLocationsEl.innerHTML = ''; if (!state.savedCities.length) { savedLocationsEl.textContent = 'None yet'; + updateDefaultCityLabel(); return; } - state.savedCities.forEach(city => { + state.savedCities.forEach((entry, index) => { + const city = entry.city; const wrap = document.createElement('div'); wrap.className = 'saved-item'; @@ -241,7 +397,35 @@ function renderSavedCities() { chip.className = 'saved-chip'; chip.type = 'button'; chip.textContent = city.name; - chip.addEventListener('click', () => loadAll(city, 'saved-location')); + chip.title = `${cityLabel(city)} • ${formatRelativeTime(entry.lastViewedAt)}`; + chip.addEventListener('click', () => { + entry.lastViewedAt = Date.now(); + persistSavedCities(); + loadAll(city, 'saved-location'); + }); + + const defaultBtn = document.createElement('button'); + defaultBtn.className = 'saved-default'; + defaultBtn.type = 'button'; + defaultBtn.title = entry.isDefault ? 'Default city' : 'Set as default city'; + defaultBtn.textContent = entry.isDefault ? '★' : '☆'; + defaultBtn.addEventListener('click', () => setDefaultCityByKey(cityKey(city))); + + const upBtn = document.createElement('button'); + upBtn.className = 'saved-move'; + upBtn.type = 'button'; + upBtn.title = 'Move up'; + upBtn.textContent = '↑'; + upBtn.disabled = index === 0; + upBtn.addEventListener('click', () => moveSavedCity(index, -1)); + + const downBtn = document.createElement('button'); + downBtn.className = 'saved-move'; + downBtn.type = 'button'; + downBtn.title = 'Move down'; + downBtn.textContent = '↓'; + downBtn.disabled = index === state.savedCities.length - 1; + downBtn.addEventListener('click', () => moveSavedCity(index, 1)); const remove = document.createElement('button'); remove.className = 'saved-remove'; @@ -249,15 +433,25 @@ function renderSavedCities() { remove.textContent = 'x'; remove.addEventListener('click', (e) => { e.stopPropagation(); - state.savedCities = state.savedCities.filter(c => cityKey(c) !== cityKey(city)); + state.savedCities = state.savedCities.filter(c => cityKey(c.city) !== cityKey(city)); persistSavedCities(); renderSavedCities(); }); + const meta = document.createElement('span'); + meta.className = 'saved-meta'; + meta.textContent = formatRelativeTime(entry.lastViewedAt); + + wrap.appendChild(defaultBtn); wrap.appendChild(chip); + wrap.appendChild(upBtn); + wrap.appendChild(downBtn); wrap.appendChild(remove); + wrap.appendChild(meta); savedLocationsEl.appendChild(wrap); }); + + updateDefaultCityLabel(); } // ── Tab switching ───────────────────────────────────────────────────────────── @@ -294,7 +488,7 @@ async function reverseGeocode(lat, lon) { } async function fetchWeather(lat, lon, tz) { - const r = await fetch(`/api/weather?lat=${lat}&lon=${lon}&tz=${encodeURIComponent(tz||'auto')}&units=us`); + const r = await fetch(`/api/weather?lat=${lat}&lon=${lon}&tz=${encodeURIComponent(tz||'auto')}&units=${encodeURIComponent(state.units)}`); if (!r.ok) throw new Error('Weather fetch failed'); return r.json(); } @@ -311,6 +505,13 @@ async function fetchAlerts(lat, lon) { return r.json(); } +async function fetchLightning(lat, lon) { + const ageMin = Number(state.lightningAgeMin || 15); + const r = await fetch(`/api/lightning?lat=${lat}&lon=${lon}&age=${ageMin}`); + if (!r.ok) throw new Error('Lightning fetch failed'); + return r.json(); +} + function detectedCityFallback(lat, lon) { return { name: `Auto-detected (${lat.toFixed(2)}, ${lon.toFixed(2)})`, @@ -528,6 +729,11 @@ function createOverviewMap(mapEl) { maxZoom: 18, }).addTo(map); + const radarLayer = L.tileLayer('https://tilecache.rainviewer.com/v2/radar/nowcast_0/256/{z}/{x}/{y}/2/1_1.png', { + maxZoom: 15, + opacity: Math.max(0.15, Math.min(0.9, state.radarOpacity / 100)), + }).addTo(map); + let marker = null; function panTo(lat, lon, zoom) { @@ -542,13 +748,20 @@ function createOverviewMap(mapEl) { }).addTo(map); } - return { map, panTo }; + function setRadarOpacity(opacityPct) { + const normalized = Math.max(0.15, Math.min(0.9, Number(opacityPct) / 100)); + radarLayer.setOpacity(normalized); + } + + return { map, panTo, setRadarOpacity }; } -function clearLightningMarkers() { +function clearLightningLayer() { if (!mapsOverview.map) return; - state.lightningMarkers.forEach(marker => mapsOverview.map.removeLayer(marker)); - state.lightningMarkers = []; + if (state.lightningLayer) { + mapsOverview.map.removeLayer(state.lightningLayer); + state.lightningLayer = null; + } } function clearWindArrows() { @@ -566,41 +779,81 @@ function refreshMapsOverview() { mapsOverview.panTo(state.currentCity.latitude, state.currentCity.longitude, 6); - clearLightningMarkers(); + clearLightningLayer(); clearWindArrows(); if (state.showLightning) renderLightningStrikes(state.currentCity); if (state.showWind && state.currentWeather) renderWindArrows(state.currentCity); } -function renderLightningStrikes(city) { +function circleCenter(coords) { + let sumLat = 0; + let sumLon = 0; + let count = 0; + coords.forEach(([lon, lat]) => { + sumLat += lat; + sumLon += lon; + count += 1; + }); + if (!count) return null; + return [sumLat / count, sumLon / count]; +} + +async function renderLightningStrikes(city) { if (!mapsOverview.map || !state.showLightning) return; - clearLightningMarkers(); + clearLightningLayer(); - // Simulate lightning strikes in the vicinity of the city (for now, use random placement) - // In a production app, you'd fetch from a lightning API like Blitzortung or similar - // For demo: always show a few sample strikes so the overlay is visible when enabled. - const strokeCount = 3; + try { + const data = await fetchLightning(city.latitude, city.longitude); + const features = data?.features || []; + if (!features.length) return; - for (let i = 0; i < strokeCount; i++) { - const angle = (Math.PI * 2 * i) / strokeCount; - const radius = 0.18 + i * 0.06; - const latOffset = Math.cos(angle) * radius; - const lonOffset = Math.sin(angle) * radius; - const lat = parseFloat(city.latitude) + latOffset; - const lon = parseFloat(city.longitude) + lonOffset; + const layers = []; + features.forEach((feature) => { + const geom = feature?.geometry; + const event = feature?.properties?.event || 'Thunderstorm activity'; + const severity = feature?.properties?.severity || 'Unknown severity'; + if (!geom) return; - const marker = L.circleMarker([lat, lon], { - radius: 4, - fillColor: '#ffeb3b', - fillOpacity: 0.8, - color: '#ff6f00', - weight: 1.5, - className: 'lightning-marker', - }).bindPopup('Sample lightning overlay'); + const polygonSets = []; + if (geom.type === 'Polygon' && Array.isArray(geom.coordinates?.[0])) { + polygonSets.push(geom.coordinates[0]); + } + if (geom.type === 'MultiPolygon' && Array.isArray(geom.coordinates)) { + geom.coordinates.forEach(poly => { + if (Array.isArray(poly?.[0])) polygonSets.push(poly[0]); + }); + } - marker.addTo(mapsOverview.map); - state.lightningMarkers.push(marker); + polygonSets.forEach((polyCoords, idx) => { + const ring = polyCoords.map(([lon, lat]) => [lat, lon]); + const poly = L.polygon(ring, { + color: '#ff8b1a', + weight: 1.5, + opacity: 0.8, + fillColor: '#ffd34d', + fillOpacity: 0.18, + }).bindPopup(`${event} • ${severity}`); + layers.push(poly); + + const center = circleCenter(polyCoords); + if (center) { + const strike = L.circleMarker(center, { + radius: 5, + fillColor: '#ffe066', + fillOpacity: 0.95, + color: '#ff6f00', + weight: 2, + }).bindPopup(`${event} area ${idx + 1}`); + layers.push(strike); + } + }); + }); + + if (!layers.length) return; + state.lightningLayer = L.layerGroup(layers).addTo(mapsOverview.map); + } catch (e) { + console.warn('Lightning overlay unavailable', e); } } @@ -691,6 +944,27 @@ function renderDailyForecast(daily) { } } +function summarizeHourly(hourly, startIdx) { + if (!hourly?.time?.length || !hourly?.precipitation_probability?.length) { + return 'Hourly details unavailable.'; + } + + let rainIdx = -1; + for (let i = startIdx; i < Math.min(startIdx + 24, hourly.time.length); i++) { + const pp = Number(hourly.precipitation_probability[i] || 0); + const rain = Number(hourly.rain?.[i] || 0); + if (pp >= 45 || rain > 0.1) { + rainIdx = i; + break; + } + } + + if (rainIdx === -1) { + return 'No notable rain expected in the next 24 hours.'; + } + return `Rain likely around ${formatHour(hourly.time[rainIdx])}.`; +} + function renderWeather(data, city) { const c = data.current; const h = data.hourly; @@ -742,11 +1016,16 @@ function renderWeather(data, city) {
--
Use star for default, arrows to reorder.
@@ -216,6 +239,11 @@ -- +