1615 lines
121 KiB
Diff
1615 lines
121 KiB
Diff
diff --git a/.tmp/imgdiff/old/app.js b/.tmp/imgdiff/new/app.js
|
|
index ab383c1..1617dfb 100644
|
|
--- a/.tmp/imgdiff/old/app.js
|
|
+++ b/.tmp/imgdiff/new/app.js
|
|
@@ -31,65 +31,26 @@ 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: parseSavedCities(localStorage.getItem(STORAGE_KEYS.savedCities)),
|
|
+ savedCities: JSON.parse(localStorage.getItem(STORAGE_KEYS.savedCities) || '[]'),
|
|
alertIdsSeen: new Set(),
|
|
autoRefreshTimer: null,
|
|
lastRefreshAt: 0,
|
|
- lightningLayer: null,
|
|
+ lightningMarkers: [],
|
|
windArrows: [],
|
|
- dailyExpanded: false,
|
|
- pushSupported: false,
|
|
- pushEnabled: false,
|
|
+ showExtendedForecast: 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');
|
|
@@ -111,18 +72,8 @@ const mapsLocationEl = document.getElementById('maps-location');
|
|
const mapsWindEl = document.getElementById('maps-wind');
|
|
const lightningToggle = document.getElementById('lightning-toggle');
|
|
const windToggle = document.getElementById('wind-toggle');
|
|
-const lightningStatus = document.getElementById('lightning-status');
|
|
-const windStatus = document.getElementById('wind-status');
|
|
+const dailyToggleBtn = document.getElementById('daily-toggle-btn');
|
|
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');
|
|
-const mapsFullscreenBtn = document.getElementById('maps-fullscreen-btn');
|
|
|
|
// ΓöÇΓöÇ Helpers ΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇ
|
|
const show = el => el.classList.remove('hidden');
|
|
@@ -130,10 +81,29 @@ const hide = el => el.classList.add('hidden');
|
|
const showErr = msg => { errorMsg.textContent = msg; show(errorMsg); };
|
|
const clearErr = () => { errorMsg.textContent = ''; hide(errorMsg); };
|
|
|
|
+async function cleanupServiceWorkerInterceptors() {
|
|
+ try {
|
|
+ if ('serviceWorker' in navigator) {
|
|
+ const regs = await navigator.serviceWorker.getRegistrations();
|
|
+ await Promise.all(regs.map(reg => reg.unregister()));
|
|
+ }
|
|
+ if ('caches' in window) {
|
|
+ const keys = await caches.keys();
|
|
+ await Promise.all(keys.map(key => caches.delete(key)));
|
|
+ }
|
|
+ } catch (e) {
|
|
+ console.warn('Service worker/cache cleanup failed:', e?.message || e);
|
|
+ }
|
|
+}
|
|
+
|
|
function compassDir(deg) {
|
|
return ['N','NE','E','SE','S','SW','W','NW'][Math.round(deg / 45) % 8];
|
|
}
|
|
|
|
+function compassDirFull(deg) {
|
|
+ return ['North','Northeast','East','Southeast','South','Southwest','West','Northwest'][Math.round(deg / 45) % 8];
|
|
+}
|
|
+
|
|
function formatHour(iso) {
|
|
const h = new Date(iso).getHours();
|
|
return h === 0 ? '12 AM' : h < 12 ? `${h} AM` : h === 12 ? '12 PM' : `${h-12} PM`;
|
|
@@ -155,17 +125,20 @@ function formatClock(iso) {
|
|
|
|
function tempDisplay(v) {
|
|
if (v == null || Number.isNaN(v)) return '--';
|
|
- return Math.round(v);
|
|
+ const n = state.units === 'us' ? v : ((v - 32) * 5) / 9;
|
|
+ return Math.round(n);
|
|
}
|
|
|
|
function speedDisplay(v) {
|
|
if (v == null || Number.isNaN(v)) return '--';
|
|
- return Math.round(v);
|
|
+ const n = state.units === 'us' ? v : v * 1.60934;
|
|
+ return Math.round(n);
|
|
}
|
|
|
|
function precipDisplay(v) {
|
|
if (v == null || Number.isNaN(v)) return '--';
|
|
- return Number(v).toFixed(1);
|
|
+ const n = state.units === 'us' ? v : v * 25.4;
|
|
+ return n.toFixed(1);
|
|
}
|
|
|
|
function visDisplay(vMeters) {
|
|
@@ -179,28 +152,6 @@ function tempUnit() { return state.units === 'us' ? 'F' : 'C'; }
|
|
function speedUnit() { return state.units === 'us' ? 'mph' : 'km/h'; }
|
|
function visUnit() { return state.units === 'us' ? 'mi' : 'km'; }
|
|
|
|
-function weatherThemeFromCode(code) {
|
|
- const n = Number(code);
|
|
- if (Number.isNaN(n)) return 'cloud';
|
|
- if (n === 0 || n === 1) return 'clear';
|
|
- if (n === 2 || n === 3) return 'cloud';
|
|
- if (n === 45 || n === 48) return 'fog';
|
|
- if ((n >= 51 && n <= 67) || (n >= 80 && n <= 82)) return 'rain';
|
|
- if (n >= 71 && n <= 77) return 'snow';
|
|
- if (n >= 85 && n <= 86) return 'snow';
|
|
- if (n >= 95) return 'storm';
|
|
- return 'cloud';
|
|
-}
|
|
-
|
|
-function applyWeatherBackground(code, isDay) {
|
|
- const theme = weatherThemeFromCode(code);
|
|
- const body = document.body;
|
|
- const themes = ['clear', 'cloud', 'rain', 'snow', 'storm', 'fog'];
|
|
- themes.forEach(t => body.classList.remove(`weather-${t}`));
|
|
- body.classList.add(`weather-${theme}`);
|
|
- body.classList.toggle('weather-night', Number(isDay) === 0);
|
|
-}
|
|
-
|
|
function updateUnitsButton() {
|
|
const label = state.units === 'us' ? 'US' : 'Metric';
|
|
unitsBtn.textContent = label;
|
|
@@ -215,91 +166,22 @@ 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 = state.pushEnabled ? 'Push enabled' : 'Enable push';
|
|
+ notifyBtn.textContent = 'Notifications enabled';
|
|
} 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) {
|
|
@@ -319,6 +201,58 @@ function precipType(code, tempF, rain, snowfall) {
|
|
return 'Dry';
|
|
}
|
|
|
|
+function calcDewpoint(tempF, rh) {
|
|
+ const tc = (tempF - 32) * 5 / 9;
|
|
+ const alpha = Math.log(rh / 100) + (17.62 * tc) / (243.12 + tc);
|
|
+ const dewC = (243.12 * alpha) / (17.62 - alpha);
|
|
+ return state.units === 'us' ? Math.round(dewC * 9 / 5 + 32) : Math.round(dewC);
|
|
+}
|
|
+
|
|
+function humidityFeel(rh) {
|
|
+ if (rh < 25) return 'Very dry';
|
|
+ if (rh < 35) return 'Dry';
|
|
+ if (rh < 55) return 'Comfortable';
|
|
+ if (rh < 65) return 'Moderate';
|
|
+ if (rh < 75) return 'Humid';
|
|
+ return 'Very humid';
|
|
+}
|
|
+
|
|
+function renderRecommendations(data) {
|
|
+ const c = data.current;
|
|
+ const d = data.daily;
|
|
+ const temp = c.temperature_2m;
|
|
+ const code = c.weather_code;
|
|
+ const uv = d.uv_index_max[0] ?? 0;
|
|
+ const wind = c.wind_speed_10m;
|
|
+ const items = [];
|
|
+
|
|
+ if (temp <= 32) items.push({ icon: '🧥', label: 'Heavy coat' });
|
|
+ else if (temp <= 50) items.push({ icon: '🧣', label: 'Jacket' });
|
|
+ else if (temp >= 85) items.push({ icon: '👕', label: 'Light clothes' });
|
|
+
|
|
+ if ([51,53,55,61,63,65,66,67,80,81,82].includes(code)) {
|
|
+ items.push({ icon: '☂️', label: 'Umbrella' });
|
|
+ items.push({ icon: '🥾', label: 'Waterproof\nshoes' });
|
|
+ }
|
|
+ if ([71,73,75,77,85,86].includes(code)) {
|
|
+ items.push({ icon: '🥾', label: 'Snow boots' });
|
|
+ items.push({ icon: '🧤', label: 'Gloves' });
|
|
+ }
|
|
+ if (code >= 95) items.push({ icon: 'ΓÜí', label: 'Stay\nindoors' });
|
|
+ if (c.is_day && uv >= 6) {
|
|
+ items.push({ icon: '🕶️', label: 'Sunglasses' });
|
|
+ items.push({ icon: '🧴', label: 'Sunscreen' });
|
|
+ }
|
|
+ if (wind >= 20) items.push({ icon: '💨', label: 'Windbreaker' });
|
|
+ items.push({ icon: '💧', label: 'Water bottle' });
|
|
+
|
|
+ const grid = document.getElementById('recommendations');
|
|
+ if (!grid) return;
|
|
+ grid.innerHTML = items.map(it =>
|
|
+ `<div class="rec-item"><span class="rec-icon">${it.icon}</span><span class="rec-label">${it.label}</span></div>`
|
|
+ ).join('');
|
|
+}
|
|
+
|
|
function apparentLabel(tempF, apparentF) {
|
|
if (tempF <= 50 && apparentF <= tempF - 2) return 'Wind chill';
|
|
if (tempF >= 80 && apparentF >= tempF + 2) return 'Heat index';
|
|
@@ -331,8 +265,6 @@ 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) {
|
|
@@ -348,46 +280,6 @@ 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);
|
|
@@ -407,12 +299,10 @@ function renderSavedCities() {
|
|
savedLocationsEl.innerHTML = '';
|
|
if (!state.savedCities.length) {
|
|
savedLocationsEl.textContent = 'None yet';
|
|
- updateDefaultCityLabel();
|
|
return;
|
|
}
|
|
|
|
- state.savedCities.forEach((entry, index) => {
|
|
- const city = entry.city;
|
|
+ state.savedCities.forEach(city => {
|
|
const wrap = document.createElement('div');
|
|
wrap.className = 'saved-item';
|
|
|
|
@@ -420,35 +310,7 @@ function renderSavedCities() {
|
|
chip.className = 'saved-chip';
|
|
chip.type = 'button';
|
|
chip.textContent = city.name;
|
|
- 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));
|
|
+ chip.addEventListener('click', () => loadAll(city, 'saved-location'));
|
|
|
|
const remove = document.createElement('button');
|
|
remove.className = 'saved-remove';
|
|
@@ -456,53 +318,50 @@ function renderSavedCities() {
|
|
remove.textContent = 'x';
|
|
remove.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
- state.savedCities = state.savedCities.filter(c => cityKey(c.city) !== cityKey(city));
|
|
+ state.savedCities = state.savedCities.filter(c => cityKey(c) !== 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 ΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇ
|
|
-document.querySelectorAll('.tab').forEach(btn => {
|
|
- btn.addEventListener('click', () => {
|
|
- document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
|
- document.querySelectorAll('.tab-panel').forEach(p => hide(p));
|
|
- btn.classList.add('active');
|
|
- show(document.getElementById('tab-' + btn.dataset.tab));
|
|
- if (btn.dataset.tab === 'radar-full') {
|
|
- setTimeout(() => { radarFull.map && radarFull.map.invalidateSize(); }, 50);
|
|
- }
|
|
- if (btn.dataset.tab === 'maps') {
|
|
- setTimeout(refreshMapsOverview, 80);
|
|
- } else {
|
|
- setMapsFullscreen(false);
|
|
- }
|
|
- });
|
|
+function activateTab(tabName) {
|
|
+ document.querySelectorAll('.tab, .bottom-tab').forEach(t => t.classList.remove('active'));
|
|
+ document.querySelectorAll('.tab-panel').forEach(p => hide(p));
|
|
+ document.querySelectorAll(`[data-tab="${tabName}"]`).forEach(t => t.classList.add('active'));
|
|
+ show(document.getElementById('tab-' + tabName));
|
|
+
|
|
+ if (tabName === 'conditions') {
|
|
+ setTimeout(() => { radarEmbed.refresh && radarEmbed.refresh(); }, 60);
|
|
+ }
|
|
+ if (tabName === 'radar-full') {
|
|
+ setTimeout(() => { radarFull.refresh && radarFull.refresh(); }, 50);
|
|
+ }
|
|
+ if (tabName === 'maps') {
|
|
+ setTimeout(refreshMapsOverview, 80);
|
|
+ }
|
|
+}
|
|
+
|
|
+document.querySelectorAll('.tab, .bottom-tab').forEach(btn => {
|
|
+ btn.addEventListener('click', () => activateTab(btn.dataset.tab));
|
|
});
|
|
|
|
document.getElementById('open-radar-tab').addEventListener('click', () => {
|
|
- document.querySelector('.tab[data-tab="radar-full"]').click();
|
|
+ activateTab('radar-full');
|
|
});
|
|
|
|
// ΓöÇΓöÇ APIs ΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇ
|
|
async function geocode(q) {
|
|
const r = await fetch(`/api/geocode?q=${encodeURIComponent(q)}`);
|
|
- if (!r.ok) throw new Error('Geocoding failed');
|
|
+ if (!r.ok) {
|
|
+ const detail = await r.text().catch(() => '');
|
|
+ throw new Error(`Geocoding failed (${r.status})${detail ? `: ${detail.slice(0, 180)}` : ''}`);
|
|
+ }
|
|
return r.json();
|
|
}
|
|
|
|
@@ -512,15 +371,40 @@ async function reverseGeocode(lat, lon) {
|
|
return r.json();
|
|
}
|
|
|
|
+async function fetchJsonWithTimeout(url, timeoutMs) {
|
|
+ const controller = new AbortController();
|
|
+ const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
|
+ const response = await fetch(url, { cache: 'no-store', signal: controller.signal });
|
|
+ clearTimeout(timeoutId);
|
|
+
|
|
+ if (!response.ok) {
|
|
+ const detail = await response.text().catch(() => '');
|
|
+ throw new Error(`Request failed (${response.status})${detail ? `: ${detail.slice(0, 180)}` : ''}`);
|
|
+ }
|
|
+ return response.json();
|
|
+}
|
|
+
|
|
async function fetchWeather(lat, lon, tz) {
|
|
- 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();
|
|
+ const proxyUrl = `/api/weather?lat=${lat}&lon=${lon}&tz=${encodeURIComponent(tz||'auto')}&units=${encodeURIComponent(state.units)}`;
|
|
+
|
|
+ for (let attempt = 1; attempt <= 3; attempt++) {
|
|
+ try {
|
|
+ return await fetchJsonWithTimeout(proxyUrl, 22000);
|
|
+ } catch (err) {
|
|
+ const reason = err?.name === 'AbortError' ? 'timeout' : (err?.message || err);
|
|
+ if (attempt >= 3) {
|
|
+ throw new Error(`Weather proxy failed after ${attempt} attempts: ${reason}`);
|
|
+ }
|
|
+ await new Promise(resolve => setTimeout(resolve, 500 * attempt));
|
|
+ }
|
|
+ }
|
|
}
|
|
|
|
async function fetchAirQuality(lat, lon, tz) {
|
|
- const r = await fetch(`/api/air-quality?lat=${lat}&lon=${lon}&tz=${encodeURIComponent(tz||'auto')}`);
|
|
- if (!r.ok) throw new Error('Air quality fetch failed');
|
|
+ const url = `/api/air-quality?lat=${lat}&lon=${lon}&tz=${encodeURIComponent(tz||'auto')}`;
|
|
+ console.log('Fetching air quality from:', url);
|
|
+ const r = await fetch(url);
|
|
+ if (!r.ok) throw new Error(`Air quality fetch failed with status ${r.status}`);
|
|
return r.json();
|
|
}
|
|
|
|
@@ -530,16 +414,9 @@ 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)})`,
|
|
+ name: `Detected location (${lat.toFixed(2)}, ${lon.toFixed(2)})`,
|
|
latitude: lat,
|
|
longitude: lon,
|
|
country: '',
|
|
@@ -548,17 +425,6 @@ function detectedCityFallback(lat, lon) {
|
|
};
|
|
}
|
|
|
|
-function resolveDetectedName(place, fallback) {
|
|
- const raw = (place?.name || '').trim();
|
|
- const genericName = !raw || /current\s*location|detected\s*location/i.test(raw);
|
|
- if (!genericName) return raw;
|
|
-
|
|
- const displayRoot = (place?.display_name || '').split(',')[0]?.trim();
|
|
- if (displayRoot) return displayRoot;
|
|
-
|
|
- return fallback.name;
|
|
-}
|
|
-
|
|
async function buildDetectedCity(lat, lon) {
|
|
const fallback = detectedCityFallback(lat, lon);
|
|
|
|
@@ -567,7 +433,6 @@ async function buildDetectedCity(lat, lon) {
|
|
return {
|
|
...fallback,
|
|
...place,
|
|
- name: resolveDetectedName(place, fallback),
|
|
latitude: lat,
|
|
longitude: lon,
|
|
timezone: place.timezone || 'auto',
|
|
@@ -579,6 +444,9 @@ async function buildDetectedCity(lat, lon) {
|
|
|
|
// ΓöÇΓöÇ Radar engine ΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇ
|
|
function createRadar(mapEl, timelineEl, playBtn, tsEl, layerBtn) {
|
|
+ const FRAME_INTERVAL_MS = 650;
|
|
+ const FRAME_FADE_MS = 240;
|
|
+
|
|
const map = L.map(mapEl, {
|
|
center: [39.5, -98.35],
|
|
zoom: 4,
|
|
@@ -598,6 +466,9 @@ function createRadar(mapEl, timelineEl, playBtn, tsEl, layerBtn) {
|
|
let playing = false;
|
|
let animTimer = null;
|
|
let locationMarker = null;
|
|
+ let visibleLayer = null;
|
|
+ let framesReady = false;
|
|
+ let loadingFrames = false;
|
|
|
|
function activeFrames() {
|
|
return mode === 'radar' ? radarFrames : satelliteFrames;
|
|
@@ -623,12 +494,33 @@ function createRadar(mapEl, timelineEl, playBtn, tsEl, layerBtn) {
|
|
if (!frame) return;
|
|
|
|
if (!layers[mode][frame.time]) {
|
|
- layers[mode][frame.time] = L.tileLayer(frameTileUrl(frame), { opacity: 0.75, maxZoom: 15 });
|
|
+ layers[mode][frame.time] = L.tileLayer(frameTileUrl(frame), { opacity: 0, maxZoom: 15 });
|
|
+ }
|
|
+
|
|
+ Object.values(layers.radar).forEach(l => {
|
|
+ if (l !== visibleLayer && map.hasLayer(l)) map.removeLayer(l);
|
|
+ });
|
|
+ Object.values(layers.satellite).forEach(l => {
|
|
+ if (l !== visibleLayer && map.hasLayer(l)) map.removeLayer(l);
|
|
+ });
|
|
+
|
|
+ const nextLayer = layers[mode][frame.time];
|
|
+ if (!map.hasLayer(nextLayer)) {
|
|
+ nextLayer.setOpacity(0);
|
|
+ nextLayer.addTo(map);
|
|
}
|
|
|
|
- Object.values(layers.radar).forEach(l => { if (map.hasLayer(l)) map.removeLayer(l); });
|
|
- Object.values(layers.satellite).forEach(l => { if (map.hasLayer(l)) map.removeLayer(l); });
|
|
- layers[mode][frame.time].addTo(map);
|
|
+ nextLayer.setOpacity(0.78);
|
|
+
|
|
+ if (visibleLayer && visibleLayer !== nextLayer && map.hasLayer(visibleLayer)) {
|
|
+ visibleLayer.setOpacity(0.2);
|
|
+ const prevLayer = visibleLayer;
|
|
+ setTimeout(() => {
|
|
+ if (prevLayer !== visibleLayer && map.hasLayer(prevLayer)) map.removeLayer(prevLayer);
|
|
+ }, FRAME_FADE_MS);
|
|
+ }
|
|
+
|
|
+ visibleLayer = nextLayer;
|
|
|
|
tsEl.textContent = tsLabel(frame.time);
|
|
document.querySelectorAll(`#${timelineEl.id} .tl-tick`).forEach((el, i) => {
|
|
@@ -661,7 +553,7 @@ function createRadar(mapEl, timelineEl, playBtn, tsEl, layerBtn) {
|
|
function startPlay() {
|
|
playing = true;
|
|
playBtn.textContent = '⏸';
|
|
- animTimer = setInterval(step, 500);
|
|
+ animTimer = setInterval(step, FRAME_INTERVAL_MS);
|
|
}
|
|
|
|
function stopPlay() {
|
|
@@ -677,6 +569,7 @@ function createRadar(mapEl, timelineEl, playBtn, tsEl, layerBtn) {
|
|
if (!(radarFrames.length && satelliteFrames.length)) return;
|
|
mode = mode === 'radar' ? 'satellite' : 'radar';
|
|
layerBtn.textContent = mode === 'radar' ? 'R' : 'S';
|
|
+ visibleLayer = null;
|
|
buildTimeline();
|
|
const idx = Math.min(currentIdx, activeFrames().length - 1);
|
|
showFrame(Math.max(idx, 0));
|
|
@@ -684,29 +577,65 @@ function createRadar(mapEl, timelineEl, playBtn, tsEl, layerBtn) {
|
|
}
|
|
|
|
async function loadFrames() {
|
|
- try {
|
|
- const r = await fetch('/api/radar/times');
|
|
- const data = await r.json();
|
|
+ if (loadingFrames || framesReady) return;
|
|
+ loadingFrames = true;
|
|
+ let retries = 0;
|
|
+ const maxRetries = 2;
|
|
|
|
- radarFrames = [];
|
|
- satelliteFrames = [];
|
|
- (data.radar?.past || []).forEach(f => radarFrames.push({ time: f.time, path: f.path, isForecast: false }));
|
|
- (data.radar?.nowcast || []).forEach(f => radarFrames.push({ time: f.time, path: f.path, isForecast: true }));
|
|
- (data.satellite?.infrared || []).forEach(f => satelliteFrames.push({ time: f.time, path: f.path, isForecast: false }));
|
|
+ try {
|
|
+ while (retries < maxRetries) {
|
|
+ try {
|
|
+ const controller = new AbortController();
|
|
+ const timeoutId = setTimeout(() => controller.abort(), 20000);
|
|
+
|
|
+ const r = await fetch('/api/radar/times', { signal: controller.signal });
|
|
+ clearTimeout(timeoutId);
|
|
+
|
|
+ if (!r.ok) {
|
|
+ throw new Error(`Radar fetch returned ${r.status}`);
|
|
+ }
|
|
+ const data = await r.json();
|
|
+
|
|
+ radarFrames = [];
|
|
+ satelliteFrames = [];
|
|
+ (data.radar?.past || []).forEach(f => radarFrames.push({ time: f.time, path: f.path, isForecast: false }));
|
|
+ (data.radar?.nowcast || []).forEach(f => radarFrames.push({ time: f.time, path: f.path, isForecast: true }));
|
|
+ (data.satellite?.infrared || []).forEach(f => satelliteFrames.push({ time: f.time, path: f.path, isForecast: false }));
|
|
+
|
|
+ if (!radarFrames.length && satelliteFrames.length) mode = 'satellite';
|
|
+ if (!radarFrames.length && !satelliteFrames.length) {
|
|
+ console.warn('No radar or satellite frames available');
|
|
+ return;
|
|
+ }
|
|
|
|
- if (!radarFrames.length && satelliteFrames.length) mode = 'satellite';
|
|
- if (!radarFrames.length && !satelliteFrames.length) return;
|
|
+ if (layerBtn) {
|
|
+ layerBtn.disabled = !(radarFrames.length && satelliteFrames.length);
|
|
+ layerBtn.textContent = mode === 'radar' ? 'R' : 'S';
|
|
+ }
|
|
|
|
- if (layerBtn) {
|
|
- layerBtn.disabled = !(radarFrames.length && satelliteFrames.length);
|
|
- layerBtn.textContent = mode === 'radar' ? 'R' : 'S';
|
|
+ buildTimeline();
|
|
+ const frames = activeFrames();
|
|
+ showFrame(Math.max(frames.length - 1, 0));
|
|
+ framesReady = true;
|
|
+ return;
|
|
+ } catch (e) {
|
|
+ retries++;
|
|
+ if (e.name === 'AbortError') {
|
|
+ console.warn(`Radar load attempt ${retries} timed out - API slow or unavailable`);
|
|
+ } else {
|
|
+ console.error(`Radar load attempt ${retries} failed:`, e.message);
|
|
+ }
|
|
+
|
|
+ if (retries >= maxRetries) {
|
|
+ console.error('Radar load failed after', maxRetries, 'attempts');
|
|
+ return;
|
|
+ }
|
|
+
|
|
+ await new Promise(resolve => setTimeout(resolve, 1000));
|
|
}
|
|
-
|
|
- buildTimeline();
|
|
- const frames = activeFrames();
|
|
- showFrame(frames.length - 1);
|
|
- } catch (e) {
|
|
- console.error('Radar load failed', e);
|
|
+ }
|
|
+ } finally {
|
|
+ loadingFrames = false;
|
|
}
|
|
}
|
|
|
|
@@ -722,8 +651,32 @@ function createRadar(mapEl, timelineEl, playBtn, tsEl, layerBtn) {
|
|
}).addTo(map);
|
|
}
|
|
|
|
- loadFrames();
|
|
- return { map, panTo, startPlay, stopPlay };
|
|
+ function isMapVisible() {
|
|
+ return mapEl.offsetWidth > 0 && mapEl.offsetHeight > 0;
|
|
+ }
|
|
+
|
|
+ function refresh() {
|
|
+ if (!isMapVisible()) return;
|
|
+ map.invalidateSize();
|
|
+ loadFrames();
|
|
+ }
|
|
+
|
|
+ // Wait for visibility before first load to avoid zero-size Leaflet rendering.
|
|
+ setTimeout(() => {
|
|
+ let tries = 0;
|
|
+ const maxTries = 40; // ~10 seconds at 250ms interval
|
|
+ const timer = setInterval(() => {
|
|
+ tries++;
|
|
+ if (isMapVisible()) {
|
|
+ refresh();
|
|
+ clearInterval(timer);
|
|
+ } else if (tries >= maxTries) {
|
|
+ clearInterval(timer);
|
|
+ }
|
|
+ }, 250);
|
|
+ }, 100);
|
|
+
|
|
+ return { map, panTo, startPlay, stopPlay, refresh };
|
|
}
|
|
|
|
const radarEmbed = createRadar(
|
|
@@ -754,35 +707,6 @@ 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.25, Math.min(0.9, state.radarOpacity / 100)),
|
|
- }).addTo(map);
|
|
-
|
|
- let lastRadarFramePath = null;
|
|
-
|
|
- async function refreshRadarOverlay() {
|
|
- try {
|
|
- const r = await fetch('/api/radar/times');
|
|
- if (!r.ok) return;
|
|
- const data = await r.json();
|
|
- const frames = [
|
|
- ...(data?.radar?.past || []),
|
|
- ...(data?.radar?.nowcast || []),
|
|
- ];
|
|
- const latest = frames[frames.length - 1];
|
|
- const path = latest?.path;
|
|
- if (!path || path === lastRadarFramePath) return;
|
|
- lastRadarFramePath = path;
|
|
- radarLayer.setUrl(`https://tilecache.rainviewer.com${path}/256/{z}/{x}/{y}/2/1_1.png`);
|
|
- } catch (e) {
|
|
- console.warn('Maps radar overlay refresh failed', e);
|
|
- }
|
|
- }
|
|
-
|
|
- refreshRadarOverlay();
|
|
- setInterval(refreshRadarOverlay, 5 * 60 * 1000);
|
|
-
|
|
let marker = null;
|
|
|
|
function panTo(lat, lon, zoom) {
|
|
@@ -797,20 +721,13 @@ function createOverviewMap(mapEl) {
|
|
}).addTo(map);
|
|
}
|
|
|
|
- function setRadarOpacity(opacityPct) {
|
|
- const normalized = Math.max(0.25, Math.min(0.9, Number(opacityPct) / 100));
|
|
- radarLayer.setOpacity(normalized);
|
|
- }
|
|
-
|
|
- return { map, panTo, setRadarOpacity, refreshRadarOverlay };
|
|
+ return { map, panTo };
|
|
}
|
|
|
|
-function clearLightningLayer() {
|
|
+function clearLightningMarkers() {
|
|
if (!mapsOverview.map) return;
|
|
- if (state.lightningLayer) {
|
|
- mapsOverview.map.removeLayer(state.lightningLayer);
|
|
- state.lightningLayer = null;
|
|
- }
|
|
+ state.lightningMarkers.forEach(marker => mapsOverview.map.removeLayer(marker));
|
|
+ state.lightningMarkers = [];
|
|
}
|
|
|
|
function clearWindArrows() {
|
|
@@ -823,87 +740,61 @@ function refreshMapsOverview() {
|
|
if (!mapsOverview.map) return;
|
|
|
|
mapsOverview.map.invalidateSize();
|
|
- mapsOverview.refreshRadarOverlay();
|
|
|
|
if (!state.currentCity) return;
|
|
|
|
mapsOverview.panTo(state.currentCity.latitude, state.currentCity.longitude, 6);
|
|
|
|
- clearLightningLayer();
|
|
+ clearLightningMarkers();
|
|
clearWindArrows();
|
|
|
|
if (state.showLightning) renderLightningStrikes(state.currentCity);
|
|
if (state.showWind && state.currentWeather) renderWindArrows(state.currentCity);
|
|
}
|
|
|
|
-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) {
|
|
+function renderLightningStrikes(city) {
|
|
if (!mapsOverview.map || !state.showLightning) return;
|
|
- clearLightningLayer();
|
|
-
|
|
- try {
|
|
- const data = await fetchLightning(city.latitude, city.longitude);
|
|
- const features = data?.features || [];
|
|
- if (!features.length) return;
|
|
-
|
|
- 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 polygonSets = [];
|
|
- if (geom.type === 'Polygon' && Array.isArray(geom.coordinates?.[0])) {
|
|
- polygonSets.push(geom.coordinates[0]);
|
|
+ clearLightningMarkers();
|
|
+
|
|
+ // Simulate lightning strikes in the vicinity of the city
|
|
+ // In production, this would fetch from a real lightning API like Blitzortung
|
|
+ const strokeCount = 5 + Math.random() * 3; // Random 5-8 strikes
|
|
+ const maxRadius = 0.35;
|
|
+
|
|
+ for (let i = 0; i < strokeCount; i++) {
|
|
+ const angle = Math.random() * Math.PI * 2;
|
|
+ const radius = Math.random() * maxRadius;
|
|
+ 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 marker = L.circleMarker([lat, lon], {
|
|
+ radius: 3 + Math.random() * 3,
|
|
+ fillColor: '#ffeb3b',
|
|
+ fillOpacity: 0.7 + Math.random() * 0.3,
|
|
+ color: '#ff6f00',
|
|
+ weight: 1.5,
|
|
+ className: 'lightning-marker',
|
|
+ }).bindPopup('ΓÜí Lightning strike (simulated)');
|
|
+
|
|
+ marker.addTo(mapsOverview.map);
|
|
+ state.lightningMarkers.push(marker);
|
|
+
|
|
+ // Animate the marker to fade out
|
|
+ setTimeout(() => {
|
|
+ if (marker && mapsOverview.map.hasLayer(marker)) {
|
|
+ const fade = setInterval(() => {
|
|
+ const opacity = parseFloat(marker.options.fillOpacity) - 0.05;
|
|
+ if (opacity <= 0) {
|
|
+ clearInterval(fade);
|
|
+ mapsOverview.map.removeLayer(marker);
|
|
+ } else {
|
|
+ marker.setStyle({ fillOpacity: opacity });
|
|
+ }
|
|
+ }, 200);
|
|
}
|
|
- if (geom.type === 'MultiPolygon' && Array.isArray(geom.coordinates)) {
|
|
- geom.coordinates.forEach(poly => {
|
|
- if (Array.isArray(poly?.[0])) polygonSets.push(poly[0]);
|
|
- });
|
|
- }
|
|
-
|
|
- 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);
|
|
+ }, 3000);
|
|
}
|
|
}
|
|
|
|
@@ -913,159 +804,80 @@ function renderWindArrows(city) {
|
|
|
|
// Get current wind data
|
|
const current = state.currentWeather.current;
|
|
- if (!current || !current.wind_speed_10m) return;
|
|
+ if (!current || current.wind_speed_10m == null) return;
|
|
|
|
const windSpeed = current.wind_speed_10m;
|
|
+ const windGust = current.wind_gusts_10m || 0;
|
|
const windDir = current.wind_direction_10m || 0;
|
|
|
|
- // Draw a wind barb pointing in the direction of wind movement
|
|
+ // Update wind data display
|
|
+ document.getElementById('wind-speed').textContent = `${speedDisplay(windSpeed)} ${speedUnit()}`;
|
|
+ document.getElementById('wind-gust').textContent = windGust > 0 ? `${speedDisplay(windGust)} ${speedUnit()}` : '--';
|
|
+ document.getElementById('wind-direction').textContent = compassDirFull(windDir);
|
|
+
|
|
+ // Draw multiple wind barbs around the city center to show wind strength
|
|
const lat = parseFloat(city.latitude);
|
|
const lon = parseFloat(city.longitude);
|
|
- const radius = 0.3;
|
|
-
|
|
- // Calculate arrow end point based on wind direction
|
|
- const radians = (windDir * Math.PI) / 180;
|
|
- const endLat = lat + radius * Math.cos(radians);
|
|
- const endLon = lon + radius * Math.sin(radians);
|
|
-
|
|
- // Draw arrow line
|
|
- const arrow = L.polyline(
|
|
- [[lat, lon], [endLat, endLon]],
|
|
- {
|
|
- color: '#4a90e2',
|
|
- weight: 2,
|
|
- opacity: 0.7,
|
|
- }
|
|
- ).bindPopup(`Wind: ${speedDisplay(windSpeed)} ${speedUnit()}`);
|
|
-
|
|
- arrow.addTo(mapsOverview.map);
|
|
- state.windArrows.push(arrow);
|
|
+ const arrowCount = Math.min(Math.ceil(windSpeed / 5), 5); // More arrows = stronger wind
|
|
+ const baseRadius = 0.15;
|
|
|
|
- // Draw arrow head as a small circle at the end
|
|
- const arrowHead = L.circleMarker([endLat, endLon], {
|
|
- radius: 3,
|
|
- fillColor: '#4a90e2',
|
|
- fillOpacity: 0.7,
|
|
- color: '#fff',
|
|
- weight: 1,
|
|
- });
|
|
-
|
|
- arrowHead.addTo(mapsOverview.map);
|
|
- state.windArrows.push(arrowHead);
|
|
-}
|
|
-
|
|
-const mapsOverview = createOverviewMap(document.getElementById('map-overview'));
|
|
-
|
|
-function setMapsFullscreen(active) {
|
|
- const wrap = document.querySelector('#tab-maps .maps-wrap');
|
|
- if (!wrap) return;
|
|
- wrap.classList.toggle('is-fullscreen', active);
|
|
- document.body.classList.toggle('map-fullscreen-lock', active);
|
|
- if (mapsFullscreenBtn) {
|
|
- mapsFullscreenBtn.textContent = active ? 'Exit fullscreen' : 'Fullscreen map';
|
|
- }
|
|
- setTimeout(() => {
|
|
- if (mapsOverview?.map) mapsOverview.map.invalidateSize();
|
|
- }, 100);
|
|
-}
|
|
-
|
|
-if (mapsFullscreenBtn) {
|
|
- mapsFullscreenBtn.addEventListener('click', async () => {
|
|
- const wrap = document.querySelector('#tab-maps .maps-wrap');
|
|
- if (!wrap) return;
|
|
-
|
|
- if (document.fullscreenElement === wrap) {
|
|
- await document.exitFullscreen().catch(() => {});
|
|
- setMapsFullscreen(false);
|
|
- return;
|
|
- }
|
|
-
|
|
- if (wrap.requestFullscreen) {
|
|
- try {
|
|
- await wrap.requestFullscreen();
|
|
- setMapsFullscreen(true);
|
|
- return;
|
|
- } catch {
|
|
- // Some mobile browsers reject Fullscreen API; fallback below.
|
|
+ for (let i = 0; i < arrowCount; i++) {
|
|
+ const offset = (i - arrowCount / 2) * 0.08;
|
|
+ const offsetLat = lat + offset;
|
|
+ const offsetLon = lon;
|
|
+
|
|
+ // Calculate arrow end point based on wind direction
|
|
+ const radians = (windDir * Math.PI) / 180;
|
|
+ const arrowLength = 0.15 + i * 0.05;
|
|
+ const endLat = offsetLat + arrowLength * Math.cos(radians);
|
|
+ const endLon = offsetLon + arrowLength * Math.sin(radians);
|
|
+
|
|
+ // Draw arrow line with gradient color based on wind speed
|
|
+ const color = windSpeed > 20 ? '#ff4444' : windSpeed > 10 ? '#ffaa00' : '#4a90e2';
|
|
+ const weight = 1 + (i % 2);
|
|
+
|
|
+ const arrow = L.polyline(
|
|
+ [[offsetLat, offsetLon], [endLat, endLon]],
|
|
+ {
|
|
+ color: color,
|
|
+ weight: weight,
|
|
+ opacity: 0.7,
|
|
+ smooth: true,
|
|
}
|
|
- }
|
|
-
|
|
- setMapsFullscreen(!wrap.classList.contains('is-fullscreen'));
|
|
- });
|
|
-}
|
|
-
|
|
-document.addEventListener('fullscreenchange', () => {
|
|
- const wrap = document.querySelector('#tab-maps .maps-wrap');
|
|
- if (!wrap) return;
|
|
- setMapsFullscreen(document.fullscreenElement === wrap);
|
|
-});
|
|
-
|
|
-// ΓöÇΓöÇ Rendering ΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇ
|
|
-function renderDailyForecast(daily) {
|
|
- const list = document.getElementById('daily-list');
|
|
- const totalDays = daily?.time?.length || 0;
|
|
- const defaultDays = Math.min(7, totalDays);
|
|
- const visibleDays = state.dailyExpanded ? totalDays : defaultDays;
|
|
-
|
|
- list.innerHTML = '';
|
|
- for (let i = 0; i < visibleDays; i++) {
|
|
- const dw = wmo(daily.weather_code[i]);
|
|
- const row = document.createElement('div');
|
|
- row.className = 'day-row' + (i === 0 ? ' today' : '');
|
|
- row.innerHTML = `
|
|
- <div class="day-name">${formatDayShort(daily.time[i])}</div>
|
|
- <div class="day-icon">${dw.icon}</div>
|
|
- <div class="day-desc">${dw.desc}</div>
|
|
- <div class="day-temps">${tempDisplay(daily.temperature_2m_max[i])}┬░<span class="day-lo"> / ${tempDisplay(daily.temperature_2m_min[i])}┬░</span></div>
|
|
- <div class="day-meta">${daily.precipitation_probability_max[i] > 0 ? daily.precipitation_probability_max[i] + '% rain<br>' : ''}${speedDisplay(daily.wind_speed_10m_max[i])} ${speedUnit()}</div>
|
|
- `;
|
|
- list.appendChild(row);
|
|
- }
|
|
-
|
|
- if (dailyForecastLabel) {
|
|
- dailyForecastLabel.textContent = `${visibleDays}-Day Forecast`;
|
|
- }
|
|
-
|
|
- if (dailyForecastToggle) {
|
|
- if (totalDays > defaultDays) {
|
|
- show(dailyForecastToggle);
|
|
- dailyForecastToggle.textContent = state.dailyExpanded
|
|
- ? 'Show 7-day view'
|
|
- : `Show full ${totalDays}-day forecast`;
|
|
- } else {
|
|
- hide(dailyForecastToggle);
|
|
- }
|
|
+ ).bindPopup(`💨 Wind: ${speedDisplay(windSpeed)} ${speedUnit()}<br>Direction: ${compassDirFull(windDir)}`);
|
|
+
|
|
+ arrow.addTo(mapsOverview.map);
|
|
+ state.windArrows.push(arrow);
|
|
+
|
|
+ // Draw arrow head
|
|
+ const headSize = 0.03;
|
|
+ const arrowHeadLat1 = endLat - headSize * Math.cos(radians + Math.PI * 0.15);
|
|
+ const arrowHeadLon1 = endLon - headSize * Math.sin(radians + Math.PI * 0.15);
|
|
+ const arrowHeadLat2 = endLat - headSize * Math.cos(radians - Math.PI * 0.15);
|
|
+ const arrowHeadLon2 = endLon - headSize * Math.sin(radians - Math.PI * 0.15);
|
|
+
|
|
+ const arrowHead = L.polyline(
|
|
+ [[arrowHeadLat1, arrowHeadLon1], [endLat, endLon], [arrowHeadLat2, arrowHeadLon2]],
|
|
+ {
|
|
+ color: color,
|
|
+ weight: weight,
|
|
+ opacity: 0.7,
|
|
+ }
|
|
+ );
|
|
+
|
|
+ arrowHead.addTo(mapsOverview.map);
|
|
+ state.windArrows.push(arrowHead);
|
|
}
|
|
}
|
|
|
|
-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])}.`;
|
|
-}
|
|
+const mapsOverview = createOverviewMap(document.getElementById('map-overview'));
|
|
|
|
+// ΓöÇΓöÇ Rendering ΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇ
|
|
function renderWeather(data, city) {
|
|
const c = data.current;
|
|
const h = data.hourly;
|
|
const d = data.daily;
|
|
|
|
- applyWeatherBackground(c.weather_code, c.is_day);
|
|
-
|
|
document.getElementById('loc-name').textContent = city.name;
|
|
document.getElementById('loc-sub').textContent = [city.admin1, city.country].filter(Boolean).join(', ');
|
|
|
|
@@ -1084,18 +896,37 @@ function renderWeather(data, city) {
|
|
document.getElementById('cur-lo').textContent = tempDisplay(d.temperature_2m_min[0]);
|
|
document.getElementById('cur-precip').textContent = c.precipitation > 0 ? `${precipDisplay(c.precipitation)} ${state.units === 'us' ? 'in' : 'mm'} precip` : 'No precipitation';
|
|
document.getElementById('cur-hum').textContent = c.relative_humidity_2m;
|
|
- document.getElementById('cur-wind').textContent = `${speedDisplay(c.wind_speed_10m)} ${compassDir(c.wind_direction_10m)}`;
|
|
+ document.getElementById('hum-feel').textContent = humidityFeel(c.relative_humidity_2m);
|
|
+ document.getElementById('cur-wind').textContent = speedDisplay(c.wind_speed_10m);
|
|
+ document.getElementById('cur-wind-dir').textContent = compassDirFull(c.wind_direction_10m);
|
|
+ document.getElementById('cur-gust').textContent = c.wind_gusts_10m != null ? speedDisplay(c.wind_gusts_10m) : 'ΓÇö';
|
|
+ document.getElementById('cur-dew').textContent = calcDewpoint(c.temperature_2m, c.relative_humidity_2m);
|
|
document.getElementById('cur-pres').textContent = Math.round(c.surface_pressure);
|
|
document.getElementById('cur-vis').textContent = visDisplay(c.visibility);
|
|
document.getElementById('cur-uv').textContent = Math.round(d.uv_index_max[0] ?? 0);
|
|
document.getElementById('cur-cloud').textContent = c.cloud_cover;
|
|
- document.querySelector('.stat:nth-child(2) .unit').textContent = speedUnit();
|
|
- document.querySelector('.stat:nth-child(4) .unit').textContent = visUnit();
|
|
+ document.querySelectorAll('.wind-unit, .gust-unit').forEach(el => el.textContent = speedUnit());
|
|
+ document.querySelectorAll('.vis-unit').forEach(el => el.textContent = visUnit());
|
|
|
|
document.getElementById('sunrise-val').textContent = formatClock(d.sunrise[0]);
|
|
document.getElementById('sunset-val').textContent = formatClock(d.sunset[0]);
|
|
document.getElementById('moon-phase-val').textContent = moonPhase(d.time[0]);
|
|
|
|
+ // Sun timeline progress
|
|
+ const sunriseMs = new Date(d.sunrise[0]).getTime();
|
|
+ const sunsetMs = new Date(d.sunset[0]).getTime();
|
|
+ const nowMs = Date.now();
|
|
+ const totalMs = sunsetMs - sunriseMs;
|
|
+ const sunPct = Math.max(0, Math.min(100, ((nowMs - sunriseMs) / totalMs) * 100));
|
|
+ const progressEl = document.getElementById('sun-progress');
|
|
+ const dotEl = document.getElementById('sun-dot');
|
|
+ if (progressEl) progressEl.style.width = sunPct + '%';
|
|
+ if (dotEl) dotEl.style.left = sunPct + '%';
|
|
+ const daylightEl = document.getElementById('daylight-info');
|
|
+ if (daylightEl) daylightEl.textContent = `${(totalMs / 3600000).toFixed(1)}h daylight`;
|
|
+
|
|
+ renderRecommendations(data);
|
|
+
|
|
const nowTs = Date.now();
|
|
let startIdx = h.time.findIndex(t => new Date(t).getTime() >= nowTs);
|
|
if (startIdx < 0) startIdx = 0;
|
|
@@ -1112,16 +943,12 @@ function renderWeather(data, city) {
|
|
<div class="hour-time">${i === startIdx ? 'Now' : formatHour(h.time[i])}</div>
|
|
<div class="hour-icon">${hw.icon}</div>
|
|
<div class="hour-temp">${tempDisplay(h.temperature_2m[i])}┬░</div>
|
|
- <div class="hour-feels">Feels ${tempDisplay(h.apparent_temperature?.[i])}┬░</div>
|
|
<div class="hour-precip">${pp > 0 ? `${pp}% ${ptype}` : ptype}</div>
|
|
`;
|
|
track.appendChild(cell);
|
|
}
|
|
|
|
- if (hourlySummaryEl) {
|
|
- hourlySummaryEl.textContent = summarizeHourly(h, startIdx);
|
|
- }
|
|
-
|
|
+ state.showExtendedForecast = false;
|
|
renderDailyForecast(d);
|
|
|
|
document.getElementById('updated-at').textContent =
|
|
@@ -1137,6 +964,43 @@ function renderWeather(data, city) {
|
|
refreshMapsOverview();
|
|
|
|
show(weatherDiv);
|
|
+ setTimeout(() => { radarEmbed.refresh && radarEmbed.refresh(); }, 80);
|
|
+}
|
|
+
|
|
+function renderDailyForecast(daily) {
|
|
+ const list = document.getElementById('daily-list');
|
|
+ if (!list || !daily?.time?.length) return;
|
|
+
|
|
+ const totalDays = daily.time.length;
|
|
+ const maxDays = state.showExtendedForecast ? Math.min(16, totalDays) : Math.min(7, totalDays);
|
|
+
|
|
+ list.innerHTML = '';
|
|
+ for (let i = 0; i < maxDays; i++) {
|
|
+ const dw = wmo(daily.weather_code[i]);
|
|
+ const row = document.createElement('div');
|
|
+ row.className = 'day-row' + (i === 0 ? ' today' : '');
|
|
+ row.innerHTML = `
|
|
+ <div class="day-name">${formatDayShort(daily.time[i])}</div>
|
|
+ <div class="day-icon">${dw.icon}</div>
|
|
+ <div class="day-desc">${dw.desc}</div>
|
|
+ <div class="day-temps">${tempDisplay(daily.temperature_2m_max[i])}┬░<span class="day-lo"> / ${tempDisplay(daily.temperature_2m_min[i])}┬░</span></div>
|
|
+ <div class="day-meta">${daily.precipitation_probability_max[i] > 0 ? daily.precipitation_probability_max[i] + '% rain<br>' : ''}${speedDisplay(daily.wind_speed_10m_max[i])} ${speedUnit()}</div>
|
|
+ `;
|
|
+ list.appendChild(row);
|
|
+ }
|
|
+
|
|
+ if (dailyForecastLabel) {
|
|
+ dailyForecastLabel.textContent = state.showExtendedForecast ? '16-Day Forecast' : '7-Day Forecast';
|
|
+ }
|
|
+
|
|
+ if (dailyToggleBtn) {
|
|
+ if (totalDays > 7) {
|
|
+ show(dailyToggleBtn);
|
|
+ dailyToggleBtn.textContent = state.showExtendedForecast ? 'Show 7-day' : 'Show 16-day';
|
|
+ } else {
|
|
+ hide(dailyToggleBtn);
|
|
+ }
|
|
+ }
|
|
}
|
|
|
|
function renderAirQuality(data) {
|
|
@@ -1189,36 +1053,70 @@ function renderAlerts(data) {
|
|
async function loadAll(city, source) {
|
|
state.currentCity = city;
|
|
if (source) state.currentSource = source;
|
|
-
|
|
- const saved = state.savedCities.find(e => cityKey(e.city) === cityKey(city));
|
|
- if (saved) {
|
|
- saved.lastViewedAt = Date.now();
|
|
- persistSavedCities();
|
|
- renderSavedCities();
|
|
- }
|
|
-
|
|
clearErr();
|
|
hide(suggestEl);
|
|
hide(weatherDiv);
|
|
show(loadingEl);
|
|
|
|
try {
|
|
- const [weather, air, alerts] = await Promise.all([
|
|
- fetchWeather(city.latitude, city.longitude, city.timezone),
|
|
+ let weather;
|
|
+ try {
|
|
+ weather = await fetchWeather(city.latitude, city.longitude, city.timezone);
|
|
+ } catch (weatherErr) {
|
|
+ console.error('Primary weather fetch failed:', weatherErr?.message || weatherErr);
|
|
+ if (state.currentWeather) {
|
|
+ weather = state.currentWeather;
|
|
+ showErr('Live weather update failed. Showing last available data.');
|
|
+ } else {
|
|
+ throw weatherErr;
|
|
+ }
|
|
+ }
|
|
+
|
|
+ const [airResult, alertsResult] = await Promise.allSettled([
|
|
fetchAirQuality(city.latitude, city.longitude, city.timezone),
|
|
- fetchAlerts(city.latitude, city.longitude).catch(() => ({ features: [] })),
|
|
+ fetchAlerts(city.latitude, city.longitude),
|
|
]);
|
|
|
|
+ const air = airResult.status === 'fulfilled' ? airResult.value : { current: {} };
|
|
+ if (airResult.status !== 'fulfilled') {
|
|
+ console.warn('Air quality fetch failed:', airResult.reason?.message || airResult.reason);
|
|
+ }
|
|
+
|
|
+ const alerts = alertsResult.status === 'fulfilled' ? alertsResult.value : { features: [] };
|
|
+ if (alertsResult.status !== 'fulfilled') {
|
|
+ console.warn('Alerts fetch failed:', alertsResult.reason?.message || alertsResult.reason);
|
|
+ }
|
|
+
|
|
state.currentWeather = weather;
|
|
state.currentAir = air;
|
|
state.lastRefreshAt = Date.now();
|
|
- renderWeather(weather, city);
|
|
- renderAirQuality(air);
|
|
- renderAlerts(alerts);
|
|
+
|
|
+ // Keep weather load resilient: map/radar/render hiccups should not fail the whole page.
|
|
+ try {
|
|
+ renderWeather(weather, city);
|
|
+ } catch (renderErr) {
|
|
+ console.error('Weather render error:', renderErr);
|
|
+ show(weatherDiv);
|
|
+ }
|
|
+
|
|
+ try {
|
|
+ renderAirQuality(air);
|
|
+ } catch (airRenderErr) {
|
|
+ console.warn('Air quality render error:', airRenderErr);
|
|
+ }
|
|
+
|
|
+ try {
|
|
+ renderAlerts(alerts);
|
|
+ } catch (alertsRenderErr) {
|
|
+ console.warn('Alerts render error:', alertsRenderErr);
|
|
+ }
|
|
|
|
- // Render map overlays
|
|
- if (state.showLightning) renderLightningStrikes(city);
|
|
- if (state.showWind) renderWindArrows(city);
|
|
+ try {
|
|
+ if (state.showLightning) renderLightningStrikes(city);
|
|
+ if (state.showWind) renderWindArrows(city);
|
|
+ } catch (mapOverlayErr) {
|
|
+ console.warn('Map overlay render error:', mapOverlayErr);
|
|
+ }
|
|
} catch (e) {
|
|
showErr('Failed to load weather data. Please try again.');
|
|
console.error(e);
|
|
@@ -1280,7 +1178,8 @@ searchInput.addEventListener('input', () => {
|
|
suggestEl.appendChild(li);
|
|
});
|
|
show(suggestEl);
|
|
- } catch {
|
|
+ } catch (err) {
|
|
+ console.warn('Autocomplete geocode failed:', err?.message || err);
|
|
hide(suggestEl);
|
|
}
|
|
}, 300);
|
|
@@ -1336,6 +1235,11 @@ unitsBtn.addEventListener('click', () => {
|
|
if (state.currentWeather && state.currentCity) {
|
|
renderWeather(state.currentWeather, state.currentCity);
|
|
renderAirQuality(state.currentAir);
|
|
+ // Update maps overlays if they're visible
|
|
+ if (state.showWind && mapsOverview.map) {
|
|
+ clearWindArrows();
|
|
+ renderWindArrows(state.currentCity);
|
|
+ }
|
|
}
|
|
});
|
|
|
|
@@ -1357,13 +1261,8 @@ shareBtn.addEventListener('click', async () => {
|
|
saveCurrentBtn.addEventListener('click', () => {
|
|
if (!state.currentCity) return;
|
|
const key = cityKey(state.currentCity);
|
|
- if (state.savedCities.some(c => cityKey(c.city) === key)) return;
|
|
- state.savedCities.push({
|
|
- city: state.currentCity,
|
|
- addedAt: Date.now(),
|
|
- lastViewedAt: Date.now(),
|
|
- isDefault: state.savedCities.length === 0,
|
|
- });
|
|
+ if (state.savedCities.some(c => cityKey(c) === key)) return;
|
|
+ state.savedCities.push(state.currentCity);
|
|
persistSavedCities();
|
|
renderSavedCities();
|
|
});
|
|
@@ -1381,11 +1280,9 @@ notifyBtn.addEventListener('click', async () => {
|
|
|
|
try {
|
|
const permission = await Notification.requestPermission();
|
|
+ updateNotifyButton();
|
|
if (permission === 'granted') {
|
|
- const subscribed = await ensurePushSubscription();
|
|
- if (!subscribed) {
|
|
- showErr('Push needs VAPID keys configured on server.');
|
|
- }
|
|
+ // Re-fetch current alerts immediately so permission change can trigger notifications.
|
|
if (state.currentCity) {
|
|
const alerts = await fetchAlerts(state.currentCity.latitude, state.currentCity.longitude).catch(() => ({ features: [] }));
|
|
renderAlerts(alerts);
|
|
@@ -1395,7 +1292,6 @@ notifyBtn.addEventListener('click', async () => {
|
|
}
|
|
} catch {
|
|
showErr('Could not request notification permission.');
|
|
- } finally {
|
|
updateNotifyButton();
|
|
}
|
|
});
|
|
@@ -1408,9 +1304,8 @@ autoDetectToggle.addEventListener('change', () => {
|
|
lightningToggle.addEventListener('change', () => {
|
|
state.showLightning = lightningToggle.checked;
|
|
localStorage.setItem(STORAGE_KEYS.showLightning, String(state.showLightning));
|
|
- lightningStatus.textContent = state.showLightning ? 'On' : 'Off';
|
|
if (state.currentCity && mapsOverview.map) {
|
|
- clearLightningLayer();
|
|
+ clearLightningMarkers();
|
|
if (state.showLightning) renderLightningStrikes(state.currentCity);
|
|
}
|
|
});
|
|
@@ -1418,55 +1313,51 @@ lightningToggle.addEventListener('change', () => {
|
|
windToggle.addEventListener('change', () => {
|
|
state.showWind = windToggle.checked;
|
|
localStorage.setItem(STORAGE_KEYS.showWind, String(state.showWind));
|
|
- windStatus.textContent = state.showWind ? 'On' : 'Off';
|
|
if (state.currentCity && mapsOverview.map) {
|
|
clearWindArrows();
|
|
if (state.showWind && state.currentWeather) renderWindArrows(state.currentCity);
|
|
}
|
|
});
|
|
|
|
-if (dailyForecastToggle) {
|
|
- dailyForecastToggle.addEventListener('click', () => {
|
|
- if (!state.currentWeather?.daily) return;
|
|
- state.dailyExpanded = !state.dailyExpanded;
|
|
- renderDailyForecast(state.currentWeather.daily);
|
|
- });
|
|
-}
|
|
-
|
|
-if (contrastToggle) {
|
|
- contrastToggle.addEventListener('change', () => {
|
|
- state.highContrast = contrastToggle.checked;
|
|
- localStorage.setItem(STORAGE_KEYS.highContrast, String(state.highContrast));
|
|
- applyHighContrast();
|
|
- });
|
|
-}
|
|
-
|
|
-if (radarOpacityEl) {
|
|
- radarOpacityEl.addEventListener('input', () => {
|
|
- state.radarOpacity = Number(radarOpacityEl.value || 65);
|
|
- localStorage.setItem(STORAGE_KEYS.radarOpacity, String(state.radarOpacity));
|
|
- mapsOverview.setRadarOpacity(state.radarOpacity);
|
|
+// Maps tab quick toggles
|
|
+const mapsLightningToggle = document.getElementById('maps-lightning-toggle');
|
|
+const mapsWindToggle = document.getElementById('maps-wind-toggle');
|
|
+
|
|
+if (mapsLightningToggle) {
|
|
+ mapsLightningToggle.addEventListener('click', () => {
|
|
+ state.showLightning = !state.showLightning;
|
|
+ localStorage.setItem(STORAGE_KEYS.showLightning, String(state.showLightning));
|
|
+ mapsLightningToggle.classList.toggle('active', state.showLightning);
|
|
+ lightningToggle.checked = state.showLightning;
|
|
+ if (state.currentCity && mapsOverview.map) {
|
|
+ clearLightningMarkers();
|
|
+ if (state.showLightning) renderLightningStrikes(state.currentCity);
|
|
+ }
|
|
});
|
|
-}
|
|
-
|
|
-if (lightningAgeEl) {
|
|
- lightningAgeEl.addEventListener('change', () => {
|
|
- state.lightningAgeMin = Number(lightningAgeEl.value || 15);
|
|
- localStorage.setItem(STORAGE_KEYS.lightningAgeMin, String(state.lightningAgeMin));
|
|
- if (state.showLightning && state.currentCity) {
|
|
- renderLightningStrikes(state.currentCity);
|
|
+ // Set initial state
|
|
+ mapsLightningToggle.classList.toggle('active', state.showLightning);
|
|
+}
|
|
+
|
|
+if (mapsWindToggle) {
|
|
+ mapsWindToggle.addEventListener('click', () => {
|
|
+ state.showWind = !state.showWind;
|
|
+ localStorage.setItem(STORAGE_KEYS.showWind, String(state.showWind));
|
|
+ mapsWindToggle.classList.toggle('active', state.showWind);
|
|
+ windToggle.checked = state.showWind;
|
|
+ if (state.currentCity && mapsOverview.map) {
|
|
+ clearWindArrows();
|
|
+ if (state.showWind && state.currentWeather) renderWindArrows(state.currentCity);
|
|
}
|
|
});
|
|
+ // Set initial state
|
|
+ mapsWindToggle.classList.toggle('active', state.showWind);
|
|
}
|
|
|
|
-if (pushTestBtn) {
|
|
- pushTestBtn.addEventListener('click', async () => {
|
|
- try {
|
|
- await sendTestPush();
|
|
- pushTestBtn.textContent = 'Push sent';
|
|
- setTimeout(() => { pushTestBtn.textContent = 'Send test push'; }, 1500);
|
|
- } catch (e) {
|
|
- showErr(e.message || 'Push test failed.');
|
|
+if (dailyToggleBtn) {
|
|
+ dailyToggleBtn.addEventListener('click', () => {
|
|
+ state.showExtendedForecast = !state.showExtendedForecast;
|
|
+ if (state.currentWeather) {
|
|
+ renderDailyForecast(state.currentWeather.daily);
|
|
}
|
|
});
|
|
}
|
|
@@ -1489,36 +1380,18 @@ function startAutoRefresh() {
|
|
|
|
// ΓöÇΓöÇ Default city / query link load ΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇ
|
|
(async () => {
|
|
+ await cleanupServiceWorkerInterceptors();
|
|
updateUnitsButton();
|
|
updateNotifyButton();
|
|
syncAutoDetectToggle();
|
|
-
|
|
- // Sync lightning/wind and UI polish toggles.
|
|
+
|
|
+ // Sync lightning and wind toggle states
|
|
lightningToggle.checked = state.showLightning;
|
|
- lightningStatus.textContent = state.showLightning ? 'On' : 'Off';
|
|
windToggle.checked = state.showWind;
|
|
- windStatus.textContent = state.showWind ? 'On' : 'Off';
|
|
- if (contrastToggle) contrastToggle.checked = state.highContrast;
|
|
- if (lightningAgeEl) lightningAgeEl.value = String(state.lightningAgeMin || 15);
|
|
- if (radarOpacityEl) radarOpacityEl.value = String(state.radarOpacity || 65);
|
|
- applyHighContrast();
|
|
- mapsOverview.setRadarOpacity(state.radarOpacity);
|
|
-
|
|
+
|
|
renderSavedCities();
|
|
startAutoRefresh();
|
|
|
|
- // Restore push state if already subscribed.
|
|
- if ('serviceWorker' in navigator && 'PushManager' in window) {
|
|
- try {
|
|
- const reg = await navigator.serviceWorker.ready;
|
|
- const existingSub = await reg.pushManager.getSubscription();
|
|
- state.pushEnabled = Boolean(existingSub);
|
|
- } catch {
|
|
- state.pushEnabled = false;
|
|
- }
|
|
- updateNotifyButton();
|
|
- }
|
|
-
|
|
const qs = new URLSearchParams(window.location.search);
|
|
const qLat = Number(qs.get('lat'));
|
|
const qLon = Number(qs.get('lon'));
|
|
@@ -1542,13 +1415,6 @@ function startAutoRefresh() {
|
|
if (autoLocated) return;
|
|
}
|
|
|
|
- const defaultSaved = getDefaultSavedEntry();
|
|
- if (defaultSaved?.city) {
|
|
- searchInput.value = defaultSaved.city.name;
|
|
- await loadAll(defaultSaved.city, 'default-saved-city');
|
|
- return;
|
|
- }
|
|
-
|
|
const lastCity = getLastCity();
|
|
if (lastCity) {
|
|
searchInput.value = lastCity.name;
|