diff --git a/.tmp/imgdiff/app.diff b/.tmp/imgdiff/app.diff new file mode 100644 index 0000000..3e37c1b Binary files /dev/null and b/.tmp/imgdiff/app.diff differ diff --git a/.tmp/imgdiff/index.diff b/.tmp/imgdiff/index.diff new file mode 100644 index 0000000..e8fdf3e Binary files /dev/null and b/.tmp/imgdiff/index.diff differ diff --git a/.tmp/imgdiff/new/app.js b/.tmp/imgdiff/new/app.js new file mode 100644 index 0000000..1617dfb --- /dev/null +++ b/.tmp/imgdiff/new/app.js @@ -0,0 +1,1435 @@ +'use strict'; + +// ── WMO codes ──────────────────────────────────────────────────────────────── +const WMO = { + 0:{desc:'Clear sky',icon:'☀️'},1:{desc:'Mainly clear',icon:'🌤️'}, + 2:{desc:'Partly cloudy',icon:'⛅'},3:{desc:'Overcast',icon:'☁️'}, + 45:{desc:'Foggy',icon:'🌫️'},48:{desc:'Icy fog',icon:'🌫️'}, + 51:{desc:'Light drizzle',icon:'🌦️'},53:{desc:'Drizzle',icon:'🌦️'}, + 55:{desc:'Heavy drizzle',icon:'🌧️'},56:{desc:'Freezing drizzle',icon:'🌨️'}, + 57:{desc:'Heavy freezing drizzle',icon:'🌨️'}, + 61:{desc:'Light rain',icon:'🌧️'},63:{desc:'Rain',icon:'🌧️'}, + 65:{desc:'Heavy rain',icon:'🌧️'},66:{desc:'Freezing rain',icon:'🌨️'}, + 67:{desc:'Heavy freezing rain',icon:'🌨️'}, + 71:{desc:'Light snow',icon:'🌨️'},73:{desc:'Snow',icon:'❄️'}, + 75:{desc:'Heavy snow',icon:'❄️'},77:{desc:'Snow grains',icon:'🌨️'}, + 80:{desc:'Light showers',icon:'🌦️'},81:{desc:'Showers',icon:'🌧️'}, + 82:{desc:'Heavy showers',icon:'⛈️'},85:{desc:'Snow showers',icon:'🌨️'}, + 86:{desc:'Heavy snow showers',icon:'❄️'}, + 95:{desc:'Thunderstorm',icon:'⛈️'},96:{desc:'Thunderstorm w/ hail',icon:'⛈️'}, + 99:{desc:'Severe thunderstorm',icon:'⛈️'}, +}; + +function wmo(code) { return WMO[code] || { desc: 'Unknown', icon: '🌡️' }; } + +const DAYS = ['Sunday','Monday','Tuesday','Wednesday','Thursday','Friday','Saturday']; +const MONTHS = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']; +const STORAGE_KEYS = { + lastCity: 'weather.lastCity', + savedCities: 'weather.savedCities', + units: 'weather.units', + autoDetect: 'weather.autoDetect', + showLightning: 'weather.showLightning', + showWind: 'weather.showWind', +}; + +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', + currentCity: null, + currentWeather: null, + currentAir: null, + currentSource: 'startup', + savedCities: JSON.parse(localStorage.getItem(STORAGE_KEYS.savedCities) || '[]'), + alertIdsSeen: new Set(), + autoRefreshTimer: null, + lastRefreshAt: 0, + lightningMarkers: [], + windArrows: [], + showExtendedForecast: false, +}; + +// ── DOM ────────────────────────────────────────────────────────────────────── +const searchInput = document.getElementById('search-input'); +const searchBtn = document.getElementById('search-btn'); +const locateBtn = document.getElementById('locate-btn'); +const suggestEl = document.getElementById('suggestions'); +const errorMsg = document.getElementById('error-msg'); +const loadingEl = document.getElementById('loading'); +const weatherDiv = document.getElementById('weather'); +const unitsBtn = document.getElementById('units-btn'); +const unitsBtnSettings = document.getElementById('units-btn-settings'); +const shareBtn = document.getElementById('share-btn'); +const saveCurrentBtn = document.getElementById('save-current-btn'); +const savedLocationsEl = document.getElementById('saved-locations'); +const notifyBtn = document.getElementById('notify-btn'); +const autoDetectToggle = document.getElementById('auto-detect-toggle'); +const alertsEmptyEl = document.getElementById('alerts-empty'); +const settingsLocationEl = document.getElementById('settings-location'); +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 dailyToggleBtn = document.getElementById('daily-toggle-btn'); +const dailyForecastLabel = document.getElementById('daily-forecast-label'); + +// ── Helpers ────────────────────────────────────────────────────────────────── +const show = el => el.classList.remove('hidden'); +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`; +} + +function formatDayShort(iso) { + const d = new Date(iso + 'T00:00:00'); + const today = new Date(); + const tomorrow = new Date(today); + tomorrow.setDate(today.getDate() + 1); + if (d.toDateString() === today.toDateString()) return 'Today'; + if (d.toDateString() === tomorrow.toDateString()) return 'Tomorrow'; + return DAYS[d.getDay()]; +} + +function formatClock(iso) { + return new Date(iso).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); +} + +function tempDisplay(v) { + if (v == null || Number.isNaN(v)) return '--'; + const n = state.units === 'us' ? v : ((v - 32) * 5) / 9; + return Math.round(n); +} + +function speedDisplay(v) { + if (v == null || Number.isNaN(v)) return '--'; + const n = state.units === 'us' ? v : v * 1.60934; + return Math.round(n); +} + +function precipDisplay(v) { + if (v == null || Number.isNaN(v)) return '--'; + const n = state.units === 'us' ? v : v * 25.4; + return n.toFixed(1); +} + +function visDisplay(vMeters) { + if (vMeters == null || Number.isNaN(vMeters)) return '--'; + const miles = vMeters / 1609; + if (state.units === 'us') return miles.toFixed(1); + return (miles * 1.60934).toFixed(1); +} + +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 updateUnitsButton() { + const label = state.units === 'us' ? 'US' : 'Metric'; + unitsBtn.textContent = label; + if (unitsBtnSettings) unitsBtnSettings.textContent = label; +} + +function syncAutoDetectToggle() { + autoDetectToggle.checked = state.autoDetect; +} + +function updateNotifyButton() { + if (!('Notification' in window)) { + notifyBtn.textContent = 'Notifications unsupported'; + notifyBtn.disabled = true; + return; + } + if (!window.isSecureContext) { + notifyBtn.textContent = 'Needs HTTPS'; + notifyBtn.disabled = true; + return; + } + + notifyBtn.disabled = false; + if (Notification.permission === 'granted') { + notifyBtn.textContent = 'Notifications enabled'; + } else if (Notification.permission === 'denied') { + notifyBtn.textContent = 'Notifications blocked'; + } else { + notifyBtn.textContent = 'Enable notifications'; + } +} + +function moonPhase(dateLike) { + const date = new Date(dateLike); + const synodic = 29.53058867; + const knownNewMoon = new Date('2000-01-06T18:14:00Z'); + const days = (date - knownNewMoon) / 86400000; + const phase = ((days % synodic) + synodic) % synodic; + const index = Math.floor((phase / synodic) * 8) % 8; + return ['New', 'Waxing Crescent', 'First Quarter', 'Waxing Gibbous', 'Full', 'Waning Gibbous', 'Last Quarter', 'Waning Crescent'][index]; +} + +function precipType(code, tempF, rain, snowfall) { + if ((snowfall ?? 0) > 0) return 'Snow'; + if ([56,57,66,67].includes(code) || ((rain ?? 0) > 0 && tempF <= 32)) return 'Sleet'; + if ((rain ?? 0) > 0 || [51,53,55,61,63,65,80,81,82].includes(code)) return 'Rain'; + 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 => + `
${it.icon}${it.label}
` + ).join(''); +} + +function apparentLabel(tempF, apparentF) { + if (tempF <= 50 && apparentF <= tempF - 2) return 'Wind chill'; + if (tempF >= 80 && apparentF >= tempF + 2) return 'Heat index'; + return 'Feels like'; +} + +function cityKey(city) { + return `${Number(city.latitude).toFixed(3)},${Number(city.longitude).toFixed(3)}`; +} + +function persistSavedCities() { + localStorage.setItem(STORAGE_KEYS.savedCities, JSON.stringify(state.savedCities)); +} + +function persistLastCity(city) { + localStorage.setItem(STORAGE_KEYS.lastCity, JSON.stringify(city)); +} + +function getLastCity() { + try { return JSON.parse(localStorage.getItem(STORAGE_KEYS.lastCity) || 'null'); } + catch { return null; } +} + +function cityLabel(city) { + return [city?.name, city?.admin1, city?.country].filter(Boolean).join(', '); +} + +function updateShareUrl(city) { + if (!city) return; + const params = new URLSearchParams(window.location.search); + params.set('lat', city.latitude); + params.set('lon', city.longitude); + params.set('name', city.name || 'Location'); + history.replaceState(null, '', `${window.location.pathname}?${params.toString()}`); +} + +function updateSourceLocation(city) { + const label = cityLabel(city) || (city ? `${Number(city.latitude).toFixed(2)}, ${Number(city.longitude).toFixed(2)}` : '--'); + if (settingsLocationEl) settingsLocationEl.textContent = label; + if (mapsLocationEl) mapsLocationEl.textContent = label; +} + +function renderSavedCities() { + savedLocationsEl.innerHTML = ''; + if (!state.savedCities.length) { + savedLocationsEl.textContent = 'None yet'; + return; + } + + state.savedCities.forEach(city => { + const wrap = document.createElement('div'); + wrap.className = 'saved-item'; + + const chip = document.createElement('button'); + chip.className = 'saved-chip'; + chip.type = 'button'; + chip.textContent = city.name; + chip.addEventListener('click', () => loadAll(city, 'saved-location')); + + const remove = document.createElement('button'); + remove.className = 'saved-remove'; + remove.type = 'button'; + remove.textContent = 'x'; + remove.addEventListener('click', (e) => { + e.stopPropagation(); + state.savedCities = state.savedCities.filter(c => cityKey(c) !== cityKey(city)); + persistSavedCities(); + renderSavedCities(); + }); + + wrap.appendChild(chip); + wrap.appendChild(remove); + savedLocationsEl.appendChild(wrap); + }); +} + +// ── Tab switching ───────────────────────────────────────────────────────────── +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', () => { + activateTab('radar-full'); +}); + +// ── APIs ───────────────────────────────────────────────────────────────────── +async function geocode(q) { + const r = await fetch(`/api/geocode?q=${encodeURIComponent(q)}`); + if (!r.ok) { + const detail = await r.text().catch(() => ''); + throw new Error(`Geocoding failed (${r.status})${detail ? `: ${detail.slice(0, 180)}` : ''}`); + } + return r.json(); +} + +async function reverseGeocode(lat, lon) { + const r = await fetch(`/api/reverse-geocode?lat=${encodeURIComponent(lat)}&lon=${encodeURIComponent(lon)}`); + if (!r.ok) throw new Error('Reverse geocoding failed'); + 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 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 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(); +} + +async function fetchAlerts(lat, lon) { + const r = await fetch(`/api/alerts?lat=${lat}&lon=${lon}`); + if (!r.ok) throw new Error('Alerts fetch failed'); + return r.json(); +} + +function detectedCityFallback(lat, lon) { + return { + name: `Detected location (${lat.toFixed(2)}, ${lon.toFixed(2)})`, + latitude: lat, + longitude: lon, + country: '', + admin1: '', + timezone: 'auto', + }; +} + +async function buildDetectedCity(lat, lon) { + const fallback = detectedCityFallback(lat, lon); + + try { + const place = await reverseGeocode(lat, lon); + return { + ...fallback, + ...place, + latitude: lat, + longitude: lon, + timezone: place.timezone || 'auto', + }; + } catch { + return fallback; + } +} + +// ── 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, + zoomControl: true, + attributionControl: false, + }); + + L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', { + maxZoom: 19, + }).addTo(map); + + let radarFrames = []; + let satelliteFrames = []; + let mode = 'radar'; + let currentIdx = 0; + let layers = { radar: {}, satellite: {} }; + 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; + } + + function tsLabel(unixSec) { + const d = new Date(unixSec * 1000); + return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) + + ' ' + MONTHS[d.getMonth()] + ' ' + d.getDate(); + } + + function frameTileUrl(frame) { + if (mode === 'satellite') { + return `https://tilecache.rainviewer.com${frame.path}/256/{z}/{x}/{y}/0/0_0.png`; + } + return `https://tilecache.rainviewer.com${frame.path}/256/{z}/{x}/{y}/2/1_1.png`; + } + + function showFrame(idx) { + const frames = activeFrames(); + currentIdx = idx; + const frame = frames[idx]; + if (!frame) return; + + if (!layers[mode][frame.time]) { + 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); + } + + 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) => { + el.classList.toggle('active', i === idx); + }); + } + + function buildTimeline() { + timelineEl.innerHTML = ''; + activeFrames().forEach((f, i) => { + const tick = document.createElement('div'); + tick.className = 'tl-tick' + (f.isForecast ? ' forecast' : ''); + tick.title = tsLabel(f.time); + tick.addEventListener('click', () => { stopPlay(); showFrame(i); }); + timelineEl.appendChild(tick); + }); + } + + function step() { + const frames = activeFrames(); + if (!frames.length) return; + const next = (currentIdx + 1) % frames.length; + showFrame(next); + if (next === 0) { + stopPlay(); + setTimeout(startPlay, 800); + } + } + + function startPlay() { + playing = true; + playBtn.textContent = '⏸'; + animTimer = setInterval(step, FRAME_INTERVAL_MS); + } + + function stopPlay() { + playing = false; + playBtn.textContent = '▶'; + clearInterval(animTimer); + } + + playBtn.addEventListener('click', () => playing ? stopPlay() : startPlay()); + + if (layerBtn) { + layerBtn.addEventListener('click', () => { + 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)); + }); + } + + async function loadFrames() { + if (loadingFrames || framesReady) return; + loadingFrames = true; + let retries = 0; + const maxRetries = 2; + + 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 (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)); + } + } + } finally { + loadingFrames = false; + } + } + + function panTo(lat, lon, zoom) { + map.setView([lat, lon], zoom || map.getZoom()); + if (locationMarker) map.removeLayer(locationMarker); + locationMarker = L.circleMarker([lat, lon], { + radius: 6, + fillColor: '#e07848', + fillOpacity: 1, + color: '#fff', + weight: 2, + }).addTo(map); + } + + 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( + document.getElementById('radar-embed'), + document.getElementById('embed-timeline'), + document.getElementById('embed-play'), + document.getElementById('embed-timestamp'), + document.getElementById('embed-layer') +); + +const radarFull = createRadar( + document.getElementById('radar-full'), + document.getElementById('full-timeline'), + document.getElementById('full-play'), + document.getElementById('full-timestamp'), + document.getElementById('full-layer') +); + +function createOverviewMap(mapEl) { + const map = L.map(mapEl, { + center: [39.5, -98.35], + zoom: 4, + zoomControl: true, + attributionControl: false, + }); + + L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { + maxZoom: 18, + }).addTo(map); + + let marker = null; + + function panTo(lat, lon, zoom) { + map.setView([lat, lon], zoom || map.getZoom()); + if (marker) map.removeLayer(marker); + marker = L.circleMarker([lat, lon], { + radius: 7, + fillColor: '#e07848', + fillOpacity: 1, + color: '#fff', + weight: 2, + }).addTo(map); + } + + return { map, panTo }; +} + +function clearLightningMarkers() { + if (!mapsOverview.map) return; + state.lightningMarkers.forEach(marker => mapsOverview.map.removeLayer(marker)); + state.lightningMarkers = []; +} + +function clearWindArrows() { + if (!mapsOverview.map) return; + state.windArrows.forEach(arrow => mapsOverview.map.removeLayer(arrow)); + state.windArrows = []; +} + +function refreshMapsOverview() { + if (!mapsOverview.map) return; + + mapsOverview.map.invalidateSize(); + + if (!state.currentCity) return; + + mapsOverview.panTo(state.currentCity.latitude, state.currentCity.longitude, 6); + + clearLightningMarkers(); + clearWindArrows(); + + if (state.showLightning) renderLightningStrikes(state.currentCity); + if (state.showWind && state.currentWeather) renderWindArrows(state.currentCity); +} + +function renderLightningStrikes(city) { + if (!mapsOverview.map || !state.showLightning) return; + 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); + } + }, 3000); + } +} + +function renderWindArrows(city) { + if (!mapsOverview.map || !state.showWind || !state.currentWeather) return; + clearWindArrows(); + + // Get current wind data + const current = state.currentWeather.current; + 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; + + // 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 arrowCount = Math.min(Math.ceil(windSpeed / 5), 5); // More arrows = stronger wind + const baseRadius = 0.15; + + 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, + } + ).bindPopup(`💨 Wind: ${speedDisplay(windSpeed)} ${speedUnit()}
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); + } +} + +const mapsOverview = createOverviewMap(document.getElementById('map-overview')); + +// ── Rendering ──────────────────────────────────────────────────────────────── +function renderWeather(data, city) { + const c = data.current; + const h = data.hourly; + const d = data.daily; + + document.getElementById('loc-name').textContent = city.name; + document.getElementById('loc-sub').textContent = [city.admin1, city.country].filter(Boolean).join(', '); + + const now = new Date(); + document.getElementById('loc-time').innerHTML = + `${DAYS[now.getDay()]}, ${MONTHS[now.getMonth()]} ${now.getDate()}
` + + now.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + + const w = wmo(c.weather_code); + document.getElementById('cur-icon').textContent = w.icon; + document.getElementById('cur-desc').textContent = w.desc; + document.getElementById('cur-temp').textContent = tempDisplay(c.temperature_2m); + document.querySelector('.deg').textContent = `°${tempUnit()}`; + document.querySelector('.feels').innerHTML = `${apparentLabel(c.temperature_2m, c.apparent_temperature)} ${tempDisplay(c.apparent_temperature)}°${tempUnit()}`; + document.getElementById('cur-hi').textContent = tempDisplay(d.temperature_2m_max[0]); + 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('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.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; + + const track = document.getElementById('hourly-track'); + track.innerHTML = ''; + for (let i = startIdx; i < Math.min(startIdx + 24, h.time.length); i++) { + const hw = wmo(h.weather_code[i]); + const pp = h.precipitation_probability[i]; + const ptype = precipType(h.weather_code[i], h.temperature_2m[i], h.rain?.[i], h.snowfall?.[i]); + const cell = document.createElement('div'); + cell.className = 'hour-cell' + (i === startIdx ? ' now' : ''); + cell.innerHTML = ` +
${i === startIdx ? 'Now' : formatHour(h.time[i])}
+
${hw.icon}
+
${tempDisplay(h.temperature_2m[i])}°
+
${pp > 0 ? `${pp}% ${ptype}` : ptype}
+ `; + track.appendChild(cell); + } + + state.showExtendedForecast = false; + renderDailyForecast(d); + + document.getElementById('updated-at').textContent = + new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + + radarEmbed.panTo(city.latitude, city.longitude, 6); + radarFull.panTo(city.latitude, city.longitude, 6); + mapsOverview.panTo(city.latitude, city.longitude, 6); + if (mapsWindEl) mapsWindEl.textContent = `${speedDisplay(c.wind_speed_10m)} ${speedUnit()} ${compassDir(c.wind_direction_10m)}`; + updateSourceLocation(city); + updateShareUrl(city); + persistLastCity(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 = ` +
${formatDayShort(daily.time[i])}
+
${dw.icon}
+
${dw.desc}
+
${tempDisplay(daily.temperature_2m_max[i])}° / ${tempDisplay(daily.temperature_2m_min[i])}°
+
${daily.precipitation_probability_max[i] > 0 ? daily.precipitation_probability_max[i] + '% rain
' : ''}${speedDisplay(daily.wind_speed_10m_max[i])} ${speedUnit()}
+ `; + 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) { + const cur = data?.current || {}; + const pollen = ['alder_pollen', 'birch_pollen', 'grass_pollen', 'mugwort_pollen', 'olive_pollen', 'ragweed_pollen'] + .map(k => cur[k] || 0) + .reduce((max, v) => Math.max(max, v), 0); + + document.getElementById('aqi-us').textContent = cur.us_aqi ?? '--'; + document.getElementById('pm25').textContent = cur.pm2_5 != null ? `${Number(cur.pm2_5).toFixed(1)} ug/m3` : '--'; + document.getElementById('pollen').textContent = pollen > 0 ? pollen.toFixed(1) : '--'; +} + +function renderAlerts(data) { + const wrap = document.getElementById('alerts-wrap'); + const list = document.getElementById('alerts-list'); + const features = data?.features || []; + list.innerHTML = ''; + + if (!features.length) { + hide(wrap); + if (alertsEmptyEl) show(alertsEmptyEl); + return; + } + + if (alertsEmptyEl) hide(alertsEmptyEl); + + features.slice(0, 5).forEach(f => { + const p = f.properties || {}; + const item = document.createElement('div'); + item.className = 'alert-item'; + item.innerHTML = `${p.event || 'Alert'}${p.severity || 'Unknown severity'}${p.headline || p.description || ''}`; + list.appendChild(item); + }); + show(wrap); + + if (typeof Notification !== 'undefined' && Notification.permission === 'granted') { + features.forEach(f => { + const id = f.id || f.properties?.id; + if (!id || state.alertIdsSeen.has(id)) return; + state.alertIdsSeen.add(id); + const p = f.properties || {}; + new Notification(`Weather alert: ${p.event || 'Severe weather'}`, { + body: p.headline || p.description || 'An alert was issued for your selected location.', + }); + }); + } +} + +async function loadAll(city, source) { + state.currentCity = city; + if (source) state.currentSource = source; + clearErr(); + hide(suggestEl); + hide(weatherDiv); + show(loadingEl); + + try { + 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), + ]); + + 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(); + + // 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); + } + + 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); + } finally { + hide(loadingEl); + } +} + +function getPosition() { + return new Promise((resolve, reject) => { + if (!navigator.geolocation) { + reject(new Error('Geolocation not supported')); + return; + } + navigator.geolocation.getCurrentPosition(resolve, reject, { + enableHighAccuracy: false, + timeout: 8000, + maximumAge: 10 * 60 * 1000, + }); + }); +} + +async function autoLocateAndLoad() { + try { + const pos = await getPosition(); + const { latitude: lat, longitude: lon } = pos.coords; + const city = await buildDetectedCity(lat, lon); + searchInput.value = city.name; + await loadAll(city, 'auto-detect'); + return true; + } catch { + return false; + } +} + +// ── Search autocomplete ─────────────────────────────────────────────────────── +let debounceTimer = null; + +searchInput.addEventListener('input', () => { + clearTimeout(debounceTimer); + const q = searchInput.value.trim(); + if (q.length < 2) { hide(suggestEl); return; } + + debounceTimer = setTimeout(async () => { + try { + const data = await geocode(q); + const results = data.results || []; + suggestEl.innerHTML = ''; + if (!results.length) { hide(suggestEl); return; } + + results.forEach(r => { + const li = document.createElement('li'); + li.innerHTML = `${r.name}${r.admin1 ? r.admin1 + ', ' : ''}${r.country || ''}`; + li.addEventListener('click', () => { + searchInput.value = r.name; + hide(suggestEl); + loadAll(r, 'search-result'); + }); + suggestEl.appendChild(li); + }); + show(suggestEl); + } catch (err) { + console.warn('Autocomplete geocode failed:', err?.message || err); + hide(suggestEl); + } + }, 300); +}); + +document.addEventListener('click', e => { + if (!e.target.closest('.search-wrap')) hide(suggestEl); +}); + +function doSearch() { + const q = searchInput.value.trim(); + if (!q) { showErr('Please enter a city name.'); return; } + clearErr(); + hide(suggestEl); + + geocode(q).then(data => { + const results = data.results || []; + if (!results.length) { showErr(`No results found for "${q}".`); return; } + loadAll(results[0], 'search-result'); + }).catch(() => showErr('Search failed. Please try again.')); +} + +searchBtn.addEventListener('click', doSearch); +searchInput.addEventListener('keydown', e => { if (e.key === 'Enter') doSearch(); }); + +// ── Geolocation ─────────────────────────────────────────────────────────────── +locateBtn.addEventListener('click', () => { + if (!navigator.geolocation) { showErr('Geolocation not supported.'); return; } + clearErr(); + hide(weatherDiv); + show(loadingEl); + + navigator.geolocation.getCurrentPosition(async pos => { + const { latitude: lat, longitude: lon } = pos.coords; + try { + const city = await buildDetectedCity(lat, lon); + await loadAll(city, 'gps-button'); + } catch { + showErr('Could not fetch weather for your location.'); + } finally { + hide(loadingEl); + } + }, () => { + hide(loadingEl); + showErr('Location access denied.'); + }); +}); + +unitsBtn.addEventListener('click', () => { + state.units = state.units === 'us' ? 'metric' : 'us'; + localStorage.setItem(STORAGE_KEYS.units, state.units); + updateUnitsButton(); + 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); + } + } +}); + +if (unitsBtnSettings) { + unitsBtnSettings.addEventListener('click', () => unitsBtn.click()); +} + +shareBtn.addEventListener('click', async () => { + if (!state.currentCity) return; + try { + await navigator.clipboard.writeText(window.location.href); + shareBtn.textContent = 'Copied link'; + setTimeout(() => { shareBtn.textContent = 'Share location'; }, 1500); + } catch { + showErr('Could not copy URL to clipboard.'); + } +}); + +saveCurrentBtn.addEventListener('click', () => { + if (!state.currentCity) return; + const key = cityKey(state.currentCity); + if (state.savedCities.some(c => cityKey(c) === key)) return; + state.savedCities.push(state.currentCity); + persistSavedCities(); + renderSavedCities(); +}); + +notifyBtn.addEventListener('click', async () => { + if (!('Notification' in window)) { + showErr('Notifications are not supported in this browser.'); + return; + } + if (!window.isSecureContext) { + showErr('Notifications require HTTPS (or localhost).'); + updateNotifyButton(); + return; + } + + try { + const permission = await Notification.requestPermission(); + updateNotifyButton(); + if (permission === 'granted') { + // 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); + } + } else if (permission === 'denied') { + showErr('Notifications are blocked in browser settings for this site.'); + } + } catch { + showErr('Could not request notification permission.'); + updateNotifyButton(); + } +}); + +autoDetectToggle.addEventListener('change', () => { + state.autoDetect = autoDetectToggle.checked; + localStorage.setItem(STORAGE_KEYS.autoDetect, String(state.autoDetect)); +}); + +lightningToggle.addEventListener('change', () => { + state.showLightning = lightningToggle.checked; + localStorage.setItem(STORAGE_KEYS.showLightning, String(state.showLightning)); + if (state.currentCity && mapsOverview.map) { + clearLightningMarkers(); + if (state.showLightning) renderLightningStrikes(state.currentCity); + } +}); + +windToggle.addEventListener('change', () => { + state.showWind = windToggle.checked; + localStorage.setItem(STORAGE_KEYS.showWind, String(state.showWind)); + if (state.currentCity && mapsOverview.map) { + clearWindArrows(); + if (state.showWind && state.currentWeather) renderWindArrows(state.currentCity); + } +}); + +// 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); + } + }); + // 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 (dailyToggleBtn) { + dailyToggleBtn.addEventListener('click', () => { + state.showExtendedForecast = !state.showExtendedForecast; + if (state.currentWeather) { + renderDailyForecast(state.currentWeather.daily); + } + }); +} + +function startAutoRefresh() { + clearInterval(state.autoRefreshTimer); + state.autoRefreshTimer = setInterval(() => { + if (state.currentCity) loadAll(state.currentCity, state.currentSource || 'auto-refresh'); + }, 10 * 60 * 1000); + + // Refresh shortly after users return to the tab if data is stale. + document.addEventListener('visibilitychange', () => { + if (document.visibilityState !== 'visible') return; + if (!state.currentCity) return; + if (Date.now() - state.lastRefreshAt > 10 * 60 * 1000) { + loadAll(state.currentCity, state.currentSource || 'tab-resume-refresh'); + } + }); +} + +// ── Default city / query link load ─────────────────────────────────────────── +(async () => { + await cleanupServiceWorkerInterceptors(); + updateUnitsButton(); + updateNotifyButton(); + syncAutoDetectToggle(); + + // Sync lightning and wind toggle states + lightningToggle.checked = state.showLightning; + windToggle.checked = state.showWind; + + renderSavedCities(); + startAutoRefresh(); + + const qs = new URLSearchParams(window.location.search); + const qLat = Number(qs.get('lat')); + const qLon = Number(qs.get('lon')); + const qName = qs.get('name'); + if (!Number.isNaN(qLat) && !Number.isNaN(qLon) && qs.get('lat') && qs.get('lon')) { + const city = { + name: qName || `${qLat.toFixed(2)}, ${qLon.toFixed(2)}`, + latitude: qLat, + longitude: qLon, + timezone: 'auto', + country: '', + admin1: '' + }; + searchInput.value = city.name; + await loadAll(city, 'shared-link'); + return; + } + + if (state.autoDetect) { + const autoLocated = await autoLocateAndLoad(); + if (autoLocated) return; + } + + const lastCity = getLastCity(); + if (lastCity) { + searchInput.value = lastCity.name; + await loadAll(lastCity, 'last-city'); + return; + } + + try { + const data = await geocode('New York'); + const city = (data.results || [])[0]; + if (city) { + searchInput.value = city.name; + await loadAll(city, 'default-city'); + } + } catch { + // silent startup fallback + } +})(); diff --git a/.tmp/imgdiff/new/index.html b/.tmp/imgdiff/new/index.html new file mode 100644 index 0000000..15d3ffb --- /dev/null +++ b/.tmp/imgdiff/new/index.html @@ -0,0 +1,405 @@ + + + + + +NeCloud Weather + + + + + + + + + + + + +
+ +
+

NeCloud Weather

+ + Open-Meteo · RainViewer +
+ + +
+
+ + + + +
+ + +
+ + + + + +
+ +
+ + + + + + + + + + + + + +
+ + + + + + + + diff --git a/.tmp/imgdiff/new/server.js b/.tmp/imgdiff/new/server.js new file mode 100644 index 0000000..2aa7df2 --- /dev/null +++ b/.tmp/imgdiff/new/server.js @@ -0,0 +1,286 @@ +const express = require('express'); +const path = require('path'); + +const app = express(); +const PORT = process.env.PORT || 3000; +const weatherCache = new Map(); + +app.use(express.static(path.join(__dirname, 'public'))); + +// Prevent browser favicon requests from showing noisy 404s. +app.get('/favicon.ico', (_req, res) => { + res.status(204).end(); +}); + +// Geocoding proxy +app.get('/api/geocode', async (req, res) => { + const { q } = req.query; + if (!q) return res.status(400).json({ error: 'Missing query' }); + try { + const url = `https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(q)}&count=5&language=en&format=json`; + + let openMeteoData = null; + for (let attempt = 1; attempt <= 2; attempt++) { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 9000); + try { + const r = await fetch(url, { signal: controller.signal }); + clearTimeout(timeoutId); + if (!r.ok) throw new Error(`Open-Meteo geocode status ${r.status}`); + openMeteoData = await r.json(); + break; + } catch (err) { + clearTimeout(timeoutId); + if (attempt === 2) { + console.warn('Open-Meteo geocode failed after retries:', err.message); + } else { + await new Promise(resolve => setTimeout(resolve, 300 * attempt)); + } + } + } + + if (openMeteoData?.results?.length) { + return res.json(openMeteoData); + } + + // Fallback to Nominatim when Open-Meteo geocoding is empty/unavailable. + const nomParams = new URLSearchParams({ q: String(q), format: 'jsonv2', limit: '5', addressdetails: '1' }); + const nom = await fetch(`https://nominatim.openstreetmap.org/search?${nomParams}`, { + headers: { + 'User-Agent': 'weather-app-v2 (nate@necloud.us)', + Accept: 'application/json', + }, + }); + + if (!nom.ok) { + throw new Error(`Nominatim geocode status ${nom.status}`); + } + + const nomData = await nom.json(); + const results = (nomData || []).map(item => { + const address = item.address || {}; + return { + name: address.city || address.town || address.village || address.hamlet || address.county || item.display_name?.split(',')?.[0] || 'Unknown', + latitude: Number(item.lat), + longitude: Number(item.lon), + country: address.country || '', + admin1: address.state || address.region || address.county || '', + timezone: 'auto', + }; + }).filter(r => Number.isFinite(r.latitude) && Number.isFinite(r.longitude)); + + return res.json({ results }); + } catch (e) { + res.status(500).json({ error: 'Geocoding failed', detail: e.message }); + } +}); + +// Reverse geocoding proxy +app.get('/api/reverse-geocode', async (req, res) => { + const { lat, lon } = req.query; + if (!lat || !lon) return res.status(400).json({ error: 'Missing lat/lon' }); + + try { + const params = new URLSearchParams({ + format: 'jsonv2', + lat: String(lat), + lon: String(lon), + zoom: '10', + addressdetails: '1', + }); + + const r = await fetch(`https://nominatim.openstreetmap.org/reverse?${params}`, { + headers: { + 'User-Agent': 'weather-app-v2 (nate@necloud.us)', + Accept: 'application/json', + }, + }); + + if (!r.ok) { + throw new Error(`Reverse geocoding failed with status ${r.status}`); + } + + const data = await r.json(); + const address = data.address || {}; + const name = address.city || address.town || address.village || address.hamlet || address.municipality || address.county || address.state || data.name || 'Detected location'; + + res.json({ + name, + admin1: address.state || address.region || address.county || '', + country: address.country || '', + latitude: Number(lat), + longitude: Number(lon), + timezone: 'auto', + display_name: data.display_name || '', + }); + } catch (e) { + res.status(500).json({ error: 'Reverse geocoding failed', detail: e.message }); + } +}); + +// Weather proxy +app.get('/api/weather', async (req, res) => { + const { lat, lon, tz, units } = req.query; + if (!lat || !lon) return res.status(400).json({ error: 'Missing lat/lon' }); + const timezone = tz || 'auto'; + const useMetric = units === 'metric'; + const cacheKey = `${Number(lat).toFixed(3)},${Number(lon).toFixed(3)},${timezone},${units || 'us'}`; + try { + const params = new URLSearchParams({ + latitude: lat, longitude: lon, timezone, + current: [ + 'temperature_2m','relative_humidity_2m','apparent_temperature', + 'is_day','precipitation','weather_code','cloud_cover', + 'wind_speed_10m','wind_direction_10m','wind_gusts_10m','surface_pressure','visibility' + ].join(','), + hourly: [ + 'temperature_2m','precipitation_probability','weather_code', + 'wind_speed_10m','apparent_temperature','rain','snowfall' + ].join(','), + daily: [ + 'weather_code','temperature_2m_max','temperature_2m_min', + 'precipitation_sum','precipitation_probability_max', + 'wind_speed_10m_max','sunrise','sunset','uv_index_max' + ].join(','), + forecast_days: 16, + wind_speed_unit: useMetric ? 'kmh' : 'mph', + temperature_unit: useMetric ? 'celsius' : 'fahrenheit', + precipitation_unit: useMetric ? 'mm' : 'inch' + }); + + let lastError = null; + const upstreamTimeoutMs = 12000; + for (let attempt = 1; attempt <= 3; attempt++) { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), upstreamTimeoutMs); + try { + const r = await fetch(`https://api.open-meteo.com/v1/forecast?${params}`, { signal: controller.signal }); + clearTimeout(timeoutId); + const body = await r.text(); + + if (!r.ok) { + throw new Error(`Open-Meteo status ${r.status}: ${body.slice(0, 200)}`); + } + + const data = JSON.parse(body); + weatherCache.set(cacheKey, { data, at: Date.now() }); + return res.json(data); + } catch (err) { + clearTimeout(timeoutId); + lastError = err; + const reason = err?.name === 'AbortError' ? `timeout after ${upstreamTimeoutMs}ms` : (err?.message || 'unknown error'); + console.warn(`Weather upstream attempt ${attempt}/3 failed: ${reason}`); + if (attempt < 3) { + await new Promise(resolve => setTimeout(resolve, 500 * attempt)); + } + } + } + + throw lastError || new Error('Weather upstream failed'); + } catch (e) { + const cached = weatherCache.get(cacheKey); + if (cached && Date.now() - cached.at < 45 * 60 * 1000) { + res.setHeader('X-Weather-Cache', 'stale-fallback'); + console.warn('Weather proxy using stale cache for', cacheKey); + return res.json(cached.data); + } + console.error('Weather proxy failed:', e.message); + res.status(500).json({ error: 'Weather fetch failed', detail: e.message }); + } +}); + +// Open-Meteo Air Quality proxy +app.get('/api/air-quality', async (req, res) => { + const { lat, lon, tz } = req.query; + if (!lat || !lon) return res.status(400).json({ error: 'Missing lat/lon' }); + try { + const params = new URLSearchParams({ + latitude: lat, + longitude: lon, + timezone: tz || 'auto', + current: ['us_aqi', 'pm2_5', 'alder_pollen', 'birch_pollen', 'grass_pollen', 'mugwort_pollen', 'olive_pollen', 'ragweed_pollen'].join(','), + forecast_days: 1, + }); + let lastError = null; + for (let attempt = 1; attempt <= 2; attempt++) { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 12000); + try { + const r = await fetch(`https://air-quality-api.open-meteo.com/v1/air-quality?${params}`, { + signal: controller.signal, + }); + clearTimeout(timeoutId); + if (!r.ok) { + throw new Error(`Open-Meteo status ${r.status}`); + } + const data = await r.json(); + return res.json(data); + } catch (err) { + clearTimeout(timeoutId); + lastError = err; + if (attempt < 2) { + await new Promise(resolve => setTimeout(resolve, 500)); + } + } + } + + throw lastError || new Error('Air quality upstream failed'); + } catch (e) { + console.error('Air quality fetch error:', e); + res.setHeader('X-Air-Quality-Fallback', 'empty'); + res.json({ current: {} }); + } +}); + +// NWS alerts proxy (US only) +app.get('/api/alerts', async (req, res) => { + const { lat, lon } = req.query; + if (!lat || !lon) return res.status(400).json({ error: 'Missing lat/lon' }); + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 12000); + + const r = await fetch(`https://api.weather.gov/alerts/active?point=${lat},${lon}`, { + headers: { + 'User-Agent': 'weather-app-v2 (nate@necloud.us)', + Accept: 'application/geo+json', + }, + signal: controller.signal, + }); + + clearTimeout(timeoutId); + + if (!r.ok) { + throw new Error(`NWS API returned ${r.status}`); + } + const data = await r.json(); + res.json(data); + } catch (e) { + console.error('Alerts fetch error:', e.message); + res.setHeader('X-Alerts-Fallback', 'empty'); + res.json({ features: [] }); + } +}); + +// RainViewer timestamps proxy +app.get('/api/radar/times', async (req, res) => { + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 25000); + + const r = await fetch('https://api.rainviewer.com/public/weather-maps.json', { + signal: controller.signal, + }); + clearTimeout(timeoutId); + if (!r.ok) { + throw new Error(`RainViewer API returned ${r.status}`); + } + const data = await r.json(); + res.json(data); + } catch (e) { + console.error('Radar times fetch error:', e.message); + res.status(500).json({ error: 'Radar times failed', detail: e.message }); + } +}); + +app.listen(PORT, () => console.log(`Weather app running on port ${PORT}`)); diff --git a/.tmp/imgdiff/new/style.css b/.tmp/imgdiff/new/style.css new file mode 100644 index 0000000..92ea4d9 --- /dev/null +++ b/.tmp/imgdiff/new/style.css @@ -0,0 +1,1304 @@ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +:root { + --ink: #1a1a1a; + --ink-mid: #3d3730; + --ink-light: #5f5649; + --paper: #f5f2ec; + --paper-dark: #ece7db; + --paper-darker: #ddd6c7; + --border: #b9ae9b; + --accent: #c25a2a; + --cool: #2a6b8a; + --mono: 'Segoe UI Rounded', 'Trebuchet MS', 'Avenir Next', 'Segoe UI', sans-serif; + --serif: 'Segoe UI Rounded', 'Trebuchet MS', 'Avenir Next', 'Segoe UI', sans-serif; + --card-bg: var(--paper-dark); + --card-bg-soft: var(--paper-darker); + --card-ink: var(--ink); + --card-sub: var(--ink-light); + --surface-border: 1px solid rgba(255, 255, 255, 0.08); + --surface-radius-lg: 16px; + --surface-radius-md: 12px; + --surface-shadow: 0 8px 18px rgba(0, 0, 0, 0.16); +} + +@media (prefers-color-scheme: dark) { + :root { + --ink: #ece7dc; + --ink-mid: #d2c8b1; + --ink-light: #beb399; + --paper: #191510; + --paper-dark: #201c16; + --paper-darker: #2b2620; + --border: #3a342a; + --accent: #e07848; + --cool: #5ab0d8; + } +} + +html, body { + min-height: 100%; + background: var(--paper); + color: var(--ink); + font-family: var(--mono); + font-size: 16px; + line-height: 1.6; + overflow-x: hidden; +} + +.app { + max-width: 900px; + margin: 0 auto; + padding: 2rem 1.5rem 4rem; +} + +a { color: var(--cool); text-decoration: none; } +a:hover { text-decoration: underline; } + +/* ── Masthead ── */ +.masthead { + display: grid; + grid-template-columns: 1fr; + align-items: center; + justify-items: center; + gap: 0.8rem; + border: var(--surface-border); + border-radius: var(--surface-radius-lg); + background: linear-gradient(180deg, var(--card-bg-soft), var(--paper-dark)); + color: var(--card-ink); + padding: 1rem 1.1rem; + margin-bottom: 1.5rem; + box-shadow: var(--surface-shadow); +} + +.masthead h1 { + font-family: var(--serif); + font-size: 2.35rem; + font-weight: 700; + letter-spacing: -0.5px; + color: var(--card-ink); + white-space: nowrap; + text-align: center; +} + +.tagline { + font-size: 0.74rem; + letter-spacing: 0.12em; + color: var(--card-sub); + text-transform: uppercase; + white-space: nowrap; +} + +/* ── Tabs ── */ +.tabs { + display: flex; + gap: 0; + border: 1px solid var(--border); + border-radius: 10px; + overflow: hidden; +} + +.tab { + background: transparent; + border: none; + border-right: 1px solid var(--border); + padding: 0.4rem 1rem; + font-family: var(--mono); + font-size: 0.84rem; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--ink-mid); + cursor: pointer; + transition: background 0.15s, color 0.15s; +} + +.tab:last-child { border-right: none; } +.tab:hover { background: var(--paper-dark); color: var(--ink); } +.tab.active { background: var(--ink); color: var(--paper); } + +/* ── Search ── */ +.search-wrap { + position: relative; + margin-bottom: 2rem; + background: var(--card-bg); + border: var(--surface-border); + border-radius: var(--surface-radius-md); + padding: 0.55rem; + box-shadow: var(--surface-shadow); +} + +.search-row { display: flex; } + +.search-row input { + flex: 1; + min-width: 0; + background: rgba(0, 0, 0, 0.18); + border: 1px solid rgba(255, 255, 255, 0.14); + border-right: none; + padding: 0.6rem 1rem; + font-family: var(--mono); + font-size: 0.95rem; + color: var(--card-ink); + outline: none; + border-radius: 0; + transition: background 0.15s; +} + +.search-row input::placeholder { color: var(--card-sub); } +.search-row input:focus { background: rgba(0, 0, 0, 0.25); } + +.search-row button { + background: rgba(0, 0, 0, 0.24); + color: var(--card-ink); + border: 1px solid rgba(255, 255, 255, 0.14); + padding: 0.6rem 1.1rem; + font-family: var(--mono); + font-size: 0.84rem; + letter-spacing: 0.08em; + text-transform: uppercase; + cursor: pointer; + border-radius: 0; + transition: opacity 0.15s; + white-space: nowrap; +} + +.search-row button:hover { opacity: 0.72; } + +#locate-btn { + background: rgba(0, 0, 0, 0.22) !important; + color: var(--card-ink) !important; + border-left: none !important; + font-size: 1.2rem !important; + letter-spacing: 0 !important; + padding: 0.6rem 0.9rem !important; +} + +#units-btn { + background: rgba(0, 0, 0, 0.22) !important; + color: var(--card-ink) !important; + border-left: none !important; + min-width: 76px; +} + +.saved-wrap { margin-top: 0.6rem; } +.saved-head { + display: flex; + justify-content: space-between; + align-items: center; + font-size: 0.62rem; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--ink-light); + margin-bottom: 0.35rem; +} + +.saved-locations { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; + font-size: 0.72rem; + color: var(--ink-mid); +} + +/* ── Toggle Switch ── */ +.switch-toggle { + display: inline-flex; + align-items: center; + position: relative; + width: 50px; + height: 28px; + cursor: pointer; + user-select: none; +} + +.switch-toggle input { + display: none; +} + +.toggle-slider { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(255, 255, 255, 0.1); + border: 1px solid var(--surface-border); + border-radius: 14px; + transition: all 0.3s ease; + display: flex; + align-items: center; +} + +.toggle-slider::before { + content: ''; + position: absolute; + width: 22px; + height: 22px; + border-radius: 50%; + background-color: var(--ink-light); + left: 2px; + transition: all 0.3s ease; +} + +.switch-toggle input:checked + .toggle-slider { + background-color: var(--accent); + border-color: var(--accent); +} + +.switch-toggle input:checked + .toggle-slider::before { + left: 26px; + background-color: #fff; +} + +.switch-toggle input:disabled + .toggle-slider { + opacity: 0.5; + cursor: not-allowed; +} + +.auto-detect-toggle { + display: inline-flex; + align-items: center; + gap: 0.45rem; + margin-bottom: 0.45rem; + font-size: 0.68rem; + color: var(--ink-mid); + cursor: pointer; + user-select: none; +} + +.auto-detect-toggle input { + accent-color: var(--accent); +} + +.toggle-label { + display: inline-flex; + align-items: center; + gap: 0.45rem; + margin-bottom: 0.45rem; + font-size: 0.68rem; + color: var(--ink-mid); + cursor: pointer; + user-select: none; +} + +.toggle-label input { + accent-color: var(--accent); +} + +.toggle-label span { + min-width: 24px; +} + +.saved-item { + display: inline-flex; + border: 1px solid var(--border); + background: var(--paper-dark); +} + +.saved-chip, +.saved-remove { + border: none; + background: transparent; + color: var(--ink); + padding: 0.35rem 0.45rem; + font-family: var(--mono); + cursor: pointer; +} + +.saved-remove { + border-left: 1px solid var(--border); + color: var(--ink-light); +} + +/* Suggestions */ +.suggestions { + position: absolute; + top: 100%; + left: 0; right: 0; + background: var(--paper-darker); + border: 1px solid var(--border); + border-top: none; + z-index: 200; + list-style: none; +} + +.suggestions li { + padding: 0.6rem 1rem; + cursor: pointer; + font-size: 0.82rem; + border-bottom: 0.5px solid var(--border); + transition: background 0.1s; +} + +.suggestions li:last-child { border-bottom: none; } +.suggestions li:hover { background: var(--paper-dark); } +.sug-country { color: var(--ink-light); margin-left: 0.5rem; font-size: 0.75rem; } + +/* ── Error / Loading ── */ +.error { font-size: 0.78rem; color: var(--accent); margin-top: 0.5rem; } + +.loading { + display: flex; + align-items: center; + justify-content: center; + min-height: calc(100vh - 200px); + padding: 3rem; +} + +.loading-container { + display: flex; + flex-direction: column; + align-items: center; + gap: 2rem; + text-align: center; +} + +.loading-icon { + font-size: 6rem; + animation: float 2s ease-in-out infinite; +} + +.loading-text { + font-size: 1.5rem; + font-weight: 500; + color: var(--ink); + letter-spacing: 0.05em; +} + +.loading-dots { + display: flex; + gap: 0.5rem; + justify-content: center; +} + +.loading-dots span { + width: 10px; + height: 10px; + border-radius: 50%; + background: var(--accent); + animation: bounce 1.4s ease-in-out infinite; +} + +.loading-dots span:nth-child(1) { animation-delay: 0s; } +.loading-dots span:nth-child(2) { animation-delay: 0.2s; } +.loading-dots span:nth-child(3) { animation-delay: 0.4s; } + +@keyframes float { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-20px); } } +@keyframes bounce { 0%, 100% { transform: translateY(0); opacity: 0.4; } 50% { transform: translateY(-15px); opacity: 1; } } + +.hidden { display: none !important; } + +/* ── Tab panels ── */ +.tab-panel { width: 100%; } + +/* ── Location header ── */ +.loc-header { + display: flex; + justify-content: space-between; + align-items: flex-end; + margin-bottom: 1.5rem; + border: var(--surface-border); + border-radius: var(--surface-radius-lg); + background: var(--card-bg-soft); + padding: 0.9rem 1rem; +} + +.loc-name { font-family: var(--serif); font-size: 1.6rem; font-weight: 400; line-height: 1.1; } +.loc-name { color: var(--card-ink); } +.loc-sub { font-size: 0.8rem; color: var(--card-sub); letter-spacing: 0.06em; margin-top: 0.2rem; text-transform: uppercase; } +.loc-time { font-size: 0.8rem; color: var(--card-sub); text-align: right; letter-spacing: 0.04em; line-height: 1.5; } +.loc-actions { display: flex; flex-direction: column; align-items: flex-end; gap: 0.35rem; } + +/* ── HERO ── */ +.hero { + display: grid; + grid-template-columns: 1fr 1.5fr 1fr; + align-items: center; + gap: 1rem; + border: var(--surface-border); + border-radius: var(--surface-radius-lg); + background: var(--card-bg); + color: var(--card-ink); + padding: 1.4rem 1rem; + margin-bottom: 1.8rem; +} + +.hero-left { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 0.4rem; +} + +.hero-icon { font-size: 3.5rem; line-height: 1; } +.hero-desc { font-size: 0.9rem; font-weight: 500; } +.hero-sub { font-size: 0.82rem; color: var(--card-sub); margin-top: 0.15rem; } + +.hero-center { text-align: center; } + +.hero-temp { + font-family: var(--serif); + font-size: 6.5rem; + font-weight: 400; + line-height: 1; + letter-spacing: -4px; +} + +.deg { font-family: var(--mono); font-size: 2rem; color: var(--card-sub); vertical-align: super; letter-spacing: 0; display: inline; } +.feels { font-size: 0.86rem; color: var(--card-sub); margin-top: 0.5rem; } + +.hero-right { display: flex; justify-content: flex-end; } + +.hero-hilo { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.6rem; + background: rgba(0, 0, 0, 0.2); + border: 1px solid rgba(255, 255, 255, 0.12); + padding: 1rem 1.4rem; + min-width: 80px; +} + +.hilo-item { + display: flex; + flex-direction: column; + align-items: center; + line-height: 1.2; + font-size: 1.3rem; + font-weight: 500; +} + +.hilo-arrow { font-size: 0.75rem; color: var(--ink-light); } +.hilo-label { font-size: 0.55rem; text-transform: uppercase; letter-spacing: 0.1em; color: var(--card-sub); margin-top: 0.15rem; } +.hilo-divider { width: 100%; height: 1px; background: rgba(255, 255, 255, 0.12); } + +/* ── Sun & Moon Timeline ── */ +.sun-section { + border: var(--surface-border); + background: var(--card-bg); + color: var(--card-ink); + border-radius: var(--surface-radius-md); + padding: 1rem 1.1rem; + margin-bottom: 2rem; +} + +.sun-timeline-wrap { + display: flex; + align-items: center; + gap: 0.75rem; + margin-bottom: 0.65rem; +} + +.sun-endpoint { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.2rem; + flex-shrink: 0; +} + +.sun-ep-icon { font-size: 1.6rem; line-height: 1; } +.sun-ep-time { font-size: 0.8rem; font-weight: 600; color: var(--card-sub); white-space: nowrap; } + +.sun-track-wrap { flex: 1; position: relative; } + +.sun-track { + height: 6px; + background: rgba(255, 255, 255, 0.14); + border-radius: 3px; + position: relative; + overflow: visible; +} + +.sun-progress { + height: 100%; + background: linear-gradient(90deg, #fb923c, #fbbf24); + border-radius: 3px; + transition: width 0.6s ease; +} + +.sun-dot { + position: absolute; + top: 50%; + transform: translate(-50%, -50%); + font-size: 1.2rem; + pointer-events: none; + transition: left 0.6s ease; +} + +.moon-row { + display: flex; + align-items: center; + gap: 0.6rem; + font-size: 0.85rem; + font-weight: 500; + color: var(--card-sub); + border-top: 1px solid rgba(255, 255, 255, 0.12); + padding-top: 0.6rem; +} + +.moon-emoji { font-size: 1.2rem; } +.daylight-info { margin-left: auto; font-size: 0.78rem; font-weight: 500; color: var(--card-sub); } + +/* ── Card Group ── */ +.card-group { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); + gap: 1px; + background: rgba(255, 255, 255, 0.08); + border: var(--surface-border); + border-radius: var(--surface-radius-md); + overflow: hidden; + margin-bottom: 2rem; +} + +.stat-card { + background: var(--card-bg); + padding: 1rem 0.9rem; + display: flex; + flex-direction: column; + gap: 0.3rem; +} + +.stat-card .stat-label { + font-size: 0.68rem; + letter-spacing: 0.12em; + text-transform: uppercase; + font-weight: 700; + color: var(--card-sub); +} + +.stat-card .stat-val { + font-size: 1.65rem; + font-weight: 600; + line-height: 1.2; + color: var(--card-ink); +} + +.stat-unit { font-size: 0.85rem; font-weight: 600; color: var(--card-sub); margin-left: 2px; } +.stat-sub { font-size: 0.8rem; font-weight: 500; color: var(--card-sub); } + +/* ── Details Drawer ── */ +.details-drawer { + border: var(--surface-border); + margin-bottom: 2rem; + background: var(--card-bg); + border-radius: var(--surface-radius-md); + overflow: hidden; +} + +.drawer-summary { + list-style: none; + font-size: 0.68rem; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--card-sub); + padding: 0.75rem 1rem; + cursor: pointer; + display: flex; + align-items: center; + justify-content: space-between; + user-select: none; +} + +.drawer-summary::-webkit-details-marker { display: none; } +.drawer-summary:focus { outline: none; } + +.drawer-chevron { + font-size: 1.1rem; + transition: transform 0.2s; + display: inline-block; +} + +details[open] .drawer-chevron { transform: rotate(90deg); } + +.drawer-body { border-top: 1px solid rgba(255, 255, 255, 0.1); } +.drawer-card-group { margin-bottom: 0; border: none; } + +/* ── What to Bring ── */ +.recs-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(90px, 1fr)); + gap: 0.5rem; + margin-bottom: 2rem; +} + +.rec-item { + background: var(--card-bg); + border: var(--surface-border); + border-radius: var(--surface-radius-md); + padding: 0.85rem 0.6rem; + text-align: center; + display: flex; + flex-direction: column; + align-items: center; + gap: 0.4rem; +} + +.rec-icon { font-size: 2rem; line-height: 1; } +.rec-label { font-size: 0.72rem; font-weight: 600; color: var(--card-sub); letter-spacing: 0.04em; line-height: 1.3; } + +.alerts-wrap { + border: var(--surface-border); + border-radius: var(--surface-radius-md); + padding: 0.75rem; + margin-bottom: 1.2rem; + background: var(--card-bg); +} + +.alerts-list { + display: grid; + gap: 0.5rem; +} + +.alert-item { + display: grid; + gap: 0.2rem; + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 10px; + padding: 0.55rem; + background: var(--card-bg-soft); + font-size: 0.72rem; +} + +.panel-note { + font-size: 0.72rem; + color: var(--ink-light); + border: var(--surface-border); + border-radius: var(--surface-radius-md); + padding: 0.75rem; + background: var(--card-bg); +} + +.settings-wrap { + border: var(--surface-border); + border-radius: var(--surface-radius-md); + background: var(--card-bg); + padding: 0.9rem; + display: grid; + gap: 1rem; +} + +.settings-row { + display: flex; + align-items: center; + justify-content: space-between; + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 10px; + background: var(--card-bg-soft); + padding: 0.65rem 0.75rem; +} + +.settings-label { + font-size: 0.72rem; + color: var(--ink-mid); + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.settings-value { + font-size: 0.8rem; + color: var(--ink); + text-align: right; +} + +.settings-btn { + border: 1px solid rgba(255, 255, 255, 0.14); + border-radius: 10px; + background: var(--card-bg-soft); + color: var(--ink); + padding: 0.45rem 0.7rem; + font-family: var(--mono); + font-size: 0.72rem; + cursor: pointer; +} + +.settings-btn:hover { background: var(--card-bg); } + +.maps-wrap { + border: var(--surface-border); + border-radius: var(--surface-radius-md); + background: var(--card-bg); + padding: 1rem; + display: flex; + flex-direction: column; + gap: 1rem; +} + +.maps-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 0.5rem; +} + +.maps-header h3 { + margin: 0; + font-size: 1.1rem; + font-weight: 600; + color: var(--card-ink); +} + +.maps-quick-toggles { + display: flex; + gap: 0.5rem; +} + +.overlay-toggle { + padding: 0.5rem 1rem; + border: 1px solid var(--surface-border); + border-radius: var(--surface-radius-sm); + background: var(--card-bg-soft); + color: var(--card-sub); + font-size: 0.85rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; +} + +.overlay-toggle:hover { + background: var(--card-bg); + border-color: var(--accent); +} + +.overlay-toggle.active { + background: var(--accent); + color: #fff; + border-color: var(--accent); +} + +.map-overview { + height: 360px; + border: var(--surface-border); + border-radius: 10px; + box-shadow: inset 0 2px 8px rgba(0, 0, 0, 0.2); +} + +.maps-legend { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1rem; + padding: 0.75rem; + background: rgba(255, 255, 255, 0.04); + border: var(--surface-border); + border-radius: 8px; +} + +.legend-section { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.legend-title { + font-size: 0.8rem; + font-weight: 600; + color: var(--card-ink); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.legend-item { + display: flex; + align-items: center; + gap: 0.6rem; + font-size: 0.75rem; + color: var(--card-sub); +} + +.legend-item svg { + flex-shrink: 0; +} + +.maps-data { + display: grid; + grid-template-columns: 1fr 1.2fr; + gap: 1rem; +} + +.maps-card { + background: var(--card-bg-soft); + border: var(--surface-border); + border-radius: var(--surface-radius-sm); + padding: 1rem; + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.card-title { + font-size: 0.8rem; + font-weight: 600; + color: var(--card-sub); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.maps-value { + font-size: 1rem; + font-weight: 500; + color: var(--card-ink); +} + +.wind-stats { + display: grid; + grid-template-columns: 1fr 1fr 1fr; + gap: 0.75rem; +} + +.wind-stat { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.wind-stat .stat-label { + font-size: 0.7rem; + color: var(--card-sub); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.wind-stat .stat-value { + font-size: 1.1rem; + font-weight: 600; + color: var(--accent); +} + +.stat-label { font-size: 0.58rem; letter-spacing: 0.1em; color: var(--ink-light); text-transform: uppercase; margin-bottom: 0.3rem; } + +/* ── Section heads ── */ +.section-head { + display: flex; + justify-content: space-between; + align-items: baseline; + margin-bottom: 0.6rem; +} + +.section-label { font-size: 0.74rem; font-weight: 800; letter-spacing: 0.14em; color: var(--ink); text-transform: uppercase; } +.section-note { font-size: 0.72rem; color: var(--card-sub); font-style: italic; } + +.link-btn { + background: none; + border: none; + font-family: var(--mono); + font-size: 0.82rem; + font-weight: 600; + color: var(--cool); + cursor: pointer; + padding: 0.35rem 0.65rem; + font-style: italic; +} + +.link-btn:hover { text-decoration: underline; } + +/* ── Radar maps ── */ +.radar-embed-wrap { + border: var(--surface-border); + border-radius: var(--surface-radius-md); + overflow: hidden; + margin-bottom: 2rem; + background: #1a1f2e; +} + +.radar-map { + width: 100%; + height: 320px; +} + +.radar-map-full { + height: calc(100vh - 280px); + min-height: 420px; +} + +.radar-full-wrap { + border: var(--surface-border); + border-radius: var(--surface-radius-md); + background: #1a1f2e; + margin-bottom: 0.5rem; + overflow: hidden; +} + +/* ── Radar controls ── */ +.radar-controls { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.6rem 0.75rem; + background: var(--paper-darker); + border-top: 1px solid var(--border); +} + +.radar-controls-full { padding: 0.75rem 1rem; } + +.radar-btn { + background: var(--ink); + color: var(--paper); + border: none; + width: 28px; height: 28px; + display: flex; align-items: center; justify-content: center; + font-size: 0.7rem; + cursor: pointer; + flex-shrink: 0; + transition: opacity 0.15s; +} + +.radar-btn:hover { opacity: 0.72; } +.layer-btn { background: var(--accent); } + +.radar-timeline { + flex: 1; + display: flex; + gap: 2px; + align-items: center; + height: 28px; +} + +.tl-tick { + flex: 1; + height: 100%; + background: var(--paper-dark); + border: 1px solid var(--border); + cursor: pointer; + transition: background 0.1s; + position: relative; +} + +.tl-tick:hover { background: var(--paper); } +.tl-tick.active { background: var(--cool); border-color: var(--cool); } +.tl-tick.forecast { opacity: 0.55; } + +.radar-ts { + font-size: 0.74rem; + color: var(--ink); + font-weight: 600; + letter-spacing: 0.04em; + white-space: nowrap; + min-width: 80px; + text-align: right; +} + +/* Radar legend */ +.radar-legend { + display: flex; + align-items: center; + gap: 0.4rem; + margin-left: 0.5rem; +} + +.legend-label { font-size: 0.66rem; color: var(--ink-mid); letter-spacing: 0.06em; } + +.legend-bar { + width: 80px; + height: 8px; + background: linear-gradient(to right, + #00d8ff, #00a0e0, #0070d0, + #00c800, #80e000, #ffff00, + #ffc000, #ff6000, #ff0000, #c00000 + ); + border: 0.5px solid var(--border); +} + +.radar-attribution { + font-size: 0.62rem; + color: var(--ink-light); + padding-top: 0.5rem; +} + +/* ── Hourly scroll ── */ +.hourly-scroll { + overflow-x: auto; + margin-bottom: 2rem; + border: var(--surface-border); + border-radius: var(--surface-radius-md); + scrollbar-width: thin; + scrollbar-color: var(--border) transparent; +} + +.hourly-track { display: flex; min-width: max-content; } + +.hour-cell { + min-width: 72px; + padding: 0.75rem 0.5rem; + text-align: center; + border-right: 0.5px solid var(--border); + background: var(--card-bg); + transition: background 0.12s; +} + +.hour-cell:last-child { border-right: none; } +.hour-cell:hover { background: var(--paper-dark); } +.hour-cell.now { background: var(--paper-darker); } + +.hour-time { font-size: 0.6rem; color: var(--card-sub); letter-spacing: 0.06em; margin-bottom: 0.35rem; text-transform: uppercase; } +.hour-icon { font-size: 1.4rem; margin-bottom: 0.3rem; } +.hour-temp { font-size: 0.88rem; font-weight: 500; color: var(--card-ink); } +.hour-precip { font-size: 0.64rem; color: #1f5f7e; margin-top: 0.2rem; font-weight: 600; } + +/* ── Daily forecast ── */ +.daily-list { + border: none; + display: grid; + gap: 0.55rem; + margin-bottom: 2rem; +} + +.day-row { + display: grid; + grid-template-columns: 120px 44px 1fr 80px 60px; + align-items: center; + gap: 1rem; + padding: 0.85rem 1.1rem; + border: var(--surface-border); + border-radius: var(--surface-radius-md); + background: var(--card-bg); + transition: background 0.12s; +} + +.day-row:last-child { border-bottom: var(--surface-border); } +.day-row:hover { background: var(--paper-dark); } +.day-row.today { background: var(--paper-dark); } + +.day-name { font-size: 0.8rem; font-weight: 500; color: var(--card-ink); } +.day-icon { font-size: 1.5rem; text-align: center; } +.day-desc { font-size: 0.75rem; color: var(--ink-mid); } +.day-temps { text-align: right; font-size: 0.82rem; font-weight: 500; white-space: nowrap; color: var(--card-ink); } +.day-lo { color: var(--ink-mid); } +.day-meta { text-align: right; font-size: 0.62rem; color: var(--ink-mid); line-height: 1.6; } + +/* ── Footer ── */ +.footer-note { + font-size: 0.72rem; + color: var(--ink-light); + letter-spacing: 0.04em; + border-top: 0.5px solid var(--border); + padding-top: 0.75rem; +} + +/* ── PWA: safe area insets (iPhone notch / home bar) ── */ +@supports (padding: env(safe-area-inset-bottom)) { + .app { + padding-top: max(2rem, calc(1rem + env(safe-area-inset-top))); + padding-left: max(1.5rem, calc(1rem + env(safe-area-inset-left))); + padding-right: max(1.5rem, calc(1rem + env(safe-area-inset-right))); + padding-bottom: max(4rem, calc(2rem + env(safe-area-inset-bottom))); + } + .radar-map-full { + height: calc(100vh - 280px - env(safe-area-inset-top) - env(safe-area-inset-bottom)); + } +} + +/* ── PWA: status bar area when running standalone ── */ +@media (display-mode: standalone) { + .masthead { padding-top: max(1rem, calc(0.75rem + env(safe-area-inset-top))); } +} + +/* ── Touch-friendly tap targets (min 44 × 44 px) ── */ +.tab { + min-height: 44px; + display: inline-flex; + align-items: center; + justify-content: center; +} +.search-row input, +.search-row button { min-height: 44px; } +.radar-btn { width: 44px; height: 44px; font-size: 0.8rem; } +.tl-tick { min-height: 36px; } + +/* iOS momentum scrolling */ +.hourly-scroll { -webkit-overflow-scrolling: touch; } + +/* ── Leaflet overrides ── */ +.leaflet-container { background: #1a1f2e; } +.leaflet-tile { transition: opacity 0.24s linear; } +.leaflet-control-zoom a { + background: var(--paper-dark) !important; + color: var(--ink) !important; + border-color: var(--border) !important; +} + +/* ── Mobile Bottom Navigation ── */ +.bottom-nav { + display: none; + position: fixed; + left: 0; + right: 0; + bottom: 0; + width: 100vw; + z-index: 300; + border-top: 1px solid rgba(255, 255, 255, 0.08); + background: rgba(25, 21, 16, 0.96); + backdrop-filter: blur(6px); + padding-bottom: env(safe-area-inset-bottom, 0px); +} + +.bottom-tab { + flex: 1; + min-height: 56px; + border: none; + border-right: 1px solid rgba(255, 255, 255, 0.08); + background: transparent; + color: #efe8dc; + font-family: var(--mono); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.15rem; + cursor: pointer; +} + +.bottom-tab:last-child { border-right: none; } +.bottom-icon { font-size: 1rem; line-height: 1; } +.bottom-label { font-size: 0.58rem; letter-spacing: 0.05em; text-transform: uppercase; } + +.bottom-tab.active { + background: var(--accent); + color: #fff; +} + +/* ── Responsive ── */ +@media (max-width: 640px) { + html, body { font-size: 16px; } + .app { padding: max(1.2rem, calc(0.8rem + env(safe-area-inset-top, 0px))) 0.9rem max(5.25rem, calc(4.5rem + env(safe-area-inset-bottom, 0px))); } + .masthead { + grid-template-columns: 1fr; + grid-template-rows: auto; + gap: 0.35rem; + padding: 0.75rem 0.9rem; + } + .masthead h1 { font-size: 2.2rem; line-height: 1.08; } + .tagline { display: none; } + .tabs { display: none; } + .bottom-nav { display: flex; } + + #tab-radar-full { + padding-bottom: max(6.4rem, calc(5.7rem + env(safe-area-inset-bottom, 0px))); + } + + .radar-controls-full { + display: grid; + grid-template-columns: auto auto minmax(0, 1fr); + grid-template-areas: + 'play layer ts' + 'timeline timeline timeline'; + gap: 0.45rem 0.5rem; + padding: 0.55rem 0.65rem; + align-items: center; + } + + .radar-controls-full .play-btn { grid-area: play; } + .radar-controls-full .layer-btn { grid-area: layer; } + .radar-controls-full .radar-ts { + grid-area: ts; + min-width: 0; + text-align: right; + font-size: 0.66rem; + } + + .radar-controls-full .radar-timeline { + grid-area: timeline; + width: 100%; + height: 24px; + } + + .radar-controls-full .radar-legend { display: none; } + .radar-attribution { + overflow-wrap: anywhere; + padding-bottom: 0.3rem; + } + + .tab { min-width: 88px; } + .search-wrap { padding: 0.45rem; } + .search-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto auto auto; + gap: 0; + } + .search-row input { + font-size: 0.84rem; + padding: 0.58rem 0.68rem; + } + .search-row button { + font-size: 0.64rem; + padding: 0.58rem 0.62rem; + letter-spacing: 0.04em; + } + #locate-btn { font-size: 1rem !important; padding: 0.58rem 0.55rem !important; } + #units-btn { min-width: 56px; } + + .loc-header { + flex-direction: column; + align-items: center; + text-align: center; + gap: 0.45rem; + } + .loc-name { font-size: 2rem; } + .loc-sub { font-size: 0.88rem; } + .loc-actions { align-items: center; margin-top: 0; } + .loc-time { text-align: center; font-size: 0.88rem; } + + .hero { grid-template-columns: 1fr; text-align: center; } + .hero-left { align-items: center; } + .hero-right { justify-content: center; } + .hero-temp { font-size: 5.4rem; } + .hero-desc { font-size: 1.55rem; } + .hero-sub { font-size: 0.92rem; } + .feels { font-size: 0.95rem; } + .hilo-item { font-size: 1.35rem; } + .hilo-label { font-size: 0.78rem; } + + .section-label { font-size: 0.76rem; font-weight: 800; } + .section-note { font-size: 0.78rem; color: var(--ink-mid); } + .stat-card .stat-label { font-size: 0.75rem; font-weight: 700; } + .stat-card .stat-val { font-size: 1.8rem; font-weight: 600; } + .stat-unit { font-size: 0.95rem; font-weight: 600; } + .stat-sub { font-size: 0.95rem; font-weight: 600; } + + /* Sun & moon section */ + .sun-ep-icon { font-size: 1.8rem; } + .sun-ep-time { font-size: 0.9rem; font-weight: 600; } + .moon-row { font-size: 0.95rem; font-weight: 500; } + .moon-emoji { font-size: 1.4rem; } + .daylight-info { font-size: 0.88rem; font-weight: 500; } + + /* What to bring */ + .rec-item { padding: 0.95rem 0.68rem; } + .rec-icon { font-size: 2.2rem; } + .rec-label { font-size: 0.82rem; font-weight: 600; } + + /* Hourly readability */ + .hour-cell { min-width: 88px; padding: 0.8rem 0.55rem; } + .hour-time { font-size: 0.74rem; } + .hour-icon { font-size: 1.6rem; } + .hour-temp { font-size: 1.08rem; } + .hour-precip { font-size: 0.78rem; } + + /* 7-day readability */ + .day-row { padding: 0.95rem 0.95rem; } + .day-name { font-size: 0.98rem; } + .day-icon { font-size: 1.35rem; } + .day-desc { font-size: 0.9rem; } + .day-temps { font-size: 0.95rem; } + + /* Bottom nav readability */ + .bottom-tab { min-height: 62px; gap: 0.2rem; } + .bottom-icon { font-size: 1.2rem; } + .bottom-label { font-size: 0.68rem; letter-spacing: 0.04em; } + + .hero-hilo { flex-direction: row; gap: 1.5rem; padding: 0.75rem 1.2rem; } + .hilo-divider { width: 1px; height: 2rem; } + .card-group { grid-template-columns: repeat(2, 1fr); } + .recs-grid { grid-template-columns: repeat(3, 1fr); } + .day-row { grid-template-columns: 80px 36px 1fr 70px; } + .day-meta { display: none; } + .radar-map { height: 240px; } + .legend-bar { width: 50px; } + .settings-row { flex-direction: column; align-items: flex-start; gap: 0.5rem; } + .settings-value { text-align: left; } + .map-overview { height: 280px; } + .maps-data { grid-template-columns: 1fr; } + .maps-legend { grid-template-columns: 1fr; } + .wind-stats { grid-template-columns: 1fr 1fr; } +} diff --git a/.tmp/imgdiff/new/sw.js b/.tmp/imgdiff/new/sw.js new file mode 100644 index 0000000..664dffd --- /dev/null +++ b/.tmp/imgdiff/new/sw.js @@ -0,0 +1,19 @@ +'use strict'; + +// Disable offline caching so UI updates are always immediate. +self.addEventListener('install', event => { + event.waitUntil(self.skipWaiting()); +}); + +self.addEventListener('activate', event => { + event.waitUntil((async () => { + const keys = await caches.keys(); + await Promise.all(keys.map(k => caches.delete(k))); + await self.clients.claim(); + await self.registration.unregister(); + })()); +}); + +self.addEventListener('fetch', () => { + // Intentionally no fetch interception. +}); \ No newline at end of file diff --git a/.tmp/imgdiff/old/app.js b/.tmp/imgdiff/old/app.js new file mode 100644 index 0000000..ab383c1 --- /dev/null +++ b/.tmp/imgdiff/old/app.js @@ -0,0 +1,1569 @@ +'use strict'; + +// ── WMO codes ──────────────────────────────────────────────────────────────── +const WMO = { + 0:{desc:'Clear sky',icon:'☀️'},1:{desc:'Mainly clear',icon:'🌤️'}, + 2:{desc:'Partly cloudy',icon:'⛅'},3:{desc:'Overcast',icon:'☁️'}, + 45:{desc:'Foggy',icon:'🌫️'},48:{desc:'Icy fog',icon:'🌫️'}, + 51:{desc:'Light drizzle',icon:'🌦️'},53:{desc:'Drizzle',icon:'🌦️'}, + 55:{desc:'Heavy drizzle',icon:'🌧️'},56:{desc:'Freezing drizzle',icon:'🌨️'}, + 57:{desc:'Heavy freezing drizzle',icon:'🌨️'}, + 61:{desc:'Light rain',icon:'🌧️'},63:{desc:'Rain',icon:'🌧️'}, + 65:{desc:'Heavy rain',icon:'🌧️'},66:{desc:'Freezing rain',icon:'🌨️'}, + 67:{desc:'Heavy freezing rain',icon:'🌨️'}, + 71:{desc:'Light snow',icon:'🌨️'},73:{desc:'Snow',icon:'❄️'}, + 75:{desc:'Heavy snow',icon:'❄️'},77:{desc:'Snow grains',icon:'🌨️'}, + 80:{desc:'Light showers',icon:'🌦️'},81:{desc:'Showers',icon:'🌧️'}, + 82:{desc:'Heavy showers',icon:'⛈️'},85:{desc:'Snow showers',icon:'🌨️'}, + 86:{desc:'Heavy snow showers',icon:'❄️'}, + 95:{desc:'Thunderstorm',icon:'⛈️'},96:{desc:'Thunderstorm w/ hail',icon:'⛈️'}, + 99:{desc:'Severe thunderstorm',icon:'⛈️'}, +}; + +function wmo(code) { return WMO[code] || { desc: 'Unknown', icon: '🌡️' }; } + +const DAYS = ['Sunday','Monday','Tuesday','Wednesday','Thursday','Friday','Saturday']; +const MONTHS = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']; +const STORAGE_KEYS = { + lastCity: 'weather.lastCity', + savedCities: 'weather.savedCities', + units: 'weather.units', + 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)), + alertIdsSeen: new Set(), + autoRefreshTimer: null, + lastRefreshAt: 0, + 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'); +const locateBtn = document.getElementById('locate-btn'); +const suggestEl = document.getElementById('suggestions'); +const errorMsg = document.getElementById('error-msg'); +const loadingEl = document.getElementById('loading'); +const weatherDiv = document.getElementById('weather'); +const unitsBtn = document.getElementById('units-btn'); +const unitsBtnSettings = document.getElementById('units-btn-settings'); +const shareBtn = document.getElementById('share-btn'); +const saveCurrentBtn = document.getElementById('save-current-btn'); +const savedLocationsEl = document.getElementById('saved-locations'); +const notifyBtn = document.getElementById('notify-btn'); +const autoDetectToggle = document.getElementById('auto-detect-toggle'); +const alertsEmptyEl = document.getElementById('alerts-empty'); +const settingsLocationEl = document.getElementById('settings-location'); +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 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'); +const hide = el => el.classList.add('hidden'); +const showErr = msg => { errorMsg.textContent = msg; show(errorMsg); }; +const clearErr = () => { errorMsg.textContent = ''; hide(errorMsg); }; + +function compassDir(deg) { + return ['N','NE','E','SE','S','SW','W','NW'][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`; +} + +function formatDayShort(iso) { + const d = new Date(iso + 'T00:00:00'); + const today = new Date(); + const tomorrow = new Date(today); + tomorrow.setDate(today.getDate() + 1); + if (d.toDateString() === today.toDateString()) return 'Today'; + if (d.toDateString() === tomorrow.toDateString()) return 'Tomorrow'; + return DAYS[d.getDay()]; +} + +function formatClock(iso) { + return new Date(iso).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); +} + +function tempDisplay(v) { + if (v == null || Number.isNaN(v)) return '--'; + return Math.round(v); +} + +function speedDisplay(v) { + if (v == null || Number.isNaN(v)) return '--'; + return Math.round(v); +} + +function precipDisplay(v) { + if (v == null || Number.isNaN(v)) return '--'; + return Number(v).toFixed(1); +} + +function visDisplay(vMeters) { + if (vMeters == null || Number.isNaN(vMeters)) return '--'; + const miles = vMeters / 1609; + if (state.units === 'us') return miles.toFixed(1); + return (miles * 1.60934).toFixed(1); +} + +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; + if (unitsBtnSettings) unitsBtnSettings.textContent = label; +} + +function syncAutoDetectToggle() { + autoDetectToggle.checked = state.autoDetect; +} + +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'; + } 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) { + const date = new Date(dateLike); + const synodic = 29.53058867; + const knownNewMoon = new Date('2000-01-06T18:14:00Z'); + const days = (date - knownNewMoon) / 86400000; + const phase = ((days % synodic) + synodic) % synodic; + const index = Math.floor((phase / synodic) * 8) % 8; + return ['New', 'Waxing Crescent', 'First Quarter', 'Waxing Gibbous', 'Full', 'Waning Gibbous', 'Last Quarter', 'Waning Crescent'][index]; +} + +function precipType(code, tempF, rain, snowfall) { + if ((snowfall ?? 0) > 0) return 'Snow'; + if ([56,57,66,67].includes(code) || ((rain ?? 0) > 0 && tempF <= 32)) return 'Sleet'; + if ((rain ?? 0) > 0 || [51,53,55,61,63,65,80,81,82].includes(code)) return 'Rain'; + return 'Dry'; +} + +function apparentLabel(tempF, apparentF) { + if (tempF <= 50 && apparentF <= tempF - 2) return 'Wind chill'; + if (tempF >= 80 && apparentF >= tempF + 2) return 'Heat index'; + return 'Feels like'; +} + +function cityKey(city) { + return `${Number(city.latitude).toFixed(3)},${Number(city.longitude).toFixed(3)}`; +} + +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) { + localStorage.setItem(STORAGE_KEYS.lastCity, JSON.stringify(city)); +} + +function getLastCity() { + try { return JSON.parse(localStorage.getItem(STORAGE_KEYS.lastCity) || 'null'); } + catch { return null; } +} + +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); + params.set('lat', city.latitude); + params.set('lon', city.longitude); + params.set('name', city.name || 'Location'); + history.replaceState(null, '', `${window.location.pathname}?${params.toString()}`); +} + +function updateSourceLocation(city) { + const label = cityLabel(city) || (city ? `${Number(city.latitude).toFixed(2)}, ${Number(city.longitude).toFixed(2)}` : '--'); + if (settingsLocationEl) settingsLocationEl.textContent = label; + if (mapsLocationEl) mapsLocationEl.textContent = label; +} + +function renderSavedCities() { + savedLocationsEl.innerHTML = ''; + if (!state.savedCities.length) { + savedLocationsEl.textContent = 'None yet'; + updateDefaultCityLabel(); + return; + } + + state.savedCities.forEach((entry, index) => { + const city = entry.city; + const wrap = document.createElement('div'); + wrap.className = 'saved-item'; + + const chip = document.createElement('button'); + 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)); + + const remove = document.createElement('button'); + remove.className = 'saved-remove'; + remove.type = 'button'; + remove.textContent = 'x'; + remove.addEventListener('click', (e) => { + e.stopPropagation(); + 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 ───────────────────────────────────────────────────────────── +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); + } + }); +}); + +document.getElementById('open-radar-tab').addEventListener('click', () => { + document.querySelector('.tab[data-tab="radar-full"]').click(); +}); + +// ── APIs ───────────────────────────────────────────────────────────────────── +async function geocode(q) { + const r = await fetch(`/api/geocode?q=${encodeURIComponent(q)}`); + if (!r.ok) throw new Error('Geocoding failed'); + return r.json(); +} + +async function reverseGeocode(lat, lon) { + const r = await fetch(`/api/reverse-geocode?lat=${encodeURIComponent(lat)}&lon=${encodeURIComponent(lon)}`); + if (!r.ok) throw new Error('Reverse geocoding failed'); + return r.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(); +} + +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'); + return r.json(); +} + +async function fetchAlerts(lat, lon) { + const r = await fetch(`/api/alerts?lat=${lat}&lon=${lon}`); + if (!r.ok) throw new Error('Alerts fetch failed'); + 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)})`, + latitude: lat, + longitude: lon, + country: '', + admin1: '', + timezone: 'auto', + }; +} + +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); + + try { + const place = await reverseGeocode(lat, lon); + return { + ...fallback, + ...place, + name: resolveDetectedName(place, fallback), + latitude: lat, + longitude: lon, + timezone: place.timezone || 'auto', + }; + } catch { + return fallback; + } +} + +// ── Radar engine ────────────────────────────────────────────────────────────── +function createRadar(mapEl, timelineEl, playBtn, tsEl, layerBtn) { + const map = L.map(mapEl, { + center: [39.5, -98.35], + zoom: 4, + zoomControl: true, + attributionControl: false, + }); + + L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', { + maxZoom: 19, + }).addTo(map); + + let radarFrames = []; + let satelliteFrames = []; + let mode = 'radar'; + let currentIdx = 0; + let layers = { radar: {}, satellite: {} }; + let playing = false; + let animTimer = null; + let locationMarker = null; + + function activeFrames() { + return mode === 'radar' ? radarFrames : satelliteFrames; + } + + function tsLabel(unixSec) { + const d = new Date(unixSec * 1000); + return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) + + ' ' + MONTHS[d.getMonth()] + ' ' + d.getDate(); + } + + function frameTileUrl(frame) { + if (mode === 'satellite') { + return `https://tilecache.rainviewer.com${frame.path}/256/{z}/{x}/{y}/0/0_0.png`; + } + return `https://tilecache.rainviewer.com${frame.path}/256/{z}/{x}/{y}/2/1_1.png`; + } + + function showFrame(idx) { + const frames = activeFrames(); + currentIdx = idx; + const frame = frames[idx]; + if (!frame) return; + + if (!layers[mode][frame.time]) { + layers[mode][frame.time] = L.tileLayer(frameTileUrl(frame), { opacity: 0.75, maxZoom: 15 }); + } + + 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); + + tsEl.textContent = tsLabel(frame.time); + document.querySelectorAll(`#${timelineEl.id} .tl-tick`).forEach((el, i) => { + el.classList.toggle('active', i === idx); + }); + } + + function buildTimeline() { + timelineEl.innerHTML = ''; + activeFrames().forEach((f, i) => { + const tick = document.createElement('div'); + tick.className = 'tl-tick' + (f.isForecast ? ' forecast' : ''); + tick.title = tsLabel(f.time); + tick.addEventListener('click', () => { stopPlay(); showFrame(i); }); + timelineEl.appendChild(tick); + }); + } + + function step() { + const frames = activeFrames(); + if (!frames.length) return; + const next = (currentIdx + 1) % frames.length; + showFrame(next); + if (next === 0) { + stopPlay(); + setTimeout(startPlay, 800); + } + } + + function startPlay() { + playing = true; + playBtn.textContent = '⏸'; + animTimer = setInterval(step, 500); + } + + function stopPlay() { + playing = false; + playBtn.textContent = '▶'; + clearInterval(animTimer); + } + + playBtn.addEventListener('click', () => playing ? stopPlay() : startPlay()); + + if (layerBtn) { + layerBtn.addEventListener('click', () => { + if (!(radarFrames.length && satelliteFrames.length)) return; + mode = mode === 'radar' ? 'satellite' : 'radar'; + layerBtn.textContent = mode === 'radar' ? 'R' : 'S'; + buildTimeline(); + const idx = Math.min(currentIdx, activeFrames().length - 1); + showFrame(Math.max(idx, 0)); + }); + } + + async function loadFrames() { + try { + const r = await fetch('/api/radar/times'); + 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) return; + + if (layerBtn) { + layerBtn.disabled = !(radarFrames.length && satelliteFrames.length); + layerBtn.textContent = mode === 'radar' ? 'R' : 'S'; + } + + buildTimeline(); + const frames = activeFrames(); + showFrame(frames.length - 1); + } catch (e) { + console.error('Radar load failed', e); + } + } + + function panTo(lat, lon, zoom) { + map.setView([lat, lon], zoom || map.getZoom()); + if (locationMarker) map.removeLayer(locationMarker); + locationMarker = L.circleMarker([lat, lon], { + radius: 6, + fillColor: '#e07848', + fillOpacity: 1, + color: '#fff', + weight: 2, + }).addTo(map); + } + + loadFrames(); + return { map, panTo, startPlay, stopPlay }; +} + +const radarEmbed = createRadar( + document.getElementById('radar-embed'), + document.getElementById('embed-timeline'), + document.getElementById('embed-play'), + document.getElementById('embed-timestamp'), + document.getElementById('embed-layer') +); + +const radarFull = createRadar( + document.getElementById('radar-full'), + document.getElementById('full-timeline'), + document.getElementById('full-play'), + document.getElementById('full-timestamp'), + document.getElementById('full-layer') +); + +function createOverviewMap(mapEl) { + const map = L.map(mapEl, { + center: [39.5, -98.35], + zoom: 4, + zoomControl: true, + attributionControl: false, + }); + + L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { + 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) { + map.setView([lat, lon], zoom || map.getZoom()); + if (marker) map.removeLayer(marker); + marker = L.circleMarker([lat, lon], { + radius: 7, + fillColor: '#e07848', + fillOpacity: 1, + color: '#fff', + weight: 2, + }).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 }; +} + +function clearLightningLayer() { + if (!mapsOverview.map) return; + if (state.lightningLayer) { + mapsOverview.map.removeLayer(state.lightningLayer); + state.lightningLayer = null; + } +} + +function clearWindArrows() { + if (!mapsOverview.map) return; + state.windArrows.forEach(arrow => mapsOverview.map.removeLayer(arrow)); + state.windArrows = []; +} + +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(); + 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) { + 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]); + } + 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); + } +} + +function renderWindArrows(city) { + if (!mapsOverview.map || !state.showWind || !state.currentWeather) return; + clearWindArrows(); + + // Get current wind data + const current = state.currentWeather.current; + if (!current || !current.wind_speed_10m) return; + + const windSpeed = current.wind_speed_10m; + const windDir = current.wind_direction_10m || 0; + + // Draw a wind barb pointing in the direction of wind movement + 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); + + // 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. + } + } + + 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 = ` +
${formatDayShort(daily.time[i])}
+
${dw.icon}
+
${dw.desc}
+
${tempDisplay(daily.temperature_2m_max[i])}° / ${tempDisplay(daily.temperature_2m_min[i])}°
+
${daily.precipitation_probability_max[i] > 0 ? daily.precipitation_probability_max[i] + '% rain
' : ''}${speedDisplay(daily.wind_speed_10m_max[i])} ${speedUnit()}
+ `; + 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); + } + } +} + +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; + 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(', '); + + const now = new Date(); + document.getElementById('loc-time').innerHTML = + `${DAYS[now.getDay()]}, ${MONTHS[now.getMonth()]} ${now.getDate()}
` + + now.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + + const w = wmo(c.weather_code); + document.getElementById('cur-icon').textContent = w.icon; + document.getElementById('cur-desc').textContent = w.desc; + document.getElementById('cur-temp').textContent = tempDisplay(c.temperature_2m); + document.querySelector('.deg').textContent = `°${tempUnit()}`; + document.querySelector('.feels').innerHTML = `${apparentLabel(c.temperature_2m, c.apparent_temperature)} ${tempDisplay(c.apparent_temperature)}°${tempUnit()}`; + document.getElementById('cur-hi').textContent = tempDisplay(d.temperature_2m_max[0]); + 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('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.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]); + + const nowTs = Date.now(); + let startIdx = h.time.findIndex(t => new Date(t).getTime() >= nowTs); + if (startIdx < 0) startIdx = 0; + + const track = document.getElementById('hourly-track'); + track.innerHTML = ''; + for (let i = startIdx; i < Math.min(startIdx + 24, h.time.length); i++) { + const hw = wmo(h.weather_code[i]); + const pp = h.precipitation_probability[i]; + const ptype = precipType(h.weather_code[i], h.temperature_2m[i], h.rain?.[i], h.snowfall?.[i]); + const cell = document.createElement('div'); + cell.className = 'hour-cell' + (i === startIdx ? ' now' : ''); + cell.innerHTML = ` +
${i === startIdx ? 'Now' : formatHour(h.time[i])}
+
${hw.icon}
+
${tempDisplay(h.temperature_2m[i])}°
+
Feels ${tempDisplay(h.apparent_temperature?.[i])}°
+
${pp > 0 ? `${pp}% ${ptype}` : ptype}
+ `; + track.appendChild(cell); + } + + if (hourlySummaryEl) { + hourlySummaryEl.textContent = summarizeHourly(h, startIdx); + } + + renderDailyForecast(d); + + document.getElementById('updated-at').textContent = + new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + + radarEmbed.panTo(city.latitude, city.longitude, 6); + radarFull.panTo(city.latitude, city.longitude, 6); + mapsOverview.panTo(city.latitude, city.longitude, 6); + if (mapsWindEl) mapsWindEl.textContent = `${speedDisplay(c.wind_speed_10m)} ${speedUnit()} ${compassDir(c.wind_direction_10m)}`; + updateSourceLocation(city); + updateShareUrl(city); + persistLastCity(city); + refreshMapsOverview(); + + show(weatherDiv); +} + +function renderAirQuality(data) { + const cur = data?.current || {}; + const pollen = ['alder_pollen', 'birch_pollen', 'grass_pollen', 'mugwort_pollen', 'olive_pollen', 'ragweed_pollen'] + .map(k => cur[k] || 0) + .reduce((max, v) => Math.max(max, v), 0); + + document.getElementById('aqi-us').textContent = cur.us_aqi ?? '--'; + document.getElementById('pm25').textContent = cur.pm2_5 != null ? `${Number(cur.pm2_5).toFixed(1)} ug/m3` : '--'; + document.getElementById('pollen').textContent = pollen > 0 ? pollen.toFixed(1) : '--'; +} + +function renderAlerts(data) { + const wrap = document.getElementById('alerts-wrap'); + const list = document.getElementById('alerts-list'); + const features = data?.features || []; + list.innerHTML = ''; + + if (!features.length) { + hide(wrap); + if (alertsEmptyEl) show(alertsEmptyEl); + return; + } + + if (alertsEmptyEl) hide(alertsEmptyEl); + + features.slice(0, 5).forEach(f => { + const p = f.properties || {}; + const item = document.createElement('div'); + item.className = 'alert-item'; + item.innerHTML = `${p.event || 'Alert'}${p.severity || 'Unknown severity'}${p.headline || p.description || ''}`; + list.appendChild(item); + }); + show(wrap); + + if (typeof Notification !== 'undefined' && Notification.permission === 'granted') { + features.forEach(f => { + const id = f.id || f.properties?.id; + if (!id || state.alertIdsSeen.has(id)) return; + state.alertIdsSeen.add(id); + const p = f.properties || {}; + new Notification(`Weather alert: ${p.event || 'Severe weather'}`, { + body: p.headline || p.description || 'An alert was issued for your selected location.', + }); + }); + } +} + +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), + fetchAirQuality(city.latitude, city.longitude, city.timezone), + fetchAlerts(city.latitude, city.longitude).catch(() => ({ features: [] })), + ]); + + state.currentWeather = weather; + state.currentAir = air; + state.lastRefreshAt = Date.now(); + renderWeather(weather, city); + renderAirQuality(air); + renderAlerts(alerts); + + // Render map overlays + if (state.showLightning) renderLightningStrikes(city); + if (state.showWind) renderWindArrows(city); + } catch (e) { + showErr('Failed to load weather data. Please try again.'); + console.error(e); + } finally { + hide(loadingEl); + } +} + +function getPosition() { + return new Promise((resolve, reject) => { + if (!navigator.geolocation) { + reject(new Error('Geolocation not supported')); + return; + } + navigator.geolocation.getCurrentPosition(resolve, reject, { + enableHighAccuracy: false, + timeout: 8000, + maximumAge: 10 * 60 * 1000, + }); + }); +} + +async function autoLocateAndLoad() { + try { + const pos = await getPosition(); + const { latitude: lat, longitude: lon } = pos.coords; + const city = await buildDetectedCity(lat, lon); + searchInput.value = city.name; + await loadAll(city, 'auto-detect'); + return true; + } catch { + return false; + } +} + +// ── Search autocomplete ─────────────────────────────────────────────────────── +let debounceTimer = null; + +searchInput.addEventListener('input', () => { + clearTimeout(debounceTimer); + const q = searchInput.value.trim(); + if (q.length < 2) { hide(suggestEl); return; } + + debounceTimer = setTimeout(async () => { + try { + const data = await geocode(q); + const results = data.results || []; + suggestEl.innerHTML = ''; + if (!results.length) { hide(suggestEl); return; } + + results.forEach(r => { + const li = document.createElement('li'); + li.innerHTML = `${r.name}${r.admin1 ? r.admin1 + ', ' : ''}${r.country || ''}`; + li.addEventListener('click', () => { + searchInput.value = r.name; + hide(suggestEl); + loadAll(r, 'search-result'); + }); + suggestEl.appendChild(li); + }); + show(suggestEl); + } catch { + hide(suggestEl); + } + }, 300); +}); + +document.addEventListener('click', e => { + if (!e.target.closest('.search-wrap')) hide(suggestEl); +}); + +function doSearch() { + const q = searchInput.value.trim(); + if (!q) { showErr('Please enter a city name.'); return; } + clearErr(); + hide(suggestEl); + + geocode(q).then(data => { + const results = data.results || []; + if (!results.length) { showErr(`No results found for "${q}".`); return; } + loadAll(results[0], 'search-result'); + }).catch(() => showErr('Search failed. Please try again.')); +} + +searchBtn.addEventListener('click', doSearch); +searchInput.addEventListener('keydown', e => { if (e.key === 'Enter') doSearch(); }); + +// ── Geolocation ─────────────────────────────────────────────────────────────── +locateBtn.addEventListener('click', () => { + if (!navigator.geolocation) { showErr('Geolocation not supported.'); return; } + clearErr(); + hide(weatherDiv); + show(loadingEl); + + navigator.geolocation.getCurrentPosition(async pos => { + const { latitude: lat, longitude: lon } = pos.coords; + try { + const city = await buildDetectedCity(lat, lon); + await loadAll(city, 'gps-button'); + } catch { + showErr('Could not fetch weather for your location.'); + } finally { + hide(loadingEl); + } + }, () => { + hide(loadingEl); + showErr('Location access denied.'); + }); +}); + +unitsBtn.addEventListener('click', () => { + state.units = state.units === 'us' ? 'metric' : 'us'; + localStorage.setItem(STORAGE_KEYS.units, state.units); + updateUnitsButton(); + if (state.currentWeather && state.currentCity) { + renderWeather(state.currentWeather, state.currentCity); + renderAirQuality(state.currentAir); + } +}); + +if (unitsBtnSettings) { + unitsBtnSettings.addEventListener('click', () => unitsBtn.click()); +} + +shareBtn.addEventListener('click', async () => { + if (!state.currentCity) return; + try { + await navigator.clipboard.writeText(window.location.href); + shareBtn.textContent = 'Copied link'; + setTimeout(() => { shareBtn.textContent = 'Share location'; }, 1500); + } catch { + showErr('Could not copy URL to clipboard.'); + } +}); + +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, + }); + persistSavedCities(); + renderSavedCities(); +}); + +notifyBtn.addEventListener('click', async () => { + if (!('Notification' in window)) { + showErr('Notifications are not supported in this browser.'); + return; + } + if (!window.isSecureContext) { + showErr('Notifications require HTTPS (or localhost).'); + updateNotifyButton(); + return; + } + + try { + const permission = await Notification.requestPermission(); + if (permission === 'granted') { + const subscribed = await ensurePushSubscription(); + if (!subscribed) { + showErr('Push needs VAPID keys configured on server.'); + } + if (state.currentCity) { + const alerts = await fetchAlerts(state.currentCity.latitude, state.currentCity.longitude).catch(() => ({ features: [] })); + renderAlerts(alerts); + } + } else if (permission === 'denied') { + showErr('Notifications are blocked in browser settings for this site.'); + } + } catch { + showErr('Could not request notification permission.'); + } finally { + updateNotifyButton(); + } +}); + +autoDetectToggle.addEventListener('change', () => { + state.autoDetect = autoDetectToggle.checked; + localStorage.setItem(STORAGE_KEYS.autoDetect, String(state.autoDetect)); +}); + +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(); + if (state.showLightning) renderLightningStrikes(state.currentCity); + } +}); + +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); + }); +} + +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); + } + }); +} + +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.'); + } + }); +} + +function startAutoRefresh() { + clearInterval(state.autoRefreshTimer); + state.autoRefreshTimer = setInterval(() => { + if (state.currentCity) loadAll(state.currentCity, state.currentSource || 'auto-refresh'); + }, 10 * 60 * 1000); + + // Refresh shortly after users return to the tab if data is stale. + document.addEventListener('visibilitychange', () => { + if (document.visibilityState !== 'visible') return; + if (!state.currentCity) return; + if (Date.now() - state.lastRefreshAt > 10 * 60 * 1000) { + loadAll(state.currentCity, state.currentSource || 'tab-resume-refresh'); + } + }); +} + +// ── Default city / query link load ─────────────────────────────────────────── +(async () => { + updateUnitsButton(); + updateNotifyButton(); + syncAutoDetectToggle(); + + // Sync lightning/wind and UI polish toggles. + 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')); + const qName = qs.get('name'); + if (!Number.isNaN(qLat) && !Number.isNaN(qLon) && qs.get('lat') && qs.get('lon')) { + const city = { + name: qName || `${qLat.toFixed(2)}, ${qLon.toFixed(2)}`, + latitude: qLat, + longitude: qLon, + timezone: 'auto', + country: '', + admin1: '' + }; + searchInput.value = city.name; + await loadAll(city, 'shared-link'); + return; + } + + if (state.autoDetect) { + const autoLocated = await autoLocateAndLoad(); + 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; + await loadAll(lastCity, 'last-city'); + return; + } + + try { + const data = await geocode('New York'); + const city = (data.results || [])[0]; + if (city) { + searchInput.value = city.name; + await loadAll(city, 'default-city'); + } + } catch { + // silent startup fallback + } +})(); diff --git a/.tmp/imgdiff/old/index.html b/.tmp/imgdiff/old/index.html new file mode 100644 index 0000000..9d9070d --- /dev/null +++ b/.tmp/imgdiff/old/index.html @@ -0,0 +1,322 @@ + + + + + +NeCloud Weather + + + + + + + + + + + + + + + +
+ +
+

NeCloud Weather

+ Open-Meteo · RainViewer +
+ + +
+
+ + + + +
+ + +
+ + + + + +
+ +
+ + + + + + + + + + + + + + + +
+ + + + + + diff --git a/.tmp/imgdiff/old/server.js b/.tmp/imgdiff/old/server.js new file mode 100644 index 0000000..177207e --- /dev/null +++ b/.tmp/imgdiff/old/server.js @@ -0,0 +1,474 @@ +const express = require('express'); +const path = require('path'); +const fs = require('fs'); +const webpush = require('web-push'); + +const app = express(); +const PORT = process.env.PORT || 3000; + +app.use(express.json({ limit: '256kb' })); + +// Lightweight request logging for observability. +app.use((req, res, next) => { + const started = Date.now(); + res.on('finish', () => { + const ms = Date.now() - started; + console.log(`${new Date().toISOString()} ${req.method} ${req.originalUrl} ${res.statusCode} ${ms}ms`); + }); + next(); +}); + +function sleep(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +async function fetchJsonWithRetry(url, options = {}, attempts = 3, initialDelayMs = 300) { + let lastErr = null; + for (let i = 0; i < attempts; i++) { + try { + const r = await fetch(url, options); + if (!r.ok) { + const text = await r.text().catch(() => ''); + throw new Error(`Upstream status ${r.status}${text ? `: ${text.slice(0, 200)}` : ''}`); + } + return await r.json(); + } catch (e) { + lastErr = e; + if (i < attempts - 1) { + await sleep(initialDelayMs * Math.pow(2, i)); + } + } + } + throw lastErr || new Error('Unknown upstream fetch error'); +} + +// --------------- Persistent data storage --------------- +const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data'); +const SUBS_FILE = path.join(DATA_DIR, 'subscriptions.json'); +const SENT_IDS_FILE = path.join(DATA_DIR, 'sent-alert-ids.json'); + +try { fs.mkdirSync(DATA_DIR, { recursive: true }); } catch (_) {} + +function loadSubscriptions() { + try { + const arr = JSON.parse(fs.readFileSync(SUBS_FILE, 'utf8')); + const map = new Map(); + for (const entry of arr) { + if (entry?.subscription?.endpoint) map.set(entry.subscription.endpoint, entry); + } + return map; + } catch (_) { return new Map(); } +} + +function saveSubscriptions() { + try { + fs.writeFileSync(SUBS_FILE, JSON.stringify(Array.from(subscriptions.values()), null, 2)); + } catch (e) { console.error('Failed to save subscriptions:', e.message); } +} + +const subscriptions = loadSubscriptions(); +if (subscriptions.size) console.log(`Loaded ${subscriptions.size} push subscription(s) from disk.`); + +function loadSentAlertIds() { + try { + return new Map(JSON.parse(fs.readFileSync(SENT_IDS_FILE, 'utf8'))); + } catch (_) { return new Map(); } +} + +function saveSentAlertIds() { + try { + fs.writeFileSync(SENT_IDS_FILE, JSON.stringify(Array.from(sentAlertIds.entries()), null, 2)); + } catch (e) { console.error('Failed to save sent alert IDs:', e.message); } +} + +const sentAlertIds = loadSentAlertIds(); +// -------------------------------------------------------- + +function getVapidConfig() { + const publicKey = process.env.VAPID_PUBLIC_KEY || ''; + const privateKey = process.env.VAPID_PRIVATE_KEY || ''; + const subject = process.env.VAPID_SUBJECT || 'mailto:nate@necloud.us'; + return { publicKey, privateKey, subject }; +} + +function pushConfigured() { + const cfg = getVapidConfig(); + return Boolean(cfg.publicKey && cfg.privateKey); +} + +function ensurePushConfigured() { + const cfg = getVapidConfig(); + if (!cfg.publicKey || !cfg.privateKey) { + return false; + } + webpush.setVapidDetails(cfg.subject, cfg.publicKey, cfg.privateKey); + return true; +} + +// HTML: never cache (always revalidate so SW/app updates are picked up immediately) +app.use('/', (req, res, next) => { + if (req.path === '/' || req.path.endsWith('.html')) { + res.setHeader('Cache-Control', 'no-store'); + } else if (req.path.endsWith('.css') || req.path.endsWith('.js') || req.path === '/sw.js' || req.path === '/manifest.json') { + // Short-lived cache — browser revalidates every 60 s but still gets a 304 fast + res.setHeader('Cache-Control', 'no-cache'); + } + next(); +}); +app.use(express.static(path.join(__dirname, 'public'))); + +app.get('/healthz', (_req, res) => { + res.json({ + ok: true, + uptimeSec: Math.round(process.uptime()), + pushConfigured: pushConfigured(), + subscriptionCount: subscriptions.size, + now: new Date().toISOString(), + }); +}); + +// Geocoding proxy +app.get('/api/geocode', async (req, res) => { + const { q } = req.query; + if (!q) return res.status(400).json({ error: 'Missing query' }); + try { + const url = `https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(q)}&count=5&language=en&format=json`; + const data = await fetchJsonWithRetry(url); + res.json(data); + } catch (e) { + res.status(500).json({ error: 'Geocoding failed', detail: e.message }); + } +}); + +// Reverse geocoding proxy +app.get('/api/reverse-geocode', async (req, res) => { + const { lat, lon } = req.query; + if (!lat || !lon) return res.status(400).json({ error: 'Missing lat/lon' }); + + try { + // Prefer Open-Meteo reverse geocoding for stable city/region labels. + const omParams = new URLSearchParams({ + latitude: String(lat), + longitude: String(lon), + count: '1', + language: 'en', + format: 'json', + }); + + const omData = await fetchJsonWithRetry(`https://geocoding-api.open-meteo.com/v1/reverse?${omParams}`, {}, 2, 200).catch(() => null); + if (omData) { + const top = omData?.results?.[0]; + if (top) { + return res.json({ + name: top.name || top.admin2 || top.admin1 || top.country || 'Detected location', + admin1: top.admin1 || top.admin2 || '', + country: top.country || '', + latitude: Number(lat), + longitude: Number(lon), + timezone: top.timezone || 'auto', + display_name: [top.name, top.admin1, top.country].filter(Boolean).join(', '), + }); + } + } + + const params = new URLSearchParams({ + format: 'jsonv2', + lat: String(lat), + lon: String(lon), + zoom: '10', + addressdetails: '1', + }); + + const data = await fetchJsonWithRetry(`https://nominatim.openstreetmap.org/reverse?${params}`, { + headers: { + 'User-Agent': 'weather-app-v2 (nate@necloud.us)', + Accept: 'application/json', + }, + }); + + const address = data.address || {}; + const name = address.city || address.town || address.village || address.hamlet || address.municipality || address.county || address.state || data.name || 'Detected location'; + + res.json({ + name, + admin1: address.state || address.region || address.county || '', + country: address.country || '', + latitude: Number(lat), + longitude: Number(lon), + timezone: 'auto', + display_name: data.display_name || '', + }); + } catch (e) { + res.status(500).json({ error: 'Reverse geocoding failed', detail: e.message }); + } +}); + +// Weather proxy +app.get('/api/weather', async (req, res) => { + const { lat, lon, tz, units } = req.query; + if (!lat || !lon) return res.status(400).json({ error: 'Missing lat/lon' }); + const timezone = tz || 'auto'; + const useMetric = units === 'metric'; + try { + const params = new URLSearchParams({ + latitude: lat, longitude: lon, timezone, + current: [ + 'temperature_2m','relative_humidity_2m','apparent_temperature', + 'is_day','precipitation','weather_code','cloud_cover', + 'wind_speed_10m','wind_direction_10m','surface_pressure','visibility' + ].join(','), + hourly: [ + 'temperature_2m','precipitation_probability','weather_code', + 'wind_speed_10m','apparent_temperature','rain','snowfall' + ].join(','), + daily: [ + 'weather_code','temperature_2m_max','temperature_2m_min', + 'precipitation_sum','precipitation_probability_max', + 'wind_speed_10m_max','sunrise','sunset','uv_index_max' + ].join(','), + forecast_days: 16, + wind_speed_unit: useMetric ? 'kmh' : 'mph', + temperature_unit: useMetric ? 'celsius' : 'fahrenheit', + precipitation_unit: useMetric ? 'mm' : 'inch' + }); + const data = await fetchJsonWithRetry(`https://api.open-meteo.com/v1/forecast?${params}`); + res.json(data); + } catch (e) { + res.status(500).json({ error: 'Weather fetch failed', detail: e.message }); + } +}); + +// Open-Meteo Air Quality proxy +app.get('/api/air-quality', async (req, res) => { + const { lat, lon, tz } = req.query; + if (!lat || !lon) return res.status(400).json({ error: 'Missing lat/lon' }); + try { + const params = new URLSearchParams({ + latitude: lat, + longitude: lon, + timezone: tz || 'auto', + current: ['us_aqi', 'pm2_5', 'alder_pollen', 'birch_pollen', 'grass_pollen', 'mugwort_pollen', 'olive_pollen', 'ragweed_pollen'].join(','), + forecast_days: 1, + }); + const data = await fetchJsonWithRetry(`https://air-quality-api.open-meteo.com/v1/air-quality?${params}`); + res.json(data); + } catch (e) { + res.status(500).json({ error: 'Air quality fetch failed', detail: e.message }); + } +}); + +// NWS alerts proxy (US only) +app.get('/api/alerts', async (req, res) => { + const { lat, lon } = req.query; + if (!lat || !lon) return res.status(400).json({ error: 'Missing lat/lon' }); + try { + const data = await fetchJsonWithRetry(`https://api.weather.gov/alerts/active?point=${lat},${lon}`, { + headers: { + 'User-Agent': 'weather-app-v2 (nate@necloud.us)', + Accept: 'application/geo+json', + }, + }); + res.json(data); + } catch (e) { + res.status(500).json({ error: 'Alerts fetch failed', detail: e.message }); + } +}); + +// Lightning activity from active weather.gov thunderstorm alerts (real alert geometries). +app.get('/api/lightning', async (req, res) => { + const { lat, lon, age } = req.query; + if (!lat || !lon) return res.status(400).json({ error: 'Missing lat/lon' }); + const ageMinutes = Number(age || 15); + + try { + const data = await fetchJsonWithRetry(`https://api.weather.gov/alerts/active?point=${lat},${lon}`, { + headers: { + 'User-Agent': 'weather-app-v2 (nate@necloud.us)', + Accept: 'application/geo+json', + }, + }); + + const cutoff = Date.now() - (Number.isFinite(ageMinutes) ? ageMinutes : 15) * 60 * 1000; + const features = (data?.features || []).filter(f => { + const p = f?.properties || {}; + const event = String(p.event || '').toLowerCase(); + const relevant = event.includes('thunderstorm') || event.includes('lightning'); + if (!relevant) return false; + const ts = Date.parse(p.sent || p.onset || p.effective || ''); + if (Number.isNaN(ts)) return true; + return ts >= cutoff; + }); + + res.json({ + source: 'weather.gov alerts', + count: features.length, + features, + ageMinutes: Number.isFinite(ageMinutes) ? ageMinutes : 15, + }); + } catch (e) { + res.status(500).json({ error: 'Lightning fetch failed', detail: e.message }); + } +}); + +app.get('/api/push/public-key', (_req, res) => { + const { publicKey } = getVapidConfig(); + res.json({ + enabled: pushConfigured(), + publicKey: publicKey || null, + }); +}); + +app.post('/api/push/subscribe', (req, res) => { + const { subscription, location } = req.body || {}; + if (!subscription?.endpoint) { + return res.status(400).json({ error: 'Missing subscription endpoint' }); + } + subscriptions.set(subscription.endpoint, { + subscription, + location: location || null, + createdAt: Date.now(), + }); + saveSubscriptions(); + res.json({ ok: true, count: subscriptions.size }); +}); + +app.post('/api/push/unsubscribe', (req, res) => { + const { endpoint } = req.body || {}; + if (!endpoint) return res.status(400).json({ error: 'Missing endpoint' }); + subscriptions.delete(endpoint); + saveSubscriptions(); + res.json({ ok: true, count: subscriptions.size }); +}); + +app.post('/api/push/test', async (req, res) => { + if (!ensurePushConfigured()) { + return res.status(400).json({ + error: 'Push is not configured. Set VAPID_PUBLIC_KEY and VAPID_PRIVATE_KEY.', + }); + } + + const payload = JSON.stringify({ + title: req.body?.title || 'Weather test notification', + body: req.body?.body || 'Push notifications are working.', + url: req.body?.url || '/', + ts: Date.now(), + }); + + let sent = 0; + let removed = 0; + const errors = []; + + const entries = Array.from(subscriptions.values()); + for (const entry of entries) { + try { + await webpush.sendNotification(entry.subscription, payload); + sent++; + } catch (e) { + const statusCode = e?.statusCode; + if (statusCode === 404 || statusCode === 410) { + subscriptions.delete(entry.subscription.endpoint); + removed++; + saveSubscriptions(); + } else { + errors.push(String(e.message || e)); + } + } + } + + res.json({ ok: true, sent, removed, errors, count: subscriptions.size }); +}); + +// --------------- Background NWS alert poller --------------- +// Runs every ALERT_POLL_INTERVAL_MS (default 5 min). +// Sends push notifications to all subscribers for any new active NWS alerts +// at their saved location. Deduplicates by alert ID (persisted to disk). + +function cleanSentAlertIds() { + const cutoff = Date.now() - 48 * 60 * 60 * 1000; + for (const [id, ts] of sentAlertIds) { + if (ts < cutoff) sentAlertIds.delete(id); + } +} + +async function alertPoller() { + if (!pushConfigured() || !ensurePushConfigured()) return; + if (subscriptions.size === 0) return; + + cleanSentAlertIds(); + + // Group subscribers by rounded lat/lon so we don't spam the NWS API. + const byLocation = new Map(); + for (const entry of subscriptions.values()) { + const loc = entry.location; + if (!loc?.latitude || !loc?.longitude) continue; + const key = `${Number(loc.latitude).toFixed(2)},${Number(loc.longitude).toFixed(2)}`; + if (!byLocation.has(key)) byLocation.set(key, { loc, entries: [] }); + byLocation.get(key).entries.push(entry); + } + + let savedIds = false; + for (const { loc, entries } of byLocation.values()) { + try { + const data = await fetchJsonWithRetry( + `https://api.weather.gov/alerts/active?point=${loc.latitude},${loc.longitude}`, + { headers: { 'User-Agent': 'weather-app-v2 (nate@necloud.us)', Accept: 'application/geo+json' } } + ).catch(() => null); + if (!data?.features?.length) continue; + + for (const feature of data.features) { + const id = feature?.properties?.id; + if (!id || sentAlertIds.has(id)) continue; + + const props = feature.properties || {}; + const title = props.event || 'Weather Alert'; + const area = props.areaDesc ? props.areaDesc.split(';')[0].trim() : (loc.name || 'Your area'); + const headline = props.headline || props.description?.slice(0, 120) || title; + const payload = JSON.stringify({ + title, + body: `${area}: ${headline}`, + url: '/', + ts: Date.now(), + }); + + let anySuccess = false; + for (const entry of entries) { + try { + await webpush.sendNotification(entry.subscription, payload); + anySuccess = true; + } catch (e) { + if (e?.statusCode === 404 || e?.statusCode === 410) { + subscriptions.delete(entry.subscription.endpoint); + saveSubscriptions(); + } + } + } + + if (anySuccess) { + console.log(`Alert push sent: [${id}] ${title} → ${area}`); + sentAlertIds.set(id, Date.now()); + savedIds = true; + } + } + } catch (e) { + console.error('Alert poller error:', e.message); + } + } + + if (savedIds) saveSentAlertIds(); +} + +const POLL_INTERVAL_MS = Number(process.env.ALERT_POLL_INTERVAL_MS) || 5 * 60 * 1000; +setInterval(alertPoller, POLL_INTERVAL_MS); +// ----------------------------------------------------------- + +// RainViewer timestamps proxy +app.get('/api/radar/times', async (req, res) => { + try { + const data = await fetchJsonWithRetry('https://api.rainviewer.com/public/weather-maps.json'); + res.json(data); + } catch (e) { + res.status(500).json({ error: 'Radar times failed', detail: e.message }); + } +}); + +app.listen(PORT, () => console.log(`Weather app running on port ${PORT}`)); diff --git a/.tmp/imgdiff/old/style.css b/.tmp/imgdiff/old/style.css new file mode 100644 index 0000000..71c96f7 --- /dev/null +++ b/.tmp/imgdiff/old/style.css @@ -0,0 +1,1031 @@ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +:root { + --ink: #102539; + --ink-soft: #476a82; + --ink-muted: #6f90a4; + --paper: #e7f2f8; + --paper-deep: #d6e7f1; + --surface: rgba(255, 255, 255, 0.9); + --surface-soft: rgba(255, 255, 255, 0.74); + --line: rgba(96, 138, 166, 0.35); + --brand: #f0722f; + --brand-strong: #d75f1f; + --info: #1d87bf; + --ok: #1f9c79; + --danger: #c84b2f; + --radius-xl: 22px; + --radius-lg: 16px; + --radius-md: 12px; + --shadow-lg: 0 14px 36px rgba(11, 35, 52, 0.16); + --shadow-md: 0 8px 22px rgba(11, 35, 52, 0.12); + --ui: 'Manrope', 'Avenir Next', 'Segoe UI', sans-serif; + --display: 'Playfair Display', Georgia, serif; +} + +@media (prefers-color-scheme: dark) { + :root { + --ink: #ecf6fd; + --ink-soft: #bed1dd; + --ink-muted: #92aebf; + --paper: #0b1721; + --paper-deep: #122433; + --surface: rgba(26, 46, 61, 0.88); + --surface-soft: rgba(28, 49, 65, 0.7); + --line: rgba(118, 152, 176, 0.34); + --brand: #ff8d4d; + --brand-strong: #ef7330; + --info: #63c2ef; + --ok: #38c89f; + --danger: #f77257; + --shadow-lg: 0 16px 40px rgba(0, 0, 0, 0.45); + --shadow-md: 0 9px 22px rgba(0, 0, 0, 0.35); + } +} + +html, body { + min-height: 100%; + font-family: var(--ui); + font-size: 18px; + line-height: 1.45; + color: var(--ink); + background: var(--paper); + overflow-x: hidden; +} + +body { + background: + radial-gradient(circle at 8% -6%, rgba(255, 255, 255, 0.86), transparent 46%), + radial-gradient(circle at 100% 0%, rgba(111, 184, 222, 0.25), transparent 35%), + linear-gradient(180deg, var(--paper) 0%, var(--paper-deep) 100%); + transition: background 0.6s ease; +} + +body.weather-clear { + background: + radial-gradient(circle at 15% 0%, rgba(255, 255, 255, 0.95), transparent 44%), + radial-gradient(circle at 95% 5%, rgba(255, 214, 131, 0.4), transparent 30%), + linear-gradient(180deg, #dff2ff 0%, #cbe6f7 45%, #b6d9ee 100%); +} + +body.weather-cloud { + background: + radial-gradient(circle at 10% -5%, rgba(255, 255, 255, 0.75), transparent 46%), + radial-gradient(circle at 85% 0%, rgba(175, 203, 220, 0.35), transparent 35%), + linear-gradient(180deg, #d7e8f1 0%, #c6dbe7 55%, #b5cfde 100%); +} + +body.weather-rain { + background: + radial-gradient(circle at 20% -8%, rgba(196, 220, 235, 0.45), transparent 45%), + radial-gradient(circle at 100% 0%, rgba(109, 162, 191, 0.32), transparent 35%), + linear-gradient(180deg, #a8c1d2 0%, #8eaabd 55%, #748fa5 100%); +} + +body.weather-snow { + background: + radial-gradient(circle at 12% 0%, rgba(255, 255, 255, 0.9), transparent 45%), + radial-gradient(circle at 92% 0%, rgba(225, 241, 252, 0.8), transparent 36%), + linear-gradient(180deg, #ebf4fa 0%, #dfeef8 55%, #d2e4f1 100%); +} + +body.weather-storm { + background: + radial-gradient(circle at 30% -10%, rgba(140, 160, 181, 0.36), transparent 44%), + radial-gradient(circle at 100% 5%, rgba(80, 111, 142, 0.45), transparent 34%), + linear-gradient(180deg, #6f8598 0%, #576d83 55%, #42566c 100%); +} + +body.weather-fog { + background: + radial-gradient(circle at 15% 0%, rgba(241, 246, 249, 0.9), transparent 44%), + radial-gradient(circle at 92% 0%, rgba(214, 224, 231, 0.6), transparent 36%), + linear-gradient(180deg, #d9e3e9 0%, #cdd9e0 55%, #c0ced7 100%); +} + +body.weather-night { + background: + radial-gradient(circle at 15% -10%, rgba(123, 145, 173, 0.2), transparent 44%), + radial-gradient(circle at 100% 0%, rgba(74, 104, 131, 0.28), transparent 34%), + linear-gradient(180deg, #0b1722 0%, #102131 55%, #13283b 100%); +} + +a { color: var(--info); text-decoration: none; } +a:hover { text-decoration: underline; } +.hidden { display: none !important; } + +.app { + width: 100%; + max-width: 100%; + padding: calc(0.7rem + env(safe-area-inset-top, 0px)) 0.75rem calc(6.9rem + env(safe-area-inset-bottom, 0px)); +} + +/* Header */ +.masthead { + position: sticky; + top: env(safe-area-inset-top, 0px); + z-index: 220; + padding: 0.55rem 0.55rem 0.45rem; + border: 1px solid var(--line); + border-radius: var(--radius-xl); + background: linear-gradient(180deg, var(--surface) 0%, var(--surface-soft) 100%); + backdrop-filter: blur(9px); + box-shadow: var(--shadow-md); + margin-bottom: 0.6rem; +} + +.masthead h1 { + font-family: var(--display); + font-size: 1.7rem; + letter-spacing: -0.3px; + margin-bottom: 0.35rem; + color: #111; + text-shadow: + 0 1px 2px rgba(255,255,255,0.7), + 0 0 8px rgba(255,255,255,0.5); +} + +.tagline { + display: block; + margin-top: 0.35rem; + font-size: 0.76rem; + letter-spacing: 0.04em; + color: rgba(0,0,0,0.65); + text-shadow: 0 1px 2px rgba(255,255,255,0.6); +} + +/* Bottom nav */ +.tabs { + position: fixed; + left: 0; + right: 0; + bottom: 0; + width: 100vw; + max-width: 100vw; + z-index: 300; + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 0.22rem; + padding: 0.45rem 0.28rem calc(0.45rem + env(safe-area-inset-bottom, 0px)); + background: rgba(9, 28, 40, 0.92); + backdrop-filter: blur(12px); + border-top: 1px solid rgba(121, 165, 191, 0.36); +} + +@supports (bottom: env(safe-area-inset-bottom)) { + .tabs { + bottom: 0; + padding-bottom: calc(0.45rem + env(safe-area-inset-bottom)); + } +} + +.tab { + border: none; + border-radius: 12px; + min-width: 0; + min-height: 58px; + padding: 0.32rem 0.15rem; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.24rem; + background: rgba(255, 255, 255, 0.04); + color: #d8eaf4; + cursor: pointer; + font-family: var(--ui); +} + +.tab-icon { + font-family: 'Apple Color Emoji', 'Segoe UI Emoji', 'Noto Color Emoji', sans-serif; + font-size: 1.18rem; + line-height: 1; +} + +.tab-text { + font-size: 0.67rem; + line-height: 1; + letter-spacing: 0.02em; + font-weight: 600; +} + +.tab.active { + color: #fff; + background: linear-gradient(180deg, var(--brand) 0%, var(--brand-strong) 100%); +} + +/* Search */ +.search-wrap { + margin-bottom: 0.4rem; +} + +.search-row { + display: flex; + align-items: center; + flex-wrap: nowrap; + gap: 0; + border: 1px solid var(--line); + border-radius: var(--radius-lg); + overflow: hidden; + background: var(--surface); + box-shadow: var(--shadow-md); +} + +button, input, select { font-family: var(--ui); } + +.search-row input, +.search-row button, +.maps-control select, +.settings-btn { + font-size: 16px; /* prevent iOS focus zoom */ +} + +.search-row input { + flex: 1; + min-width: 0; + min-height: 46px; + border: none; + background: transparent; + color: var(--ink); + padding: 0 0.75rem; +} + +.search-row button { + flex: 0 0 auto; + min-height: 46px; + border: none; + border-left: 1px solid var(--line); + cursor: pointer; + padding: 0 0.65rem; +} + +#search-btn { + background: linear-gradient(180deg, #15445f 0%, #11344c 100%); + color: #f2f8fc; + min-width: 52px; + width: 52px; + font-size: 0; + letter-spacing: 0; + text-transform: none; + padding: 0; +} + +#search-btn::before { + content: '🔎'; + font-size: 1rem; + line-height: 1; +} + +#locate-btn, +#units-btn { + background: var(--surface) !important; + color: var(--ink) !important; + min-width: 48px; +} + +#locate-btn { + font-size: 1.05rem !important; +} + +@media (max-width: 390px) { + #search-btn, + #locate-btn, + #units-btn { + min-width: 46px; + width: 46px; + padding: 0 0.5rem; + } +} + +.suggestions { + list-style: none; + margin-top: 0.3rem; + border-radius: var(--radius-lg); + overflow: hidden; + border: 1px solid var(--line); + background: var(--surface); + box-shadow: var(--shadow-lg); +} + +.suggestions li { + padding: 0.72rem 0.8rem; + border-bottom: 1px solid var(--line); + font-size: 0.88rem; + cursor: pointer; +} + +.suggestions li:last-child { border-bottom: none; } +.suggestions li:hover { background: rgba(29, 135, 191, 0.09); } +.sug-country { color: var(--ink-muted); font-size: 0.8rem; margin-left: 0.35rem; } + +.error { + margin-top: 0.35rem; + color: var(--danger); + font-size: 0.84rem; +} + +/* Generic cards */ +.tab-panel { + width: 100%; + min-height: calc(100dvh - 11.6rem); +} + +.loc-header, +.radar-embed-wrap, +.radar-full-wrap, +.maps-wrap, +.alerts-wrap, +.panel-note, +.settings-wrap, +.daily-list, +.hourly-scroll, +.stats-bar, +.astro-grid, +.aq-grid { + border: 1px solid var(--line); + border-radius: var(--radius-xl); + background: var(--surface-soft); + box-shadow: var(--shadow-md); +} + +.loading { + display: flex; + align-items: center; + gap: 0.65rem; + color: var(--ink-soft); + font-size: 0.88rem; + padding: 1rem 0.35rem; +} + +.loading-dot { + width: 11px; + height: 11px; + border-radius: 50%; + background: var(--brand); + animation: pulse 1.1s ease-in-out infinite; +} + +@keyframes pulse { + 0%, 100% { opacity: 0.4; transform: scale(0.9); } + 50% { opacity: 1; transform: scale(1.08); } +} + +.loc-header { + padding: 0.9rem; + margin-bottom: 0.75rem; + display: flex; + flex-direction: column; + gap: 0.4rem; +} + +.loc-name { + font-family: var(--display); + font-size: 1.5rem; + line-height: 1.1; +} + +.loc-sub, +.loc-time { + font-size: 0.8rem; + color: var(--ink-muted); +} + +.loc-actions { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 0.32rem; +} + +.link-btn { + border: none; + background: none; + color: var(--info); + cursor: pointer; + font-size: 0.84rem; +} + +.section-head { + display: flex; + flex-direction: column; + gap: 0.22rem; + margin: 0.35rem 0.45rem 0.5rem; +} + +.section-label { + font-size: 0.74rem; + color: var(--ink-soft); + text-transform: uppercase; + letter-spacing: 0.08em; + font-weight: 700; +} + +.section-note { + color: var(--ink-muted); + font-size: 0.79rem; +} + +.section-actions { + display: inline-flex; + flex-wrap: wrap; + gap: 0.62rem; +} + +/* Current */ +.current-grid { + display: grid; + gap: 0.6rem; + margin-bottom: 0.75rem; +} + +.current-grid > * { + border-radius: var(--radius-lg); + border: 1px solid var(--line); + background: var(--surface); + padding: 1rem 0.85rem; + box-shadow: var(--shadow-md); +} + +.temp-big { + font-family: var(--display); + font-size: 3.75rem; + line-height: 0.92; + letter-spacing: -1.5px; +} + +.deg { + font-size: 1.35rem; + color: var(--ink-soft); + vertical-align: super; +} + +.feels, +.hi-lo, +.cond-sub { + font-size: 0.84rem; + color: var(--ink-soft); +} + +.current-cond { display: flex; flex-direction: column; justify-content: center; } +.cond-icon { font-size: 2.2rem; margin-bottom: 0.15rem; } +.cond-desc { font-size: 1.08rem; font-weight: 700; } + +/* Stat blocks */ +.stats-bar, +.astro-grid, +.aq-grid { + display: grid; + gap: 0.52rem; + border: none; + background: transparent; + box-shadow: none; + margin-bottom: 0.78rem; +} + +.stats-bar { grid-template-columns: repeat(2, minmax(0, 1fr)); } + +.stat, +.astro-card, +.aq-card, +.maps-card, +.settings-row, +.alert-item { + border: 1px solid var(--line); + border-radius: var(--radius-md); + background: var(--surface); + box-shadow: var(--shadow-md); +} + +.stat { padding: 0.78rem 0.58rem; text-align: center; } + +.stat-label { + font-size: 0.64rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--ink-muted); + margin-bottom: 0.2rem; + font-weight: 700; +} + +.stat-val { font-size: 1.02rem; font-weight: 700; } +.unit { font-size: 0.72rem; color: var(--ink-soft); } + +.astro-card, +.aq-card, +.maps-card { padding: 0.74rem 0.7rem; } +.astro-val, +.aq-val, +.maps-value { font-size: 0.98rem; font-weight: 700; } + +/* Radar & maps */ +.radar-embed-wrap, +.radar-full-wrap, +.maps-wrap, +.alerts-wrap, +.settings-wrap { + padding: 0.7rem; + margin-bottom: 0.85rem; +} + +.radar-map, +.map-overview { + width: 100%; + height: 235px; + border-radius: var(--radius-md); + border: 1px solid var(--line); + overflow: hidden; +} + +.radar-map-full { + height: calc(100dvh - 20rem); + min-height: 310px; +} + +.radar-controls { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.45rem; + margin-top: 0.62rem; +} + +.radar-btn { + width: 46px; + height: 46px; + border: none; + border-radius: 12px; + background: #0f3248; + color: #f5fbfe; + cursor: pointer; + font-size: 0.86rem; +} + +.layer-btn { background: var(--brand); } + +.radar-timeline { + order: 3; + flex-basis: 100%; + height: 32px; + display: flex; + gap: 2px; +} + +.tl-tick { + flex: 1; + border-radius: 7px; + border: 1px solid var(--line); + background: rgba(255, 255, 255, 0.52); + cursor: pointer; +} + +.tl-tick.active { background: var(--info); border-color: var(--info); } +.tl-tick.forecast { opacity: 0.6; } + +.radar-ts { font-size: 0.75rem; color: var(--ink-soft); } +.radar-legend { display: inline-flex; align-items: center; gap: 0.35rem; } +.legend-label { font-size: 0.64rem; color: var(--ink-muted); } +.legend-bar { + width: 84px; + height: 10px; + border-radius: 999px; + border: 1px solid var(--line); + background: linear-gradient(to right, #00d8ff, #00a0e0, #0070d0, #00c800, #ffff00, #ff6000, #ff0000); +} + +.radar-attribution, +.footer-note { + font-size: 0.74rem; + color: var(--ink-muted); + margin-bottom: 0.8rem; +} + +/* Hourly */ +.hourly-summary { + margin: 0.22rem 0.38rem 0.45rem; + font-size: 0.8rem; + color: var(--ink-soft); +} + +.hourly-scroll { + overflow-x: auto; + -webkit-overflow-scrolling: touch; + margin-bottom: 0.85rem; + padding: 0.42rem; +} + +.hourly-track { display: flex; min-width: max-content; } + +.hour-cell { + min-width: 90px; + padding: 0.72rem 0.45rem; + text-align: center; + border-radius: 12px; + border: 1px solid var(--line); + background: var(--surface); + margin-right: 0.3rem; +} + +.hour-cell.now { + background: linear-gradient(180deg, rgba(29, 135, 191, 0.18) 0%, var(--surface) 100%); +} + +.hour-time { font-size: 0.7rem; color: var(--ink-muted); margin-bottom: 0.28rem; } +.hour-icon { font-size: 1.35rem; margin-bottom: 0.18rem; } +.hour-temp { font-size: 1rem; font-weight: 700; } +.hour-feels, +.hour-precip { font-size: 0.68rem; color: var(--ink-soft); } + +/* Daily */ +.daily-list { + padding: 0.4rem; + margin-bottom: 0.9rem; +} + +.day-row { + display: grid; + grid-template-columns: 1fr auto; + grid-template-areas: + 'name temps' + 'desc desc'; + gap: 0.28rem 0.55rem; + border-radius: 12px; + border: 1px solid var(--line); + background: var(--surface); + padding: 0.72rem; + margin-bottom: 0.34rem; +} + +.day-row:last-child { margin-bottom: 0; } +.day-row.today { outline: 1px solid rgba(29, 135, 191, 0.45); } +.day-name { grid-area: name; font-size: 0.9rem; font-weight: 700; } +.day-icon { display: none; } +.day-desc { + grid-area: desc; + font-size: 0.78rem; + color: var(--ink-soft); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.day-temps { grid-area: temps; font-size: 0.88rem; font-weight: 700; } +.day-lo { color: var(--ink-muted); } +.day-meta { display: none; } + +/* Lists and settings */ +.panel-note { + padding: 0.75rem; + font-size: 0.82rem; + color: var(--ink-soft); +} + +.alerts-list, +.saved-locations, +.maps-controls, +.maps-meta { + display: grid; + gap: 0.5rem; +} + +.alert-item { + padding: 0.72rem; + display: grid; + gap: 0.22rem; + font-size: 0.82rem; +} + +.maps-control { + display: grid; + gap: 0.3rem; + font-size: 0.68rem; + color: var(--ink-muted); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.maps-control input, +.maps-control select, +.settings-btn { + min-height: 44px; + border: 1px solid var(--line); + border-radius: 12px; + background: var(--surface); + color: var(--ink); + padding: 0.35rem 0.55rem; +} + +#maps-fullscreen-btn { + font-size: 0.78rem; + font-weight: 700; +} + +body.map-fullscreen-lock { + overflow: hidden; +} + +.maps-wrap.is-fullscreen { + position: fixed; + inset: 0; + z-index: 520; + margin: 0; + border-radius: 0; + border: none; + background: var(--paper); + overflow-y: auto; + padding: calc(0.65rem + env(safe-area-inset-top, 0px)) 0.65rem calc(0.65rem + env(safe-area-inset-bottom, 0px)); +} + +.maps-wrap.is-fullscreen .map-overview { + height: calc(100dvh - 210px); + min-height: 320px; +} + +.wind-legend { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 0.42rem; + margin-top: 0.52rem; + font-size: 0.71rem; + color: var(--ink-muted); +} + +.wind-legend-scale { + width: 126px; + height: 10px; + border-radius: 999px; + border: 1px solid var(--line); + background: linear-gradient(to right, #d2f0ff, #84ccff, #3f9fff, #1879df); +} + +.saved-wrap { margin-top: 0.1rem; } + +.saved-head { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 0.3rem; + margin-bottom: 0.32rem; + color: var(--ink-muted); + text-transform: uppercase; + letter-spacing: 0.07em; + font-size: 0.67rem; +} + +.saved-help { + margin-bottom: 0.38rem; + font-size: 0.74rem; + color: var(--ink-muted); +} + +.saved-item { + display: grid; + grid-template-columns: 36px 1fr 36px 36px 36px; + border: 1px solid var(--line); + border-radius: 12px; + background: var(--surface); + overflow: hidden; +} + +.saved-chip, +.saved-remove, +.saved-default, +.saved-move { + min-height: 44px; + border: none; + background: transparent; + color: var(--ink); + cursor: pointer; +} + +.saved-chip { + padding: 0 0.58rem; + text-align: left; + border-left: 1px solid var(--line); + border-right: 1px solid var(--line); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.saved-default { color: var(--brand); } +.saved-move { border-right: 1px solid var(--line); } +.saved-remove { border-right: 1px solid var(--line); color: var(--ink-muted); } +.saved-move:disabled { opacity: 0.35; cursor: not-allowed; } +.saved-meta { display: none; } + +.settings-wrap { display: grid; gap: 0.62rem; } + +.settings-row { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 0.45rem; + padding: 0.74rem; +} + +.settings-label { + font-size: 0.72rem; + color: var(--ink-muted); + text-transform: uppercase; + letter-spacing: 0.05em; + font-weight: 700; +} + +.settings-value { font-size: 0.88rem; color: var(--ink); } + +.auto-detect-toggle, +.toggle-label { + display: inline-flex; + align-items: center; + gap: 0.48rem; + color: var(--ink-soft); + font-size: 0.9rem; +} + +.auto-detect-toggle input, +.toggle-label input { accent-color: var(--brand); } + +button:focus-visible, +input:focus-visible, +select:focus-visible { + outline: 2px solid var(--brand); + outline-offset: 2px; +} + +/* Leaflet */ +.leaflet-container { background: #1a2430; } +.leaflet-control-zoom a { + background: var(--surface) !important; + color: var(--ink) !important; + border-color: var(--line) !important; +} + +/* High contrast */ +body.high-contrast { + --ink: #0a0a0a; + --ink-soft: #1f1f1f; + --ink-muted: #3b3b3b; + --paper: #fff; + --paper-deep: #f2f2f2; + --surface: #fff; + --surface-soft: #fff; + --line: #111; + --brand: #a93a00; + --brand-strong: #8d2f00; + --info: #005a82; +} + +/* Desktop second */ +@media (min-width: 940px) { + html, body { font-size: 16px; } + + .app { + max-width: 1120px; + margin: 0 auto; + padding: 1.35rem 1.3rem calc(6.9rem + env(safe-area-inset-bottom, 0px)); + } + + .masthead { + position: static; + display: grid; + grid-template-columns: auto 1fr; + align-items: center; + gap: 1rem; + margin-bottom: 1rem; + padding: 0; + border: none; + box-shadow: none; + background: transparent; + backdrop-filter: none; + } + + .masthead h1 { margin: 0; font-size: 2rem; } + + .tabs { + position: fixed; + left: 0; + right: 0; + bottom: 0; + width: 100vw; + max-width: 100vw; + z-index: 300; + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 0.22rem; + padding: 0.45rem 0.28rem calc(0.45rem + env(safe-area-inset-bottom, 0px)); + background: rgba(9, 28, 40, 0.92); + backdrop-filter: blur(12px); + border-top: 1px solid rgba(121, 165, 191, 0.36); + border-radius: 0; + } + + .tab { + min-height: 58px; + flex-direction: column; + border-radius: 12px; + border-right: none; + padding: 0.32rem 0.15rem; + background: rgba(255, 255, 255, 0.04); + color: #d8eaf4; + gap: 0.24rem; + } + + .tab-icon { + font-family: 'Apple Color Emoji', 'Segoe UI Emoji', 'Noto Color Emoji', sans-serif; + font-size: 1.18rem; + line-height: 1; + } + + .tab-text { + font-size: 0.67rem; + line-height: 1; + letter-spacing: 0.02em; + font-weight: 600; + } + + .tab.active { + color: #fff; + background: linear-gradient(180deg, var(--brand) 0%, var(--brand-strong) 100%); + } + + .tagline { margin: 0; } + + .search-row input { min-height: 48px; } + #search-btn { min-width: 140px; } + + .loc-header { + flex-direction: row; + justify-content: space-between; + align-items: flex-end; + } + + .loc-actions { align-items: flex-end; } + + .current-grid { grid-template-columns: 1fr 1fr; } + .stats-bar { grid-template-columns: repeat(6, minmax(0, 1fr)); } + + .astro-grid, + .aq-grid, + .maps-meta, + .maps-controls { grid-template-columns: repeat(3, minmax(0, 1fr)); } + + .maps-controls { grid-template-columns: 1fr 1fr; } + .alerts-list { grid-template-columns: 1fr 1fr; } + + .day-row { + grid-template-columns: 120px 44px 1fr 84px 64px; + grid-template-areas: none; + align-items: center; + gap: 0.8rem; + } + + .day-name { grid-area: auto; } + .day-icon { display: block; font-size: 1.36rem; text-align: center; } + .day-desc { grid-area: auto; white-space: normal; } + .day-temps { grid-area: auto; text-align: right; } + .day-meta { + display: block; + text-align: right; + font-size: 0.67rem; + color: var(--ink-muted); + line-height: 1.35; + } + + .saved-item { grid-template-columns: 36px 1fr 36px 36px 36px auto; } + .saved-meta { + display: block; + padding: 0 0.58rem; + font-size: 0.68rem; + color: var(--ink-muted); + } + + .settings-row { + flex-direction: row; + justify-content: space-between; + align-items: center; + } + + .section-head { + flex-direction: row; + justify-content: space-between; + align-items: center; + margin-left: 0; + margin-right: 0; + } + + .radar-timeline { + order: 0; + flex-basis: auto; + flex: 1; + min-width: 180px; + } + + .radar-ts { margin-left: auto; } + + .radar-map-full { + height: calc(100vh - 300px); + min-height: 440px; + } + + .tab-panel { min-height: auto; } +} diff --git a/.tmp/imgdiff/old/sw.js b/.tmp/imgdiff/old/sw.js new file mode 100644 index 0000000..22c715d --- /dev/null +++ b/.tmp/imgdiff/old/sw.js @@ -0,0 +1,123 @@ +'use strict'; + +const CACHE = 'weather-v4'; + +// App shell — cached on install for offline use +const PRECACHE = [ + '/', + '/app.js?v=4', + '/style.css?v=4', + '/manifest.json', + '/icons/icon.svg' +]; + +self.addEventListener('install', event => { + event.waitUntil( + caches.open(CACHE) + .then(cache => cache.addAll(PRECACHE)) + .then(() => self.skipWaiting()) + ); +}); + +self.addEventListener('activate', event => { + event.waitUntil( + caches.keys() + .then(keys => Promise.all( + keys.filter(k => k !== CACHE).map(k => caches.delete(k)) + )) + .then(() => self.clients.claim()) + ); +}); + +self.addEventListener('fetch', event => { + const { request } = event; + const url = new URL(request.url); + + // Skip non-GET, API calls, and map/radar tile requests + if ( + request.method !== 'GET' || + url.pathname.startsWith('/api/') || + url.hostname.includes('rainviewer') || + url.hostname.includes('openstreetmap') || + url.hostname.includes('cartodb') || + url.hostname.includes('basemaps') + ) { + return; + } + + const isAppShellRequest = + request.mode === 'navigate' || + request.destination === 'document' || + request.destination === 'script' || + request.destination === 'style'; + + // Prefer fresh app shell assets so UI updates are visible immediately. + if (isAppShellRequest) { + event.respondWith( + fetch(request) + .then(response => { + if (response.ok) { + const clone = response.clone(); + caches.open(CACHE).then(cache => cache.put(request, clone)); + } + return response; + }) + .catch(() => caches.match(request).then(cached => cached || caches.match('/'))) + ); + return; + } + + event.respondWith( + caches.match(request).then(cached => { + if (cached) return cached; + return fetch(request).then(response => { + // Only cache successful same-origin and trusted CDN responses + if (response.ok && ( + url.origin === self.location.origin || + url.hostname.includes('googleapis') || + url.hostname.includes('gstatic') || + url.hostname.includes('unpkg.com') + )) { + const clone = response.clone(); + caches.open(CACHE).then(cache => cache.put(request, clone)); + } + return response; + }); + }) + ); +}); + +self.addEventListener('push', event => { + let payload = { title: 'Weather alert', body: 'New weather update available.', url: '/' }; + try { + const data = event.data ? event.data.json() : null; + if (data) payload = { ...payload, ...data }; + } catch { + // Ignore malformed payload and use defaults. + } + + event.waitUntil( + self.registration.showNotification(payload.title, { + body: payload.body, + icon: '/icons/icon.svg', + badge: '/icons/icon.svg', + data: { url: payload.url || '/' }, + }) + ); +}); + +self.addEventListener('notificationclick', event => { + event.notification.close(); + const target = event.notification?.data?.url || '/'; + event.waitUntil( + clients.matchAll({ type: 'window', includeUncontrolled: true }).then(windowClients => { + const existing = windowClients.find(c => c.url.includes(self.location.origin)); + if (existing) { + existing.focus(); + existing.navigate(target); + return; + } + return clients.openWindow(target); + }) + ); +}); diff --git a/.tmp/imgdiff/server.diff b/.tmp/imgdiff/server.diff new file mode 100644 index 0000000..02d1571 Binary files /dev/null and b/.tmp/imgdiff/server.diff differ diff --git a/.tmp/imgdiff/style.diff b/.tmp/imgdiff/style.diff new file mode 100644 index 0000000..1236296 Binary files /dev/null and b/.tmp/imgdiff/style.diff differ diff --git a/.tmp/imgdiff/sw.diff b/.tmp/imgdiff/sw.diff new file mode 100644 index 0000000..b64e17a Binary files /dev/null and b/.tmp/imgdiff/sw.diff differ diff --git a/app/public/app.js b/app/public/app.js index 873c03d..74e7b7c 100644 --- a/app/public/app.js +++ b/app/public/app.js @@ -31,6 +31,16 @@ const STORAGE_KEYS = { autoDetect: 'weather.autoDetect', showLightning: 'weather.showLightning', showWind: 'weather.showWind', + showRadarLayer: 'weather.map.radar', + showSatelliteLayer: 'weather.map.satellite', + showLightningLayer: 'weather.map.lightning', + showWindLayer: 'weather.map.wind', + showAqiLayer: 'weather.map.aqi', + showAlertsLayer: 'weather.map.alerts', + showTempMarker: 'weather.map.temp', + showSunMoonMarkers: 'weather.map.sunMoon', + unifiedMapBaseLayer: 'weather.map.baseLayer', + unifiedMapRadarMode: 'weather.map.radarMode', }; const state = { @@ -48,7 +58,23 @@ const state = { lastRefreshAt: 0, lightningMarkers: [], windArrows: [], + aqiMarkers: [], + alertMarkers: [], + tempMarker: null, + sunMoonMarkers: [], showExtendedForecast: false, + unifiedMapLayers: { + radar: localStorage.getItem(STORAGE_KEYS.showRadarLayer) !== 'false', + satellite: localStorage.getItem(STORAGE_KEYS.showSatelliteLayer) !== 'false', + lightning: localStorage.getItem(STORAGE_KEYS.showLightningLayer) !== 'false', + wind: localStorage.getItem(STORAGE_KEYS.showWindLayer) !== 'false', + aqi: localStorage.getItem(STORAGE_KEYS.showAqiLayer) !== 'false', + alerts: localStorage.getItem(STORAGE_KEYS.showAlertsLayer) !== 'false', + temp: localStorage.getItem(STORAGE_KEYS.showTempMarker) !== 'false', + sunMoon: localStorage.getItem(STORAGE_KEYS.showSunMoonMarkers) !== 'false', + }, + unifiedMapBaseLayer: localStorage.getItem(STORAGE_KEYS.unifiedMapBaseLayer) || 'dark', + unifiedMapRadarMode: localStorage.getItem(STORAGE_KEYS.unifiedMapRadarMode) || 'radar', }; // ── DOM ────────────────────────────────────────────────────────────────────── @@ -315,6 +341,7 @@ function renderSavedCities() { } // ── Tab switching ───────────────────────────────────────────────────────────── + function activateTab(tabName) { document.querySelectorAll('.tab, .bottom-tab').forEach(t => t.classList.remove('active')); document.querySelectorAll('.tab-panel').forEach(p => hide(p)); @@ -322,24 +349,23 @@ function activateTab(tabName) { show(document.getElementById('tab-' + tabName)); if (tabName === 'conditions') { - setTimeout(() => { radarEmbed.refresh && radarEmbed.refresh(); }, 60); + setTimeout(() => { radarEmbed && radarEmbed.refresh && radarEmbed.refresh(); }, 60); } - if (tabName === 'radar-full') { - setTimeout(() => { radarFull.refresh && radarFull.refresh(); }, 50); - } - if (tabName === 'maps') { - setTimeout(refreshMapsOverview, 80); + if (tabName === 'unified-map') { + setTimeout(() => { unifiedMap && unifiedMap.refresh && unifiedMap.refresh(); }, 50); } } - document.querySelectorAll('.tab, .bottom-tab').forEach(btn => { btn.addEventListener('click', () => activateTab(btn.dataset.tab)); }); -document.getElementById('open-radar-tab').addEventListener('click', () => { - activateTab('radar-full'); -}); +const openMapTabBtn = document.getElementById('open-map-tab'); +if (openMapTabBtn) { + openMapTabBtn.addEventListener('click', () => { + activateTab('unified-map'); + }); +} // ── APIs ───────────────────────────────────────────────────────────────────── async function geocode(q) { const r = await fetch(`/api/geocode?q=${encodeURIComponent(q)}`); diff --git a/app/public/index.html b/app/public/index.html index d45ef07..e40a5b4 100644 --- a/app/public/index.html +++ b/app/public/index.html @@ -3,17 +3,15 @@ -Weather +NeCloud Weather - + - - @@ -21,11 +19,10 @@
-

Weather

+

NeCloud Weather

@@ -46,7 +43,13 @@ @@ -64,47 +67,132 @@
- -
-
-
--°F
+ +
+
+
+
+
+
+
+
--°F
Feels like --°F
-
↑--° · ↓--°
-
-
-
-
+
+
+
+ ↑ + --° +
High
+
+
+
+ ↓ + --° +
Low
+
+
- -
-
Humidity
--%
-
Wind
--mph
-
Pressure
--hPa
-
Visibility
--mi
-
UV Index
--
-
Cloud Cover
--%
+ +
+
+
+ 🌅 + -- +
+
+
+
+
☀️
+
+
+
+ 🌇 + -- +
+
+
+ 🌙 + -- + -- +
-
-
Sunrise
--
-
Sunset
--
-
Moon Phase
--
+ +
+ +
+
+
+
Wind
+
-- mph
+
--
+
+
+
Gusts
+
-- mph
+
+
+
Humidity
+
--%
+
+
+
+
Dewpoint
+
--°
+
+
+
Pressure
+
-- hPa
+
+
+
Visibility
+
-- mi
+
-
-
US AQI
--
-
PM2.5
--
-
Pollen
--
+ +
+ More Details › +
+
+
+
UV Index
+
--
+
+
+
Cloud Cover
+
--%
+
+
+
US AQI
+
--
+
+
+
PM2.5
+
--
+
+
+
Pollen
+
--
+
+
+
+
+ + +
+
+
- +
@@ -128,7 +216,8 @@
- + +
@@ -140,40 +229,97 @@
- -