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 ab383c1..a3cc624 100644 --- a/app/public/app.js +++ b/app/public/app.js @@ -31,65 +31,53 @@ const STORAGE_KEYS = { autoDetect: 'weather.autoDetect', showLightning: 'weather.showLightning', showWind: 'weather.showWind', - defaultCity: 'weather.defaultCity', - highContrast: 'weather.highContrast', - radarOpacity: 'weather.radarOpacity', - lightningAgeMin: 'weather.lightningAgeMin', + 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', }; -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, + currentAlerts: null, currentSource: 'startup', - savedCities: parseSavedCities(localStorage.getItem(STORAGE_KEYS.savedCities)), + savedCities: JSON.parse(localStorage.getItem(STORAGE_KEYS.savedCities) || '[]'), alertIdsSeen: new Set(), autoRefreshTimer: null, lastRefreshAt: 0, - lightningLayer: null, + lightningMarkers: [], windArrows: [], - dailyExpanded: false, - pushSupported: false, - pushEnabled: false, + 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', }; -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'); @@ -101,9 +89,13 @@ 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 shareHeroBtn = document.getElementById('share-hero-btn'); +const shareWeatherCardBtn = document.getElementById('share-weather-card-btn'); const saveCurrentBtn = document.getElementById('save-current-btn'); const savedLocationsEl = document.getElementById('saved-locations'); const notifyBtn = document.getElementById('notify-btn'); +const shareHeroStatusEl = document.getElementById('share-hero-status'); +const shareWeatherStatusEl = document.getElementById('share-weather-status'); const autoDetectToggle = document.getElementById('auto-detect-toggle'); const alertsEmptyEl = document.getElementById('alerts-empty'); const settingsLocationEl = document.getElementById('settings-location'); @@ -111,18 +103,8 @@ const mapsLocationEl = document.getElementById('maps-location'); const mapsWindEl = document.getElementById('maps-wind'); const lightningToggle = document.getElementById('lightning-toggle'); const windToggle = document.getElementById('wind-toggle'); -const lightningStatus = document.getElementById('lightning-status'); -const windStatus = document.getElementById('wind-status'); +const dailyToggleBtn = document.getElementById('daily-toggle-btn'); const dailyForecastLabel = document.getElementById('daily-forecast-label'); -const dailyForecastToggle = document.getElementById('daily-forecast-toggle'); -const pushTestBtn = document.getElementById('push-test-btn'); -const hourlySummaryEl = document.getElementById('hourly-summary'); -const defaultCityEl = document.getElementById('default-city'); -const contrastToggle = document.getElementById('contrast-toggle'); -const contrastStatus = document.getElementById('contrast-status'); -const radarOpacityEl = document.getElementById('radar-opacity'); -const lightningAgeEl = document.getElementById('lightning-age'); -const mapsFullscreenBtn = document.getElementById('maps-fullscreen-btn'); // ── Helpers ────────────────────────────────────────────────────────────────── const show = el => el.classList.remove('hidden'); @@ -134,6 +116,10 @@ function compassDir(deg) { return ['N','NE','E','SE','S','SW','W','NW'][Math.round(deg / 45) % 8]; } +function compassDirFull(deg) { + return ['North','Northeast','East','Southeast','South','Southwest','West','Northwest'][Math.round(deg / 45) % 8]; +} + function formatHour(iso) { const h = new Date(iso).getHours(); return h === 0 ? '12 AM' : h < 12 ? `${h} AM` : h === 12 ? '12 PM' : `${h-12} PM`; @@ -155,17 +141,20 @@ function formatClock(iso) { function tempDisplay(v) { if (v == null || Number.isNaN(v)) return '--'; - return Math.round(v); + const n = state.units === 'us' ? v : ((v - 32) * 5) / 9; + return Math.round(n); } function speedDisplay(v) { if (v == null || Number.isNaN(v)) return '--'; - return Math.round(v); + const n = state.units === 'us' ? v : v * 1.60934; + return Math.round(n); } function precipDisplay(v) { if (v == null || Number.isNaN(v)) return '--'; - return Number(v).toFixed(1); + const n = state.units === 'us' ? v : v * 25.4; + return n.toFixed(1); } function visDisplay(vMeters) { @@ -179,28 +168,6 @@ function tempUnit() { return state.units === 'us' ? 'F' : 'C'; } function speedUnit() { return state.units === 'us' ? 'mph' : 'km/h'; } function visUnit() { return state.units === 'us' ? 'mi' : 'km'; } -function weatherThemeFromCode(code) { - const n = Number(code); - if (Number.isNaN(n)) return 'cloud'; - if (n === 0 || n === 1) return 'clear'; - if (n === 2 || n === 3) return 'cloud'; - if (n === 45 || n === 48) return 'fog'; - if ((n >= 51 && n <= 67) || (n >= 80 && n <= 82)) return 'rain'; - if (n >= 71 && n <= 77) return 'snow'; - if (n >= 85 && n <= 86) return 'snow'; - if (n >= 95) return 'storm'; - return 'cloud'; -} - -function applyWeatherBackground(code, isDay) { - const theme = weatherThemeFromCode(code); - const body = document.body; - const themes = ['clear', 'cloud', 'rain', 'snow', 'storm', 'fog']; - themes.forEach(t => body.classList.remove(`weather-${t}`)); - body.classList.add(`weather-${theme}`); - body.classList.toggle('weather-night', Number(isDay) === 0); -} - function updateUnitsButton() { const label = state.units === 'us' ? 'US' : 'Metric'; unitsBtn.textContent = label; @@ -215,91 +182,22 @@ function updateNotifyButton() { if (!('Notification' in window)) { notifyBtn.textContent = 'Notifications unsupported'; notifyBtn.disabled = true; - if (pushTestBtn) pushTestBtn.disabled = true; return; } if (!window.isSecureContext) { notifyBtn.textContent = 'Needs HTTPS'; notifyBtn.disabled = true; - if (pushTestBtn) pushTestBtn.disabled = true; return; } notifyBtn.disabled = false; if (Notification.permission === 'granted') { - notifyBtn.textContent = state.pushEnabled ? 'Push enabled' : 'Enable push'; + notifyBtn.textContent = 'Notifications enabled'; } else if (Notification.permission === 'denied') { notifyBtn.textContent = 'Notifications blocked'; } else { notifyBtn.textContent = 'Enable notifications'; } - - if (pushTestBtn) { - pushTestBtn.disabled = !(Notification.permission === 'granted' && state.pushEnabled); - } -} - -function applyHighContrast() { - document.body.classList.toggle('high-contrast', state.highContrast); - if (contrastStatus) contrastStatus.textContent = state.highContrast ? 'On' : 'Off'; -} - -function urlBase64ToUint8Array(base64String) { - const padding = '='.repeat((4 - base64String.length % 4) % 4); - const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/'); - const rawData = atob(base64); - const outputArray = new Uint8Array(rawData.length); - for (let i = 0; i < rawData.length; ++i) outputArray[i] = rawData.charCodeAt(i); - return outputArray; -} - -async function ensurePushSubscription() { - if (!('serviceWorker' in navigator) || !('PushManager' in window)) return false; - - const keyResp = await fetch('/api/push/public-key').then(r => r.json()).catch(() => null); - const publicKey = keyResp?.publicKey; - if (!publicKey) return false; - - const reg = await navigator.serviceWorker.ready; - let sub = await reg.pushManager.getSubscription(); - if (!sub) { - sub = await reg.pushManager.subscribe({ - userVisibleOnly: true, - applicationServerKey: urlBase64ToUint8Array(publicKey), - }); - } - - await fetch('/api/push/subscribe', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - subscription: sub, - location: state.currentCity ? { - latitude: state.currentCity.latitude, - longitude: state.currentCity.longitude, - } : null, - }), - }); - - state.pushEnabled = true; - updateNotifyButton(); - return true; -} - -async function sendTestPush() { - const resp = await fetch('/api/push/test', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - title: 'Weather push test', - body: state.currentCity ? `Alerts enabled for ${cityLabel(state.currentCity) || 'your location'}` : 'Push notifications are active.', - url: window.location.pathname, - }), - }); - if (!resp.ok) { - const err = await resp.json().catch(() => ({})); - throw new Error(err.error || 'Push test failed'); - } } function moonPhase(dateLike) { @@ -319,6 +217,58 @@ function precipType(code, tempF, rain, snowfall) { return 'Dry'; } +function calcDewpoint(tempF, rh) { + const tc = (tempF - 32) * 5 / 9; + const alpha = Math.log(rh / 100) + (17.62 * tc) / (243.12 + tc); + const dewC = (243.12 * alpha) / (17.62 - alpha); + return state.units === 'us' ? Math.round(dewC * 9 / 5 + 32) : Math.round(dewC); +} + +function humidityFeel(rh) { + if (rh < 25) return 'Very dry'; + if (rh < 35) return 'Dry'; + if (rh < 55) return 'Comfortable'; + if (rh < 65) return 'Moderate'; + if (rh < 75) return 'Humid'; + return 'Very humid'; +} + +function renderRecommendations(data) { + const c = data.current; + const d = data.daily; + const temp = c.temperature_2m; + const code = c.weather_code; + const uv = d.uv_index_max[0] ?? 0; + const wind = c.wind_speed_10m; + const items = []; + + if (temp <= 32) items.push({ icon: '🧥', label: 'Heavy coat' }); + else if (temp <= 50) items.push({ icon: '🧣', label: 'Jacket' }); + else if (temp >= 85) items.push({ icon: '👕', label: 'Light clothes' }); + + if ([51,53,55,61,63,65,66,67,80,81,82].includes(code)) { + items.push({ icon: '☂️', label: 'Umbrella' }); + items.push({ icon: '🥾', label: 'Waterproof\nshoes' }); + } + if ([71,73,75,77,85,86].includes(code)) { + items.push({ icon: '🥾', label: 'Snow boots' }); + items.push({ icon: '🧤', label: 'Gloves' }); + } + if (code >= 95) items.push({ icon: '⚡', label: 'Stay\nindoors' }); + if (c.is_day && uv >= 6) { + items.push({ icon: '🕶️', label: 'Sunglasses' }); + items.push({ icon: '🧴', label: 'Sunscreen' }); + } + if (wind >= 20) items.push({ icon: '💨', label: 'Windbreaker' }); + items.push({ icon: '💧', label: 'Water bottle' }); + + const grid = document.getElementById('recommendations'); + if (!grid) return; + grid.innerHTML = items.map(it => + `
${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'; @@ -331,8 +281,6 @@ function cityKey(city) { function persistSavedCities() { localStorage.setItem(STORAGE_KEYS.savedCities, JSON.stringify(state.savedCities)); - const defaultEntry = state.savedCities.find(e => e.isDefault); - localStorage.setItem(STORAGE_KEYS.defaultCity, defaultEntry ? cityKey(defaultEntry.city) : ''); } function persistLastCity(city) { @@ -348,46 +296,6 @@ function cityLabel(city) { return [city?.name, city?.admin1, city?.country].filter(Boolean).join(', '); } -function formatRelativeTime(ts) { - if (!ts) return 'Never opened'; - const mins = Math.max(1, Math.round((Date.now() - ts) / 60000)); - if (mins < 60) return `${mins}m ago`; - const hrs = Math.round(mins / 60); - if (hrs < 48) return `${hrs}h ago`; - const days = Math.round(hrs / 24); - return `${days}d ago`; -} - -function getDefaultSavedEntry() { - return state.savedCities.find(e => e.isDefault) || null; -} - -function updateDefaultCityLabel() { - if (!defaultCityEl) return; - const def = getDefaultSavedEntry(); - defaultCityEl.textContent = def?.city?.name || 'None'; -} - -function setDefaultCityByKey(key) { - state.savedCities = state.savedCities.map(e => ({ - ...e, - isDefault: cityKey(e.city) === key, - })); - persistSavedCities(); - updateDefaultCityLabel(); - renderSavedCities(); -} - -function moveSavedCity(index, direction) { - const next = index + direction; - if (next < 0 || next >= state.savedCities.length) return; - const arr = [...state.savedCities]; - [arr[index], arr[next]] = [arr[next], arr[index]]; - state.savedCities = arr; - persistSavedCities(); - renderSavedCities(); -} - function updateShareUrl(city) { if (!city) return; const params = new URLSearchParams(window.location.search); @@ -407,12 +315,10 @@ function renderSavedCities() { savedLocationsEl.innerHTML = ''; if (!state.savedCities.length) { savedLocationsEl.textContent = 'None yet'; - updateDefaultCityLabel(); return; } - state.savedCities.forEach((entry, index) => { - const city = entry.city; + state.savedCities.forEach(city => { const wrap = document.createElement('div'); wrap.className = 'saved-item'; @@ -420,35 +326,7 @@ function renderSavedCities() { chip.className = 'saved-chip'; chip.type = 'button'; chip.textContent = city.name; - chip.title = `${cityLabel(city)} • ${formatRelativeTime(entry.lastViewedAt)}`; - chip.addEventListener('click', () => { - entry.lastViewedAt = Date.now(); - persistSavedCities(); - loadAll(city, 'saved-location'); - }); - - const defaultBtn = document.createElement('button'); - defaultBtn.className = 'saved-default'; - defaultBtn.type = 'button'; - defaultBtn.title = entry.isDefault ? 'Default city' : 'Set as default city'; - defaultBtn.textContent = entry.isDefault ? '★' : '☆'; - defaultBtn.addEventListener('click', () => setDefaultCityByKey(cityKey(city))); - - const upBtn = document.createElement('button'); - upBtn.className = 'saved-move'; - upBtn.type = 'button'; - upBtn.title = 'Move up'; - upBtn.textContent = '↑'; - upBtn.disabled = index === 0; - upBtn.addEventListener('click', () => moveSavedCity(index, -1)); - - const downBtn = document.createElement('button'); - downBtn.className = 'saved-move'; - downBtn.type = 'button'; - downBtn.title = 'Move down'; - downBtn.textContent = '↓'; - downBtn.disabled = index === state.savedCities.length - 1; - downBtn.addEventListener('click', () => moveSavedCity(index, 1)); + chip.addEventListener('click', () => loadAll(city, 'saved-location')); const remove = document.createElement('button'); remove.className = 'saved-remove'; @@ -456,53 +334,57 @@ function renderSavedCities() { remove.textContent = 'x'; remove.addEventListener('click', (e) => { e.stopPropagation(); - state.savedCities = state.savedCities.filter(c => cityKey(c.city) !== cityKey(city)); + state.savedCities = state.savedCities.filter(c => cityKey(c) !== cityKey(city)); persistSavedCities(); renderSavedCities(); }); - const meta = document.createElement('span'); - meta.className = 'saved-meta'; - meta.textContent = formatRelativeTime(entry.lastViewedAt); - - wrap.appendChild(defaultBtn); wrap.appendChild(chip); - wrap.appendChild(upBtn); - wrap.appendChild(downBtn); wrap.appendChild(remove); - wrap.appendChild(meta); savedLocationsEl.appendChild(wrap); }); - - updateDefaultCityLabel(); } // ── Tab switching ───────────────────────────────────────────────────────────── -document.querySelectorAll('.tab').forEach(btn => { - btn.addEventListener('click', () => { - document.querySelectorAll('.tab').forEach(t => t.classList.remove('active')); - document.querySelectorAll('.tab-panel').forEach(p => hide(p)); - btn.classList.add('active'); - show(document.getElementById('tab-' + btn.dataset.tab)); - if (btn.dataset.tab === 'radar-full') { - setTimeout(() => { radarFull.map && radarFull.map.invalidateSize(); }, 50); - } - if (btn.dataset.tab === 'maps') { - setTimeout(refreshMapsOverview, 80); - } else { - setMapsFullscreen(false); - } + +function activateTab(tabName) { + document.querySelectorAll('.tab, .bottom-tab').forEach(t => t.classList.remove('active')); + document.querySelectorAll('.tab-panel').forEach(p => hide(p)); + document.querySelectorAll(`[data-tab="${tabName}"]`).forEach(t => t.classList.add('active')); + show(document.getElementById('tab-' + tabName)); + + if (tabName === 'conditions') { + setTimeout(() => { radarEmbed && radarEmbed.refresh && radarEmbed.refresh(); }, 60); + } + if (tabName === 'unified-map') { + setTimeout(() => { + if (unifiedMap && unifiedMap.refresh) unifiedMap.refresh(); + refreshMapsOverview(); + }, 60); + setTimeout(() => { + if (unifiedMap && unifiedMap.refresh) unifiedMap.refresh(); + refreshMapsOverview(); + }, 340); + } +} +document.querySelectorAll('.tab, .bottom-tab').forEach(btn => { + btn.addEventListener('click', () => activateTab(btn.dataset.tab)); +}); + + +const openMapTabBtn = document.getElementById('open-map-tab'); +if (openMapTabBtn) { + openMapTabBtn.addEventListener('click', () => { + activateTab('unified-map'); }); -}); - -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'); + if (!r.ok) { + const detail = await r.text().catch(() => ''); + throw new Error(`Geocoding failed (${r.status})${detail ? `: ${detail.slice(0, 180)}` : ''}`); + } return r.json(); } @@ -512,15 +394,40 @@ async function reverseGeocode(lat, lon) { return r.json(); } +async function fetchJsonWithTimeout(url, timeoutMs) { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeoutMs); + const response = await fetch(url, { cache: 'no-store', signal: controller.signal }); + clearTimeout(timeoutId); + + if (!response.ok) { + const detail = await response.text().catch(() => ''); + throw new Error(`Request failed (${response.status})${detail ? `: ${detail.slice(0, 180)}` : ''}`); + } + return response.json(); +} + async function fetchWeather(lat, lon, tz) { - const r = await fetch(`/api/weather?lat=${lat}&lon=${lon}&tz=${encodeURIComponent(tz||'auto')}&units=${encodeURIComponent(state.units)}`); - if (!r.ok) throw new Error('Weather fetch failed'); - return r.json(); + const proxyUrl = `/api/weather?lat=${lat}&lon=${lon}&tz=${encodeURIComponent(tz||'auto')}&units=${encodeURIComponent(state.units)}`; + + for (let attempt = 1; attempt <= 3; attempt++) { + try { + return await fetchJsonWithTimeout(proxyUrl, 22000); + } catch (err) { + const reason = err?.name === 'AbortError' ? 'timeout' : (err?.message || err); + if (attempt >= 3) { + throw new Error(`Weather proxy failed after ${attempt} attempts: ${reason}`); + } + await new Promise(resolve => setTimeout(resolve, 500 * attempt)); + } + } } async function fetchAirQuality(lat, lon, tz) { - const r = await fetch(`/api/air-quality?lat=${lat}&lon=${lon}&tz=${encodeURIComponent(tz||'auto')}`); - if (!r.ok) throw new Error('Air quality fetch failed'); + const url = `/api/air-quality?lat=${lat}&lon=${lon}&tz=${encodeURIComponent(tz||'auto')}`; + console.log('Fetching air quality from:', url); + const r = await fetch(url); + if (!r.ok) throw new Error(`Air quality fetch failed with status ${r.status}`); return r.json(); } @@ -530,16 +437,9 @@ async function fetchAlerts(lat, lon) { return r.json(); } -async function fetchLightning(lat, lon) { - const ageMin = Number(state.lightningAgeMin || 15); - const r = await fetch(`/api/lightning?lat=${lat}&lon=${lon}&age=${ageMin}`); - if (!r.ok) throw new Error('Lightning fetch failed'); - return r.json(); -} - function detectedCityFallback(lat, lon) { return { - name: `Auto-detected (${lat.toFixed(2)}, ${lon.toFixed(2)})`, + name: `Detected location (${lat.toFixed(2)}, ${lon.toFixed(2)})`, latitude: lat, longitude: lon, country: '', @@ -548,17 +448,6 @@ function detectedCityFallback(lat, lon) { }; } -function resolveDetectedName(place, fallback) { - const raw = (place?.name || '').trim(); - const genericName = !raw || /current\s*location|detected\s*location/i.test(raw); - if (!genericName) return raw; - - const displayRoot = (place?.display_name || '').split(',')[0]?.trim(); - if (displayRoot) return displayRoot; - - return fallback.name; -} - async function buildDetectedCity(lat, lon) { const fallback = detectedCityFallback(lat, lon); @@ -567,7 +456,6 @@ async function buildDetectedCity(lat, lon) { return { ...fallback, ...place, - name: resolveDetectedName(place, fallback), latitude: lat, longitude: lon, timezone: place.timezone || 'auto', @@ -579,6 +467,9 @@ async function buildDetectedCity(lat, lon) { // ── Radar engine ────────────────────────────────────────────────────────────── function createRadar(mapEl, timelineEl, playBtn, tsEl, layerBtn) { + const FRAME_INTERVAL_MS = 650; + const FRAME_FADE_MS = 240; + const map = L.map(mapEl, { center: [39.5, -98.35], zoom: 4, @@ -586,8 +477,15 @@ function createRadar(mapEl, timelineEl, playBtn, tsEl, layerBtn) { attributionControl: false, }); - L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', { - maxZoom: 19, + const baseMapType = mapEl?.id === 'unified-map' ? state.unifiedMapBaseLayer : 'dark'; + const baseMapUrl = baseMapType === 'light' + ? 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png' + : baseMapType === 'satellite' + ? 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}' + : 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png'; + + L.tileLayer(baseMapUrl, { + maxZoom: baseMapType === 'satellite' ? 18 : 19, }).addTo(map); let radarFrames = []; @@ -598,6 +496,9 @@ function createRadar(mapEl, timelineEl, playBtn, tsEl, layerBtn) { let playing = false; let animTimer = null; let locationMarker = null; + let visibleLayer = null; + let framesReady = false; + let loadingFrames = false; function activeFrames() { return mode === 'radar' ? radarFrames : satelliteFrames; @@ -610,6 +511,7 @@ function createRadar(mapEl, timelineEl, playBtn, tsEl, layerBtn) { } function frameTileUrl(frame) { + if (frame?.url) return frame.url; if (mode === 'satellite') { return `https://tilecache.rainviewer.com${frame.path}/256/{z}/{x}/{y}/0/0_0.png`; } @@ -623,12 +525,38 @@ function createRadar(mapEl, timelineEl, playBtn, tsEl, layerBtn) { if (!frame) return; if (!layers[mode][frame.time]) { - layers[mode][frame.time] = L.tileLayer(frameTileUrl(frame), { opacity: 0.75, maxZoom: 15 }); + const opts = { + opacity: 0, + maxZoom: 19, + }; + if (frame?.maxNativeZoom != null) opts.maxNativeZoom = frame.maxNativeZoom; + layers[mode][frame.time] = L.tileLayer(frameTileUrl(frame), opts); } - 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); + 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) => { @@ -661,7 +589,7 @@ function createRadar(mapEl, timelineEl, playBtn, tsEl, layerBtn) { function startPlay() { playing = true; playBtn.textContent = '⏸'; - animTimer = setInterval(step, 500); + animTimer = setInterval(step, FRAME_INTERVAL_MS); } function stopPlay() { @@ -677,6 +605,7 @@ function createRadar(mapEl, timelineEl, playBtn, tsEl, layerBtn) { if (!(radarFrames.length && satelliteFrames.length)) return; mode = mode === 'radar' ? 'satellite' : 'radar'; layerBtn.textContent = mode === 'radar' ? 'R' : 'S'; + visibleLayer = null; buildTimeline(); const idx = Math.min(currentIdx, activeFrames().length - 1); showFrame(Math.max(idx, 0)); @@ -684,29 +613,83 @@ function createRadar(mapEl, timelineEl, playBtn, tsEl, layerBtn) { } async function loadFrames() { + if (loadingFrames || framesReady) return; + loadingFrames = true; + let retries = 0; + const maxRetries = 2; + try { - const r = await fetch('/api/radar/times'); - const data = await r.json(); + 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 })); + radarFrames = []; + satelliteFrames = []; + (data.radar?.past || []).forEach(f => radarFrames.push({ + time: f.time, + path: f.path, + url: f.url, + maxNativeZoom: f.maxNativeZoom, + isForecast: false, + })); + (data.radar?.nowcast || []).forEach(f => radarFrames.push({ + time: f.time, + path: f.path, + url: f.url, + maxNativeZoom: f.maxNativeZoom, + isForecast: true, + })); + (data.satellite?.infrared || []).forEach(f => satelliteFrames.push({ + time: f.time, + path: f.path, + url: f.url, + maxNativeZoom: f.maxNativeZoom, + isForecast: false, + })); - if (!radarFrames.length && satelliteFrames.length) mode = 'satellite'; - if (!radarFrames.length && !satelliteFrames.length) return; + 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'; + if (layerBtn) { + layerBtn.disabled = !(radarFrames.length && satelliteFrames.length); + layerBtn.textContent = mode === 'radar' ? 'R' : 'S'; + } + + buildTimeline(); + const frames = activeFrames(); + showFrame(Math.max(frames.length - 1, 0)); + framesReady = true; + return; + } catch (e) { + retries++; + if (e.name === 'AbortError') { + console.warn(`Radar load attempt ${retries} timed out - API slow or unavailable`); + } else { + console.error(`Radar load attempt ${retries} failed:`, e.message); + } + + if (retries >= maxRetries) { + console.error('Radar load failed after', maxRetries, 'attempts'); + return; + } + + await new Promise(resolve => setTimeout(resolve, 1000)); } - - buildTimeline(); - const frames = activeFrames(); - showFrame(frames.length - 1); - } catch (e) { - console.error('Radar load failed', e); + } + } finally { + loadingFrames = false; } } @@ -722,25 +705,55 @@ function createRadar(mapEl, timelineEl, playBtn, tsEl, layerBtn) { }).addTo(map); } - loadFrames(); - return { map, panTo, startPlay, stopPlay }; + function isMapVisible() { + return mapEl.offsetWidth > 0 && mapEl.offsetHeight > 0; + } + + function refresh() { + if (!isMapVisible()) return; + map.invalidateSize(); + loadFrames(); + } + + // Wait for visibility before first load to avoid zero-size Leaflet rendering. + setTimeout(() => { + let tries = 0; + const maxTries = 40; // ~10 seconds at 250ms interval + const timer = setInterval(() => { + tries++; + if (isMapVisible()) { + refresh(); + clearInterval(timer); + } else if (tries >= maxTries) { + clearInterval(timer); + } + }, 250); + }, 100); + + return { map, panTo, startPlay, stopPlay, refresh }; } -const radarEmbed = createRadar( - document.getElementById('radar-embed'), - document.getElementById('embed-timeline'), - document.getElementById('embed-play'), - document.getElementById('embed-timestamp'), - document.getElementById('embed-layer') -); +const radarEmbedEl = document.getElementById('radar-embed'); +const radarEmbed = radarEmbedEl + ? createRadar( + radarEmbedEl, + document.getElementById('embed-timeline'), + document.getElementById('embed-play'), + document.getElementById('embed-timestamp'), + document.getElementById('embed-layer') + ) + : { map: null, panTo: () => {}, startPlay: () => {}, stopPlay: () => {}, refresh: () => {} }; -const radarFull = createRadar( - document.getElementById('radar-full'), - document.getElementById('full-timeline'), - document.getElementById('full-play'), - document.getElementById('full-timestamp'), - document.getElementById('full-layer') -); +const radarFullEl = document.getElementById('radar-full'); +const radarFull = radarFullEl + ? createRadar( + radarFullEl, + document.getElementById('full-timeline'), + document.getElementById('full-play'), + document.getElementById('full-timestamp'), + document.getElementById('full-layer') + ) + : { map: null, panTo: () => {}, startPlay: () => {}, stopPlay: () => {}, refresh: () => {} }; function createOverviewMap(mapEl) { const map = L.map(mapEl, { @@ -754,35 +767,6 @@ function createOverviewMap(mapEl) { maxZoom: 18, }).addTo(map); - const radarLayer = L.tileLayer('https://tilecache.rainviewer.com/v2/radar/nowcast_0/256/{z}/{x}/{y}/2/1_1.png', { - maxZoom: 15, - opacity: Math.max(0.25, Math.min(0.9, state.radarOpacity / 100)), - }).addTo(map); - - let lastRadarFramePath = null; - - async function refreshRadarOverlay() { - try { - const r = await fetch('/api/radar/times'); - if (!r.ok) return; - const data = await r.json(); - const frames = [ - ...(data?.radar?.past || []), - ...(data?.radar?.nowcast || []), - ]; - const latest = frames[frames.length - 1]; - const path = latest?.path; - if (!path || path === lastRadarFramePath) return; - lastRadarFramePath = path; - radarLayer.setUrl(`https://tilecache.rainviewer.com${path}/256/{z}/{x}/{y}/2/1_1.png`); - } catch (e) { - console.warn('Maps radar overlay refresh failed', e); - } - } - - refreshRadarOverlay(); - setInterval(refreshRadarOverlay, 5 * 60 * 1000); - let marker = null; function panTo(lat, lon, zoom) { @@ -797,20 +781,13 @@ function createOverviewMap(mapEl) { }).addTo(map); } - function setRadarOpacity(opacityPct) { - const normalized = Math.max(0.25, Math.min(0.9, Number(opacityPct) / 100)); - radarLayer.setOpacity(normalized); - } - - return { map, panTo, setRadarOpacity, refreshRadarOverlay }; + return { map, panTo }; } -function clearLightningLayer() { +function clearLightningMarkers() { if (!mapsOverview.map) return; - if (state.lightningLayer) { - mapsOverview.map.removeLayer(state.lightningLayer); - state.lightningLayer = null; - } + state.lightningMarkers.forEach(marker => mapsOverview.map.removeLayer(marker)); + state.lightningMarkers = []; } function clearWindArrows() { @@ -823,88 +800,32 @@ function refreshMapsOverview() { if (!mapsOverview.map) return; mapsOverview.map.invalidateSize(); - mapsOverview.refreshRadarOverlay(); if (!state.currentCity) return; - mapsOverview.panTo(state.currentCity.latitude, state.currentCity.longitude, 6); + const zoom = mapsOverview.map.getZoom ? mapsOverview.map.getZoom() : 6; + mapsOverview.panTo(state.currentCity.latitude, state.currentCity.longitude, zoom || 6); - clearLightningLayer(); + clearLightningMarkers(); clearWindArrows(); + clearAqiMarkers(); + clearAlertMarkers(); + clearSunMoonMarkers(); + clearTempMarker(); - if (state.showLightning) renderLightningStrikes(state.currentCity); - if (state.showWind && state.currentWeather) renderWindArrows(state.currentCity); + if (state.showLightning || state.unifiedMapLayers.lightning) renderLightningStrikes(state.currentCity); + if ((state.showWind || state.unifiedMapLayers.wind) && state.currentWeather) renderWindArrows(state.currentCity); + renderAqiMarker(state.currentCity); + renderAlertMarker(state.currentCity); + renderTempMarker(state.currentCity); + renderSunMoonMarker(state.currentCity); } -function circleCenter(coords) { - let sumLat = 0; - let sumLon = 0; - let count = 0; - coords.forEach(([lon, lat]) => { - sumLat += lat; - sumLon += lon; - count += 1; - }); - if (!count) return null; - return [sumLat / count, sumLon / count]; -} - -async function renderLightningStrikes(city) { +function renderLightningStrikes(city) { if (!mapsOverview.map || !state.showLightning) return; - clearLightningLayer(); + clearLightningMarkers(); - 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); - } + // Simulated strikes were removed. Only live strike feeds should render here. } function renderWindArrows(city) { @@ -913,150 +834,228 @@ function renderWindArrows(city) { // Get current wind data const current = state.currentWeather.current; - if (!current || !current.wind_speed_10m) return; + if (!current || current.wind_speed_10m == null) return; const windSpeed = current.wind_speed_10m; + const windGust = current.wind_gusts_10m || 0; const windDir = current.wind_direction_10m || 0; - // Draw a wind barb pointing in the direction of wind movement + // Update wind data display + const windSpeedEl = document.getElementById('wind-speed'); + const windGustEl = document.getElementById('wind-gust'); + const windDirectionEl = document.getElementById('wind-direction'); + if (windSpeedEl) windSpeedEl.textContent = `${speedDisplay(windSpeed)} ${speedUnit()}`; + if (windGustEl) windGustEl.textContent = windGust > 0 ? `${speedDisplay(windGust)} ${speedUnit()}` : '--'; + if (windDirectionEl) windDirectionEl.textContent = compassDirFull(windDir); + + // Draw a denser set of arrows around the city center for richer wind context. 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 arrowCount = Math.max(3, Math.min(9, Math.ceil(windSpeed / 3))); // 3-9 arrows + const baseRadius = 0.12; -const mapsOverview = createOverviewMap(document.getElementById('map-overview')); - -function setMapsFullscreen(active) { - const wrap = document.querySelector('#tab-maps .maps-wrap'); - if (!wrap) return; - wrap.classList.toggle('is-fullscreen', active); - document.body.classList.toggle('map-fullscreen-lock', active); - if (mapsFullscreenBtn) { - mapsFullscreenBtn.textContent = active ? 'Exit fullscreen' : 'Fullscreen map'; - } - setTimeout(() => { - if (mapsOverview?.map) mapsOverview.map.invalidateSize(); - }, 100); -} - -if (mapsFullscreenBtn) { - mapsFullscreenBtn.addEventListener('click', async () => { - const wrap = document.querySelector('#tab-maps .maps-wrap'); - if (!wrap) return; - - if (document.fullscreenElement === wrap) { - await document.exitFullscreen().catch(() => {}); - setMapsFullscreen(false); - return; - } - - if (wrap.requestFullscreen) { - try { - await wrap.requestFullscreen(); - setMapsFullscreen(true); - return; - } catch { - // Some mobile browsers reject Fullscreen API; fallback below. + for (let i = 0; i < arrowCount; i++) { + const bandPos = i - (arrowCount - 1) / 2; + const offsetLat = lat + (bandPos * 0.045); + const offsetLon = lon + (Math.abs(bandPos) % 2 ? 0.025 : -0.025); + + // Calculate arrow end point based on wind direction + const radians = (windDir * Math.PI) / 180; + const arrowLength = baseRadius + (Math.abs(bandPos) * 0.028) + Math.min(windSpeed / 120, 0.08); + 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 ? '#ff3333' : windSpeed > 10 ? '#ffaa00' : '#4a90e2'; + const weight = 2.5 + (i % 2); + + const arrow = L.polyline( + [[offsetLat, offsetLon], [endLat, endLon]], + { + color: color, + weight: weight, + opacity: 1.0, + smooth: true, } - } - - setMapsFullscreen(!wrap.classList.contains('is-fullscreen')); - }); + ).bindPopup( + `💨 Wind: ${speedDisplay(windSpeed)} ${speedUnit()}
Gust: ${windGust > 0 ? `${speedDisplay(windGust)} ${speedUnit()}` : '--'}
Direction: ${compassDirFull(windDir)} (${Math.round(windDir)}°)` + ); + + arrow.addTo(mapsOverview.map); + state.windArrows.push(arrow); + + // Draw arrow head + const headSize = 0.05; + 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: 1.0, + } + ); + + arrowHead.addTo(mapsOverview.map); + state.windArrows.push(arrowHead); + } } -document.addEventListener('fullscreenchange', () => { - const wrap = document.querySelector('#tab-maps .maps-wrap'); - if (!wrap) return; - setMapsFullscreen(document.fullscreenElement === wrap); +function clearAqiMarkers() { + if (!mapsOverview.map) return; + state.aqiMarkers.forEach(marker => mapsOverview.map.removeLayer(marker)); + state.aqiMarkers = []; +} + +function clearAlertMarkers() { + if (!mapsOverview.map) return; + state.alertMarkers.forEach(marker => mapsOverview.map.removeLayer(marker)); + state.alertMarkers = []; +} + +function clearSunMoonMarkers() { + if (!mapsOverview.map) return; + state.sunMoonMarkers.forEach(marker => mapsOverview.map.removeLayer(marker)); + state.sunMoonMarkers = []; +} + +function clearTempMarker() { + if (!mapsOverview.map || !state.tempMarker) return; + mapsOverview.map.removeLayer(state.tempMarker); + state.tempMarker = null; +} + +function renderAqiMarker(city) { + if (!mapsOverview.map || !state.unifiedMapLayers.aqi || !state.currentAir?.current) return; + clearAqiMarkers(); + const aqi = state.currentAir.current.us_aqi; + if (aqi == null) return; + const color = aqi > 150 ? '#d32f2f' : aqi > 100 ? '#f57c00' : aqi > 50 ? '#fbc02d' : '#388e3c'; + const aqiLat = Number(city.latitude) - 0.045; + const aqiLon = Number(city.longitude) - 0.06; + const marker = L.circleMarker([aqiLat, aqiLon], { + radius: 9, + fillColor: color, + fillOpacity: 0.85, + color: '#fff', + weight: 2, + }).bindPopup(`AQI ${aqi}`); + marker.bindTooltip(`AQI ${aqi}`, { + permanent: true, + direction: 'left', + offset: [-10, 0], + className: 'map-label map-label-aqi', + opacity: 0.95, + }); + marker.addTo(mapsOverview.map); + state.aqiMarkers.push(marker); +} + +function renderAlertMarker(city) { + if (!mapsOverview.map || !state.unifiedMapLayers.alerts || !state.currentAlerts?.features?.length) return; + clearAlertMarkers(); + const marker = L.circleMarker([city.latitude, city.longitude], { + radius: 10, + fillColor: '#d32f2f', + fillOpacity: 0.8, + color: '#fff', + weight: 2, + }).bindPopup(`${state.currentAlerts.features.length} active alert(s)`); + marker.addTo(mapsOverview.map); + state.alertMarkers.push(marker); +} + +function renderTempMarker(city) { + if (!mapsOverview.map || !state.unifiedMapLayers.temp || !state.currentWeather?.current) return; + clearTempMarker(); + const temp = tempDisplay(state.currentWeather.current.temperature_2m); + const tempLat = Number(city.latitude) + 0.05; + const tempLon = Number(city.longitude) - 0.055; + const marker = L.circleMarker([tempLat, tempLon], { + radius: 11, + fillColor: '#1e88e5', + fillOpacity: 0.9, + color: '#fff', + weight: 2, + }).bindPopup(`${temp}°${tempUnit()}`); + marker.bindTooltip(`${temp}°${tempUnit()}`, { + permanent: true, + direction: 'right', + offset: [10, 0], + className: 'map-label map-label-temp', + opacity: 0.95, + }); + marker.addTo(mapsOverview.map); + state.tempMarker = marker; +} + +function renderSunMoonMarker(city) { + if (!mapsOverview.map || !state.unifiedMapLayers.sunMoon || !state.currentWeather?.daily) return; + clearSunMoonMarkers(); + const daily = state.currentWeather.daily; + const sunrise = formatClock(daily.sunrise?.[0]); + const sunset = formatClock(daily.sunset?.[0]); + const smLat = Number(city.latitude) + 0.05; + const smLon = Number(city.longitude) + 0.06; + const marker = L.circleMarker([smLat, smLon], { + radius: 8, + fillColor: '#fbc02d', + fillOpacity: 0.8, + color: '#fff', + weight: 2, + }).bindPopup(`Sunrise: ${sunrise}
Sunset: ${sunset}`); + marker.bindTooltip(`☀ ${sunrise} • 🌙 ${sunset}`, { + permanent: true, + direction: 'top', + offset: [0, -10], + className: 'map-label map-label-sunmoon', + opacity: 0.95, + }); + marker.addTo(mapsOverview.map); + state.sunMoonMarkers.push(marker); +} + +const unifiedMapModeButton = document.createElement('button'); +let unifiedMapMode = 'radar'; + +const unifiedMapEl = document.getElementById('unified-map'); +const legacyMapEl = document.getElementById('map-overview'); +const mapsOverview = unifiedMapEl + ? createRadar( + unifiedMapEl, + document.getElementById('timeline-slider'), + document.getElementById('timeline-play'), + document.getElementById('timeline-timestamp'), + unifiedMapModeButton + ) + : (legacyMapEl + ? createOverviewMap(legacyMapEl) + : { map: null, panTo: () => {}, refresh: () => {} }); + +const unifiedMap = mapsOverview; + +unifiedMapModeButton.addEventListener('click', () => { + unifiedMapMode = unifiedMapMode === 'radar' ? 'satellite' : 'radar'; + localStorage.setItem(STORAGE_KEYS.unifiedMapRadarMode, unifiedMapMode); }); -// ── 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); - } - } +if (unifiedMapEl && state.unifiedMapRadarMode === 'satellite') { + unifiedMapModeButton.click(); } -function summarizeHourly(hourly, startIdx) { - if (!hourly?.time?.length || !hourly?.precipitation_probability?.length) { - return 'Hourly details unavailable.'; - } +// ── Rendering ──────────────────────────────────────────────────────────────── +function setMapOverlayReadouts({ temp, aqi, sunmoon } = {}) { + const tempEl = document.getElementById('overlay-temp'); + const aqiEl = document.getElementById('overlay-aqi'); + const sunmoonEl = document.getElementById('overlay-sunmoon'); - 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])}.`; + if (tempEl && temp != null && temp !== '') tempEl.textContent = `Temp ${temp}`; + if (aqiEl && aqi != null && aqi !== '') aqiEl.textContent = `AQI ${aqi}`; + if (sunmoonEl && sunmoon) sunmoonEl.textContent = sunmoon; } function renderWeather(data, city) { @@ -1064,8 +1063,6 @@ function renderWeather(data, city) { const h = data.hourly; const d = data.daily; - applyWeatherBackground(c.weather_code, c.is_day); - document.getElementById('loc-name').textContent = city.name; document.getElementById('loc-sub').textContent = [city.admin1, city.country].filter(Boolean).join(', '); @@ -1084,18 +1081,59 @@ function renderWeather(data, city) { document.getElementById('cur-lo').textContent = tempDisplay(d.temperature_2m_min[0]); document.getElementById('cur-precip').textContent = c.precipitation > 0 ? `${precipDisplay(c.precipitation)} ${state.units === 'us' ? 'in' : 'mm'} precip` : 'No precipitation'; document.getElementById('cur-hum').textContent = c.relative_humidity_2m; - document.getElementById('cur-wind').textContent = `${speedDisplay(c.wind_speed_10m)} ${compassDir(c.wind_direction_10m)}`; + document.getElementById('hum-feel').textContent = humidityFeel(c.relative_humidity_2m); + document.getElementById('cur-wind').textContent = speedDisplay(c.wind_speed_10m); + document.getElementById('cur-wind-dir').textContent = compassDirFull(c.wind_direction_10m); + document.getElementById('cur-gust').textContent = c.wind_gusts_10m != null ? speedDisplay(c.wind_gusts_10m) : '—'; + document.getElementById('cur-dew').textContent = calcDewpoint(c.temperature_2m, c.relative_humidity_2m); document.getElementById('cur-pres').textContent = Math.round(c.surface_pressure); document.getElementById('cur-vis').textContent = visDisplay(c.visibility); + + const panelLocation = document.getElementById('panel-location-name'); + const panelWindSpeed = document.getElementById('panel-wind-speed'); + const panelWindDir = document.getElementById('panel-wind-dir'); + const panelTemp = document.getElementById('panel-temp'); + const panelAqi = document.getElementById('panel-aqi'); + const panelCloud = document.getElementById('panel-cloud'); + const panelWrap = document.getElementById('data-panel'); + if (panelLocation) panelLocation.textContent = cityLabel(city) || city.name || '--'; + if (panelWindSpeed) panelWindSpeed.textContent = `${speedDisplay(c.wind_speed_10m)} ${speedUnit()}`; + if (panelWindDir) panelWindDir.textContent = compassDirFull(c.wind_direction_10m || 0); + if (panelTemp) panelTemp.textContent = `${tempDisplay(c.temperature_2m)}°${tempUnit()}`; + if (panelAqi) panelAqi.textContent = state.currentAir?.current?.us_aqi ?? '--'; + if (panelCloud) panelCloud.textContent = c.cloud_cover != null ? `${Math.round(c.cloud_cover)}%` : '--'; + if (panelWrap) panelWrap.classList.remove('hidden'); document.getElementById('cur-uv').textContent = Math.round(d.uv_index_max[0] ?? 0); document.getElementById('cur-cloud').textContent = c.cloud_cover; - document.querySelector('.stat:nth-child(2) .unit').textContent = speedUnit(); - document.querySelector('.stat:nth-child(4) .unit').textContent = visUnit(); + document.querySelectorAll('.wind-unit, .gust-unit').forEach(el => el.textContent = speedUnit()); + document.querySelectorAll('.vis-unit').forEach(el => el.textContent = visUnit()); document.getElementById('sunrise-val').textContent = formatClock(d.sunrise[0]); document.getElementById('sunset-val').textContent = formatClock(d.sunset[0]); document.getElementById('moon-phase-val').textContent = moonPhase(d.time[0]); + const sunriseLabel = formatClock(d.sunrise[0]); + const sunsetLabel = formatClock(d.sunset[0]); + setMapOverlayReadouts({ + temp: `${tempDisplay(c.temperature_2m)}°${tempUnit()}`, + sunmoon: `Sun ${sunriseLabel}/${sunsetLabel}`, + }); + + // Sun timeline progress + const sunriseMs = new Date(d.sunrise[0]).getTime(); + const sunsetMs = new Date(d.sunset[0]).getTime(); + const nowMs = Date.now(); + const totalMs = sunsetMs - sunriseMs; + const sunPct = Math.max(0, Math.min(100, ((nowMs - sunriseMs) / totalMs) * 100)); + const progressEl = document.getElementById('sun-progress'); + const dotEl = document.getElementById('sun-dot'); + if (progressEl) progressEl.style.width = sunPct + '%'; + if (dotEl) dotEl.style.left = sunPct + '%'; + const daylightEl = document.getElementById('daylight-info'); + if (daylightEl) daylightEl.textContent = `${(totalMs / 3600000).toFixed(1)}h daylight`; + + renderRecommendations(data); + const nowTs = Date.now(); let startIdx = h.time.findIndex(t => new Date(t).getTime() >= nowTs); if (startIdx < 0) startIdx = 0; @@ -1112,16 +1150,12 @@ function renderWeather(data, city) {
${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); - } - + state.showExtendedForecast = false; renderDailyForecast(d); document.getElementById('updated-at').textContent = @@ -1129,7 +1163,8 @@ function renderWeather(data, city) { radarEmbed.panTo(city.latitude, city.longitude, 6); radarFull.panTo(city.latitude, city.longitude, 6); - mapsOverview.panTo(city.latitude, city.longitude, 6); + const mapZoom = mapsOverview?.map?.getZoom ? mapsOverview.map.getZoom() : 6; + mapsOverview.panTo(city.latitude, city.longitude, mapZoom || 6); if (mapsWindEl) mapsWindEl.textContent = `${speedDisplay(c.wind_speed_10m)} ${speedUnit()} ${compassDir(c.wind_direction_10m)}`; updateSourceLocation(city); updateShareUrl(city); @@ -1137,6 +1172,43 @@ function renderWeather(data, city) { refreshMapsOverview(); show(weatherDiv); + setTimeout(() => { radarEmbed.refresh && radarEmbed.refresh(); }, 80); +} + +function renderDailyForecast(daily) { + const list = document.getElementById('daily-list'); + if (!list || !daily?.time?.length) return; + + const totalDays = daily.time.length; + const maxDays = state.showExtendedForecast ? Math.min(16, totalDays) : Math.min(7, totalDays); + + list.innerHTML = ''; + for (let i = 0; i < maxDays; i++) { + const dw = wmo(daily.weather_code[i]); + const row = document.createElement('div'); + row.className = 'day-row' + (i === 0 ? ' today' : ''); + row.innerHTML = ` +
${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) { @@ -1148,6 +1220,237 @@ function renderAirQuality(data) { 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) : '--'; + + const panelAqi = document.getElementById('panel-aqi'); + if (panelAqi) panelAqi.textContent = cur.us_aqi ?? '--'; + + setMapOverlayReadouts({ aqi: cur.us_aqi ?? '--' }); +} + +function normalizeSeverity(severity) { + const s = String(severity || 'Unknown').toLowerCase(); + if (s.includes('extreme')) return 'Extreme'; + if (s.includes('severe')) return 'Severe'; + if (s.includes('moderate')) return 'Moderate'; + if (s.includes('minor')) return 'Minor'; + return 'Unknown'; +} + +function severityRank(severity) { + const map = { Extreme: 0, Severe: 1, Moderate: 2, Minor: 3, Unknown: 4 }; + return map[severity] ?? 4; +} + +function plainAlertAction(eventName, severity) { + const eventText = String(eventName || '').toLowerCase(); + const sev = normalizeSeverity(severity); + if (eventText.includes('tornado')) return 'Move to an interior room on the lowest level away from windows immediately.'; + if (eventText.includes('thunderstorm')) return 'Pause outdoor activity, secure loose items, and be ready to shelter indoors quickly.'; + if (eventText.includes('flood')) return 'Avoid low-water crossings and reroute away from flood-prone roads.'; + if (eventText.includes('winter') || eventText.includes('snow') || eventText.includes('ice') || eventText.includes('blizzard')) return 'Delay travel if possible and prepare for slick roads and slower stopping distances.'; + if (eventText.includes('heat')) return 'Hydrate often, reduce exertion, and seek shade or cooling when possible.'; + if (eventText.includes('wind')) return 'Secure outdoor objects and use extra caution when driving high-profile vehicles.'; + if (eventText.includes('fire') || eventText.includes('red flag')) return 'Avoid sparks or open flame sources and follow local fire restrictions.'; + if (sev === 'Extreme' || sev === 'Severe') return 'Treat this as high risk and take protective action now.'; + if (sev === 'Moderate') return 'Use caution and adjust plans to reduce exposure to hazardous conditions.'; + return 'Stay aware of updates and keep backup plans ready.'; +} + +function buildAlertTiming(p) { + const start = p?.onset || p?.effective || p?.sent; + const end = p?.expires; + const startTxt = start ? formatClock(start) : 'now'; + const endTxt = end ? formatClock(end) : 'until further notice'; + return `${startTxt} - ${endTxt}`; +} + +function buildStormEtaText() { + const nowMs = Date.now(); + const alertTimes = (state.currentAlerts?.features || []) + .map(f => { + const p = f.properties || {}; + return new Date(p.onset || p.effective || p.sent || 0).getTime(); + }) + .filter(ms => Number.isFinite(ms) && ms > nowMs) + .sort((a, b) => a - b); + if (alertTimes.length) { + const mins = Math.max(1, Math.round((alertTimes[0] - nowMs) / 60000)); + if (mins < 60) return `~${mins} min`; + return `~${Math.round(mins / 60)} hr`; + } + + const h = state.currentWeather?.hourly; + if (!h?.time || !h?.weather_code) return 'No storm signal in next 24h'; + for (let i = 0; i < h.time.length && i < 24; i++) { + const ts = new Date(h.time[i]).getTime(); + if (ts < nowMs) continue; + if (Number(h.weather_code[i]) >= 95) { + const mins = Math.max(1, Math.round((ts - nowMs) / 60000)); + return mins < 60 ? `~${mins} min` : `~${Math.round(mins / 60)} hr`; + } + } + return 'No storm signal in next 24h'; +} + +function computeRiskLabel() { + const severities = (state.currentAlerts?.features || []).map(f => normalizeSeverity(f.properties?.severity)); + if (severities.some(s => s === 'Extreme' || s === 'Severe')) return 'High'; + if (severities.some(s => s === 'Moderate')) return 'Elevated'; + if (severities.some(s => s === 'Minor')) return 'Guarded'; + return 'Low'; +} + +function buildShareChecklist(risk) { + if (risk === 'High') return ['Have shelter plan ready', 'Delay non-essential travel', 'Keep devices fully charged']; + if (risk === 'Elevated') return ['Monitor radar and alerts hourly', 'Adjust outdoor timing', 'Secure loose outdoor items']; + if (risk === 'Guarded') return ['Use caution for outdoor plans', 'Keep backup indoor option', 'Recheck before departure']; + return ['Good conditions overall', 'Check updates before longer trips', 'Carry normal weather gear']; +} + +function buildWeatherCardText() { + const city = cityLabel(state.currentCity) || 'Selected location'; + const c = state.currentWeather?.current || {}; + const currentAqi = state.currentAir?.current?.us_aqi ?? '--'; + const risk = computeRiskLabel(); + const eta = buildStormEtaText(); + const alerts = state.currentAlerts?.features || []; + const checklist = buildShareChecklist(risk); + + const lines = [ + `Weather Card: ${city}`, + `Temp: ${tempDisplay(c.temperature_2m)}°${tempUnit()} (Feels ${tempDisplay(c.apparent_temperature)}°${tempUnit()})`, + `Wind: ${speedDisplay(c.wind_speed_10m)} ${speedUnit()} ${compassDirFull(c.wind_direction_10m || 0)}`, + `AQI: ${currentAqi}`, + `Storm ETA: ${eta}`, + `Risk Level: ${risk}`, + `Active Alerts: ${alerts.length}`, + 'Checklist:', + ...checklist.map(item => `- ${item}`), + ]; + return lines.join('\n'); +} + +function buildHeroCardText() { + const city = cityLabel(state.currentCity) || 'Selected location'; + const c = state.currentWeather?.current || {}; + const w = wmo(c.weather_code); + const risk = computeRiskLabel(); + const currentAqi = state.currentAir?.current?.us_aqi ?? '--'; + return [ + `Now in ${city}`, + `${w.icon} ${w.desc}`, + `Temp ${tempDisplay(c.temperature_2m)}°${tempUnit()} (Feels ${tempDisplay(c.apparent_temperature)}°${tempUnit()})`, + `High ${tempDisplay(state.currentWeather?.daily?.temperature_2m_max?.[0])}°${tempUnit()} / Low ${tempDisplay(state.currentWeather?.daily?.temperature_2m_min?.[0])}°${tempUnit()}`, + `Wind ${speedDisplay(c.wind_speed_10m)} ${speedUnit()} ${compassDirFull(c.wind_direction_10m || 0)}`, + `AQI ${currentAqi} | Risk ${risk}`, + ].join('\n'); +} + +function drawRoundedRect(ctx, x, y, w, h, r) { + const radius = Math.min(r, w / 2, h / 2); + ctx.beginPath(); + ctx.moveTo(x + radius, y); + ctx.lineTo(x + w - radius, y); + ctx.quadraticCurveTo(x + w, y, x + w, y + radius); + ctx.lineTo(x + w, y + h - radius); + ctx.quadraticCurveTo(x + w, y + h, x + w - radius, y + h); + ctx.lineTo(x + radius, y + h); + ctx.quadraticCurveTo(x, y + h, x, y + h - radius); + ctx.lineTo(x, y + radius); + ctx.quadraticCurveTo(x, y, x + radius, y); + ctx.closePath(); +} + +function canvasToPngBlob(canvas) { + return new Promise(resolve => { + canvas.toBlob(blob => resolve(blob), 'image/png'); + }); +} + +async function buildHeroCardImageFile() { + const city = cityLabel(state.currentCity) || 'Selected location'; + const c = state.currentWeather?.current || {}; + const d = state.currentWeather?.daily || {}; + const w = wmo(c.weather_code); + const currentAqi = state.currentAir?.current?.us_aqi ?? '--'; + const risk = computeRiskLabel(); + + const canvas = document.createElement('canvas'); + canvas.width = 1080; + canvas.height = 1350; + const ctx = canvas.getContext('2d'); + if (!ctx) return null; + + const bg = ctx.createLinearGradient(0, 0, canvas.width, canvas.height); + bg.addColorStop(0, '#1f2a38'); + bg.addColorStop(1, '#334e68'); + ctx.fillStyle = bg; + ctx.fillRect(0, 0, canvas.width, canvas.height); + + ctx.fillStyle = 'rgba(255, 255, 255, 0.12)'; + drawRoundedRect(ctx, 56, 56, canvas.width - 112, canvas.height - 112, 38); + ctx.fill(); + + ctx.fillStyle = '#ffffff'; + ctx.font = '700 68px Segoe UI'; + ctx.fillText('Weather Hero Card', 96, 156); + + ctx.font = '600 42px Segoe UI'; + ctx.fillStyle = '#dbeafe'; + ctx.fillText(city, 96, 222); + + ctx.font = '600 120px Segoe UI Emoji, Segoe UI'; + ctx.fillStyle = '#ffffff'; + ctx.fillText(w.icon || '⛅', 96, 390); + + ctx.font = '700 64px Segoe UI'; + ctx.fillText(`${tempDisplay(c.temperature_2m)}°${tempUnit()}`, 250, 362); + + ctx.font = '500 36px Segoe UI'; + ctx.fillStyle = '#e2e8f0'; + ctx.fillText(`${w.desc}`, 250, 412); + ctx.fillText(`Feels ${tempDisplay(c.apparent_temperature)}°${tempUnit()}`, 250, 458); + + ctx.fillStyle = 'rgba(255, 255, 255, 0.1)'; + drawRoundedRect(ctx, 96, 520, canvas.width - 192, 460, 28); + ctx.fill(); + + ctx.fillStyle = '#ffffff'; + ctx.font = '600 36px Segoe UI'; + ctx.fillText(`High / Low: ${tempDisplay(d.temperature_2m_max?.[0])}°${tempUnit()} / ${tempDisplay(d.temperature_2m_min?.[0])}°${tempUnit()}`, 132, 610); + ctx.fillText(`Wind: ${speedDisplay(c.wind_speed_10m)} ${speedUnit()} ${compassDirFull(c.wind_direction_10m || 0)}`, 132, 684); + ctx.fillText(`AQI: ${currentAqi}`, 132, 758); + ctx.fillText(`Risk level: ${risk}`, 132, 832); + ctx.fillText(`Shared: ${new Date().toLocaleString()}`, 132, 906); + + ctx.fillStyle = '#cbd5e1'; + ctx.font = '500 30px Segoe UI'; + ctx.fillText('Open full details and alerts in app link below.', 96, 1080); + ctx.fillText(window.location.href, 96, 1130); + + const blob = await canvasToPngBlob(canvas); + if (!blob) return null; + return new File([blob], `hero-weather-${Date.now()}.png`, { type: 'image/png' }); +} + +function showHeroShareStatus(msg) { + if (!shareHeroStatusEl) return; + shareHeroStatusEl.textContent = msg; + show(shareHeroStatusEl); + setTimeout(() => { + shareHeroStatusEl.textContent = ''; + hide(shareHeroStatusEl); + }, 2200); +} + +function showShareWeatherStatus(msg) { + if (!shareWeatherStatusEl) return; + shareWeatherStatusEl.textContent = msg; + show(shareWeatherStatusEl); + setTimeout(() => { + shareWeatherStatusEl.textContent = ''; + hide(shareWeatherStatusEl); + }, 2200); } function renderAlerts(data) { @@ -1164,13 +1467,68 @@ function renderAlerts(data) { if (alertsEmptyEl) hide(alertsEmptyEl); - features.slice(0, 5).forEach(f => { + const grouped = new Map(); + features.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); + const sev = normalizeSeverity(p.severity); + if (!grouped.has(sev)) grouped.set(sev, []); + grouped.get(sev).push(f); }); + + Array.from(grouped.keys()) + .sort((a, b) => severityRank(a) - severityRank(b)) + .forEach(severity => { + const groupWrap = document.createElement('section'); + groupWrap.className = `alerts-group alerts-group-${severity.toLowerCase()}`; + + const head = document.createElement('div'); + head.className = 'alerts-group-head'; + const title = document.createElement('h5'); + title.className = 'alerts-group-title'; + title.textContent = `${severity} Alerts (${grouped.get(severity).length})`; + const hint = document.createElement('span'); + hint.className = 'alerts-group-hint'; + hint.textContent = severityRank(severity) <= 1 ? 'Take action now' : 'Use caution'; + head.appendChild(title); + head.appendChild(hint); + groupWrap.appendChild(head); + + grouped.get(severity).slice(0, 6).forEach(f => { + const p = f.properties || {}; + const card = document.createElement('article'); + card.className = 'alert-item'; + + const top = document.createElement('div'); + top.className = 'alert-top'; + const event = document.createElement('strong'); + event.textContent = p.event || 'Alert'; + const sev = document.createElement('span'); + sev.className = `alert-severity severity-${normalizeSeverity(p.severity).toLowerCase()}`; + sev.textContent = normalizeSeverity(p.severity); + top.appendChild(event); + top.appendChild(sev); + + const timing = document.createElement('div'); + timing.className = 'alert-timing'; + timing.textContent = `Window: ${buildAlertTiming(p)}`; + + const msg = document.createElement('div'); + msg.className = 'alert-message'; + msg.textContent = p.headline || p.description || 'See official sources for details.'; + + const action = document.createElement('div'); + action.className = 'alert-action'; + action.textContent = `What this means for me: ${plainAlertAction(p.event, p.severity)}`; + + card.appendChild(top); + card.appendChild(timing); + card.appendChild(msg); + card.appendChild(action); + groupWrap.appendChild(card); + }); + + list.appendChild(groupWrap); + }); show(wrap); if (typeof Notification !== 'undefined' && Notification.permission === 'granted') { @@ -1189,36 +1547,75 @@ function renderAlerts(data) { async function loadAll(city, source) { state.currentCity = city; if (source) state.currentSource = source; - - const saved = state.savedCities.find(e => cityKey(e.city) === cityKey(city)); - if (saved) { - saved.lastViewedAt = Date.now(); - persistSavedCities(); - renderSavedCities(); - } - clearErr(); hide(suggestEl); hide(weatherDiv); show(loadingEl); try { - const [weather, air, alerts] = await Promise.all([ - fetchWeather(city.latitude, city.longitude, city.timezone), + let weather; + try { + weather = await fetchWeather(city.latitude, city.longitude, city.timezone); + } catch (weatherErr) { + console.error('Primary weather fetch failed:', weatherErr?.message || weatherErr); + if (state.currentWeather) { + weather = state.currentWeather; + showErr('Live weather update failed. Showing last available data.'); + } else { + throw weatherErr; + } + } + + const [airResult, alertsResult] = await Promise.allSettled([ fetchAirQuality(city.latitude, city.longitude, city.timezone), - fetchAlerts(city.latitude, city.longitude).catch(() => ({ features: [] })), + fetchAlerts(city.latitude, city.longitude), ]); + const air = airResult.status === 'fulfilled' ? airResult.value : { current: {} }; + if (airResult.status !== 'fulfilled') { + console.warn('Air quality fetch failed:', airResult.reason?.message || airResult.reason); + } + + const alerts = alertsResult.status === 'fulfilled' ? alertsResult.value : { features: [] }; + if (alertsResult.status !== 'fulfilled') { + console.warn('Alerts fetch failed:', alertsResult.reason?.message || alertsResult.reason); + } + state.currentWeather = weather; state.currentAir = air; + state.currentAlerts = alerts; state.lastRefreshAt = Date.now(); - renderWeather(weather, city); - renderAirQuality(air); - renderAlerts(alerts); + + // Keep weather load resilient: map/radar/render hiccups should not fail the whole page. + try { + renderWeather(weather, city); + } catch (renderErr) { + console.error('Weather render error:', renderErr); + show(weatherDiv); + } + + try { + renderAirQuality(air); + } catch (airRenderErr) { + console.warn('Air quality render error:', airRenderErr); + } + + try { + renderAlerts(alerts); + } catch (alertsRenderErr) { + console.warn('Alerts render error:', alertsRenderErr); + } - // Render map overlays - if (state.showLightning) renderLightningStrikes(city); - if (state.showWind) renderWindArrows(city); + try { + if (state.showLightning || state.unifiedMapLayers.lightning) renderLightningStrikes(city); + if (state.showWind || state.unifiedMapLayers.wind) renderWindArrows(city); + renderAqiMarker(city); + renderAlertMarker(city); + renderTempMarker(city); + renderSunMoonMarker(city); + } catch (mapOverlayErr) { + console.warn('Map overlay render error:', mapOverlayErr); + } } catch (e) { showErr('Failed to load weather data. Please try again.'); console.error(e); @@ -1280,7 +1677,8 @@ searchInput.addEventListener('input', () => { suggestEl.appendChild(li); }); show(suggestEl); - } catch { + } catch (err) { + console.warn('Autocomplete geocode failed:', err?.message || err); hide(suggestEl); } }, 300); @@ -1336,6 +1734,11 @@ unitsBtn.addEventListener('click', () => { if (state.currentWeather && state.currentCity) { renderWeather(state.currentWeather, state.currentCity); renderAirQuality(state.currentAir); + // Update maps overlays if they're visible + if (state.showWind && mapsOverview.map) { + clearWindArrows(); + renderWindArrows(state.currentCity); + } } }); @@ -1354,16 +1757,78 @@ shareBtn.addEventListener('click', async () => { } }); +if (shareWeatherCardBtn) { + shareWeatherCardBtn.addEventListener('click', async () => { + if (!state.currentCity || !state.currentWeather) { + showErr('Load a location first to share a weather card.'); + return; + } + + const text = buildWeatherCardText(); + const payload = { + title: `Weather card: ${cityLabel(state.currentCity) || 'Location'}`, + text, + url: window.location.href, + }; + + try { + if (navigator.share) { + await navigator.share(payload); + showShareWeatherStatus('Shared weather card'); + return; + } + + await navigator.clipboard.writeText(`${text}\n\n${window.location.href}`); + showShareWeatherStatus('Weather card copied'); + } catch (err) { + if (err && err.name === 'AbortError') return; + showErr('Could not share weather card.'); + } + }); +} + +if (shareHeroBtn) { + shareHeroBtn.addEventListener('click', async () => { + if (!state.currentCity || !state.currentWeather) { + showErr('Load a location first to share hero conditions.'); + return; + } + + const text = buildHeroCardText(); + const title = `Now: ${cityLabel(state.currentCity) || 'Location'}`; + + try { + const imageFile = await buildHeroCardImageFile(); + + if (navigator.share && imageFile) { + const filePayload = { title, text, files: [imageFile] }; + if (!navigator.canShare || navigator.canShare({ files: [imageFile] })) { + await navigator.share(filePayload); + showHeroShareStatus('Shared hero image'); + return; + } + } + + if (navigator.share) { + await navigator.share({ title, text, url: window.location.href }); + showHeroShareStatus('Shared hero card'); + return; + } + + await navigator.clipboard.writeText(`${text}\n\n${window.location.href}`); + showHeroShareStatus('Hero card copied'); + } catch (err) { + if (err && err.name === 'AbortError') return; + showErr('Could not share hero card.'); + } + }); +} + saveCurrentBtn.addEventListener('click', () => { if (!state.currentCity) return; const key = cityKey(state.currentCity); - if (state.savedCities.some(c => cityKey(c.city) === key)) return; - state.savedCities.push({ - city: state.currentCity, - addedAt: Date.now(), - lastViewedAt: Date.now(), - isDefault: state.savedCities.length === 0, - }); + if (state.savedCities.some(c => cityKey(c) === key)) return; + state.savedCities.push(state.currentCity); persistSavedCities(); renderSavedCities(); }); @@ -1381,11 +1846,9 @@ notifyBtn.addEventListener('click', async () => { try { const permission = await Notification.requestPermission(); + updateNotifyButton(); if (permission === 'granted') { - const subscribed = await ensurePushSubscription(); - if (!subscribed) { - showErr('Push needs VAPID keys configured on server.'); - } + // Re-fetch current alerts immediately so permission change can trigger notifications. if (state.currentCity) { const alerts = await fetchAlerts(state.currentCity.latitude, state.currentCity.longitude).catch(() => ({ features: [] })); renderAlerts(alerts); @@ -1395,7 +1858,6 @@ notifyBtn.addEventListener('click', async () => { } } catch { showErr('Could not request notification permission.'); - } finally { updateNotifyButton(); } }); @@ -1405,68 +1867,179 @@ autoDetectToggle.addEventListener('change', () => { 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 (lightningToggle) { + 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); } }); } -if (pushTestBtn) { - pushTestBtn.addEventListener('click', async () => { - try { - await sendTestPush(); - pushTestBtn.textContent = 'Push sent'; - setTimeout(() => { pushTestBtn.textContent = 'Send test push'; }, 1500); - } catch (e) { - showErr(e.message || 'Push test failed.'); +if (windToggle) { + 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); +} + +const mapLayerStorageKey = { + radar: STORAGE_KEYS.showRadarLayer, + satellite: STORAGE_KEYS.showSatelliteLayer, + lightning: STORAGE_KEYS.showLightningLayer, + wind: STORAGE_KEYS.showWindLayer, + aqi: STORAGE_KEYS.showAqiLayer, + alerts: STORAGE_KEYS.showAlertsLayer, + temp: STORAGE_KEYS.showTempMarker, + 'sun-moon': STORAGE_KEYS.showSunMoonMarkers, +}; + +function setRadarLayerVisible(visible) { + if (!mapsOverview?.map) return; + mapsOverview.map.eachLayer(layer => { + if (!layer?._url) return; + const isRadarLayer = + layer._url.includes('nexrad-n0q-900913') || + layer._url.includes('tilecache.rainviewer.com') || + layer._url.includes('radar_base_reflectivity_time'); + if (isRadarLayer) { + if (visible && !mapsOverview.map.hasLayer(layer)) mapsOverview.map.addLayer(layer); + if (!visible && mapsOverview.map.hasLayer(layer)) mapsOverview.map.removeLayer(layer); + } + }); +} + +function setCloudLayerVisible(visible) { + if (!mapsOverview?.map) return; + mapsOverview.map.eachLayer(layer => { + if (!layer?._url) return; + const isCloudLayer = + layer._url.includes('goes_') || + layer._url.includes('/0/0_0.png') || + layer._url.includes('sat_meteo_emulated'); + if (isCloudLayer) { + if (visible && !mapsOverview.map.hasLayer(layer)) mapsOverview.map.addLayer(layer); + if (!visible && mapsOverview.map.hasLayer(layer)) mapsOverview.map.removeLayer(layer); + } + }); +} + +document.querySelectorAll('#tab-unified-map input[type="checkbox"][data-layer]').forEach(input => { + const layerName = input.dataset.layer; + input.checked = layerName === 'sun-moon' + ? state.unifiedMapLayers.sunMoon + : !!state.unifiedMapLayers[layerName]; + + input.addEventListener('change', () => { + const enabled = input.checked; + if (layerName === 'sun-moon') { + state.unifiedMapLayers.sunMoon = enabled; + } else { + state.unifiedMapLayers[layerName] = enabled; + } + + const storageKey = mapLayerStorageKey[layerName]; + if (storageKey) localStorage.setItem(storageKey, String(enabled)); + + if (layerName === 'radar') { + setRadarLayerVisible(enabled); + if (!enabled && unifiedMap?.stopPlay) unifiedMap.stopPlay(); + } + + if (layerName === 'satellite') { + if (enabled) { + if (unifiedMapMode !== 'satellite') unifiedMapModeButton.click(); + setCloudLayerVisible(true); + } else { + setCloudLayerVisible(false); + if (unifiedMapMode !== 'radar') unifiedMapModeButton.click(); + } + } + + if (state.currentCity) refreshMapsOverview(); + }); +}); + +const baseMapSelect = document.getElementById('base-map-select'); +if (baseMapSelect) { + baseMapSelect.value = state.unifiedMapBaseLayer; + baseMapSelect.addEventListener('change', () => { + state.unifiedMapBaseLayer = baseMapSelect.value; + localStorage.setItem(STORAGE_KEYS.unifiedMapBaseLayer, state.unifiedMapBaseLayer); + if (state.currentCity) { + const city = state.currentCity; + const zoom = mapsOverview?.map?.getZoom?.() || 6; + if (mapsOverview?.map?.remove) mapsOverview.map.remove(); + const replacement = createRadar( + document.getElementById('unified-map'), + document.getElementById('timeline-slider'), + document.getElementById('timeline-play'), + document.getElementById('timeline-timestamp'), + unifiedMapModeButton + ); + Object.assign(mapsOverview, replacement); + mapsOverview.panTo(city.latitude, city.longitude, zoom); + refreshMapsOverview(); + } + }); +} + +const layerMenuToggleBtn = document.getElementById('layer-menu-toggle'); +const layerMenuPanel = document.getElementById('layer-menu-panel'); +if (layerMenuToggleBtn && layerMenuPanel) { + layerMenuToggleBtn.addEventListener('click', () => { + const willShow = layerMenuPanel.classList.contains('hidden'); + layerMenuPanel.classList.toggle('hidden', !willShow); + layerMenuToggleBtn.textContent = willShow ? 'Hide Layer Menu' : 'Show Layer Menu'; + layerMenuToggleBtn.setAttribute('aria-expanded', willShow ? 'true' : 'false'); + }); +} + +if (dailyToggleBtn) { + dailyToggleBtn.addEventListener('click', () => { + state.showExtendedForecast = !state.showExtendedForecast; + if (state.currentWeather) { + renderDailyForecast(state.currentWeather.daily); } }); } @@ -1492,31 +2065,17 @@ function startAutoRefresh() { 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); - + + // Sync lightning and wind toggle states + if (lightningToggle) lightningToggle.checked = state.showLightning; + if (windToggle) windToggle.checked = state.showWind; + 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 timelineControls = document.getElementById('timeline-controls'); + if (timelineControls && unifiedMapEl) { + timelineControls.classList.remove('hidden'); } const qs = new URLSearchParams(window.location.search); @@ -1542,13 +2101,6 @@ function startAutoRefresh() { if (autoLocated) return; } - const defaultSaved = getDefaultSavedEntry(); - if (defaultSaved?.city) { - searchInput.value = defaultSaved.city.name; - await loadAll(defaultSaved.city, 'default-saved-city'); - return; - } - const lastCity = getLastCity(); if (lastCity) { searchInput.value = lastCity.name; diff --git a/app/public/index.html b/app/public/index.html index 9d9070d..e8378e2 100644 --- a/app/public/index.html +++ b/app/public/index.html @@ -5,24 +5,28 @@ NeCloud Weather - - + - - - +

NeCloud Weather

+ Open-Meteo · RainViewer
@@ -40,7 +44,13 @@ @@ -58,47 +68,134 @@
- -
-
-
--°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
+
--
+
+
+
+
+ + +
+
+
- +
@@ -116,7 +213,6 @@ next 24 hours
-

--

@@ -124,9 +220,7 @@
- - - +
@@ -138,64 +232,138 @@
- - + + - + diff --git a/app/public/style.css b/app/public/style.css index 71c96f7..299f3cb 100644 --- a/app/public/style.css +++ b/app/public/style.css @@ -1,1031 +1,2090 @@ *, *::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; + --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: #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); + --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%; - font-family: var(--ui); - font-size: 18px; - line-height: 1.45; - color: var(--ink); background: var(--paper); + color: var(--ink); + font-family: var(--mono); + font-size: 16px; + line-height: 1.6; + scroll-behavior: smooth; 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)); + max-width: 900px; + margin: 0 auto; + padding: 2rem 1.5rem 4rem; } -/* Header */ +a { color: var(--cool); text-decoration: none; } +a:hover { text-decoration: underline; } + +/* ── Masthead ── */ .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; + 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(--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); + 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 { - 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); + font-size: 0.88rem; + letter-spacing: 0.08em; + color: var(--card-sub); + text-transform: uppercase; + white-space: nowrap; } -/* Bottom nav */ +/* ── Tabs ── */ .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); + display: flex; + gap: 0; + border: 1px solid var(--border); + border-radius: 10px; + overflow: hidden; } -@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; +/* ── Unified Map ── */ +.unified-map-wrap { + border: var(--surface-border); + border-radius: var(--surface-radius-md); + background: var(--card-bg); + padding: 1rem; 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); + gap: 1rem; } -.tab-icon { - font-family: 'Apple Color Emoji', 'Segoe UI Emoji', 'Noto Color Emoji', sans-serif; - font-size: 1.18rem; - line-height: 1; +/* ── Map Controls ── */ +.map-controls-header { + display: flex; + flex-direction: column; + gap: 0.75rem; + padding-bottom: 0.75rem; + border-bottom: var(--surface-border); } -.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 { +.map-controls-row { display: flex; align-items: center; - flex-wrap: nowrap; - gap: 0; - border: 1px solid var(--line); - border-radius: var(--radius-lg); + gap: 0.75rem; +} + +.control-label { + font-size: 0.9rem; + font-weight: 500; + color: var(--card-ink); +} + +.map-select { + padding: 0.4rem 0.6rem; + border: 1px solid var(--border); + border-radius: var(--surface-radius-sm); + background: var(--card-bg-soft); + color: var(--card-ink); + font-size: 1rem; + cursor: pointer; +} + +.map-controls-toggle-grid { + display: grid; + grid-template-columns: 1fr; + gap: 0.5rem; + margin-top: 0.55rem; +} + +.map-layer-menu { + border: var(--surface-border); + border-radius: var(--surface-radius-sm); + background: var(--card-bg-soft); + padding: 0.45rem 0.5rem 0.55rem; +} + +.map-layer-menu-toggle { + width: 100%; + text-align: left; + background: transparent; + border: 1px solid var(--border); + border-radius: 8px; + padding: 0.45rem 0.6rem; + cursor: pointer; + user-select: none; + font-family: var(--mono); + font-size: 0.95rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--card-ink); + transition: background 0.15s; +} + +.map-layer-menu-toggle:hover { + background: var(--paper-dark); +} + +.map-layer-menu-title { + margin-top: 0.55rem; + font-family: var(--mono); + font-size: 0.88rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--card-sub); +} + +.map-layer-menu-panel { + margin-top: 0.45rem; +} + +.toggle-control { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.4rem 0.6rem; + cursor: pointer; + border-radius: var(--surface-radius-sm); + transition: background 0.15s; + font-family: var(--mono); +} + +.toggle-control:hover { + background: var(--card-bg-soft); +} + +.toggle-control input[type="checkbox"] { + cursor: pointer; +} + +.toggle-label { + font-size: 1rem; + color: var(--card-ink); + font-weight: 700; + letter-spacing: 0.015em; +} + +.unified-map-container { + height: calc(100vh - 490px); + min-height: 420px; + border: var(--surface-border); + border-radius: 10px; 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 */ +.unified-map-shell { + position: relative; } +.map-overlay-readouts { + position: absolute; + left: 0.7rem; + right: 0.7rem; + bottom: 0.7rem; + display: flex; + flex-wrap: wrap; + gap: 0.45rem; + z-index: 650; + pointer-events: none; +} + +.map-readout-chip { + display: inline-flex; + align-items: center; + padding: 0.3rem 0.62rem; + border-radius: 999px; + background: rgba(20, 18, 14, 0.9); + border: 1px solid rgba(255, 255, 255, 0.24); + color: #fff; + font-family: var(--mono); + font-size: 0.78rem; + font-weight: 700; + letter-spacing: 0.01em; + text-shadow: 0 1px 1px rgba(0, 0, 0, 0.5); + box-shadow: 0 3px 12px rgba(0, 0, 0, 0.34); +} + +.map-timeline-wrap { + padding: 0.6rem 0.75rem; + background: var(--paper-darker); + border-top: 1px solid var(--border); + border-radius: 0 0 10px 10px; +} + +.map-data-panel { + background: var(--card-bg-soft); + border: var(--surface-border); + border-radius: var(--surface-radius-sm); + padding: 1rem 1.1rem; + display: grid; + grid-template-columns: 1fr; + gap: 1rem; +} + +.panel-location { + font-weight: 700; + color: var(--card-ink); + font-size: 1.08rem; +} + +.panel-data { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 0.85rem; +} + +.data-item { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.leaflet-tooltip.map-label { + background: rgba(20, 18, 14, 0.9); + border: 1px solid rgba(255, 255, 255, 0.22); + color: #fff; + border-radius: 999px; + padding: 0.18rem 0.52rem; + font-family: var(--mono); + font-size: 0.78rem; + font-weight: 700; + letter-spacing: 0.01em; + text-shadow: 0 1px 1px rgba(0, 0, 0, 0.55); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.35); +} + +.leaflet-tooltip.map-label:before { + border-top-color: rgba(20, 18, 14, 0.9); +} + +.data-label { + font-size: 0.78rem; + color: var(--card-sub); + text-transform: uppercase; + font-weight: 700; + letter-spacing: 0.06em; +} + +.data-item span:last-child { + font-size: 1.02rem; + color: var(--card-ink); + font-weight: 700; +} + + + +.tab { + background: transparent; + border: none; + border-right: 1px solid var(--border); + padding: 0.4rem 1rem; + font-family: var(--mono); + font-size: 0.95rem; + 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; - min-height: 46px; + 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.95rem; + 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: 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 0.75rem; -} - -.search-row button { - flex: 0 0 auto; - min-height: 46px; - border: none; - border-left: 1px solid var(--line); + padding: 0.35rem 0.45rem; + font-family: var(--mono); 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; - } +.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; - 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; + 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: rgba(29, 135, 191, 0.09); } -.sug-country { color: var(--ink-muted); font-size: 0.8rem; margin-left: 0.35rem; } +.suggestions li:hover { background: var(--paper-dark); } +.sug-country { color: var(--ink-light); margin-left: 0.5rem; font-size: 0.75rem; } -.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); -} +/* ── Error / Loading ── */ +.error { font-size: 0.78rem; color: var(--accent); margin-top: 0.5rem; } .loading { display: flex; align-items: center; - gap: 0.65rem; - color: var(--ink-soft); - font-size: 0.88rem; - padding: 1rem 0.35rem; + justify-content: center; + min-height: calc(100vh - 200px); + padding: 3rem; } -.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; +.loading-container { display: flex; flex-direction: column; - gap: 0.4rem; + align-items: center; + gap: 2rem; + text-align: center; } -.loc-name { - font-family: var(--display); +.loading-icon { + font-size: 6rem; + animation: float 2s ease-in-out infinite; +} + +.loading-text { font-size: 1.5rem; - line-height: 1.1; + font-weight: 500; + color: var(--ink); + letter-spacing: 0.05em; } -.loc-sub, -.loc-time { - font-size: 0.8rem; - color: var(--ink-muted); -} - -.loc-actions { +.loading-dots { display: flex; - flex-direction: column; - align-items: flex-start; + 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%; } + +/* ── Learn tab ── */ +.learn-wrap { + border: var(--surface-border); + border-radius: var(--surface-radius-md); + background: var(--card-bg); + padding: 1rem; +} + +.learn-title { + font-family: var(--serif); + font-size: 1.35rem; + color: var(--card-ink); + margin-bottom: 0.35rem; +} + +.learn-intro { + color: var(--card-sub); + margin-bottom: 0.95rem; +} + +.learn-toc { + border: var(--surface-border); + border-radius: 10px; + background: var(--card-bg-soft); + padding: 0.6rem; + margin-bottom: 0.9rem; +} + +.learn-toc-title { + font-size: 0.78rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--card-sub); + margin-bottom: 0.4rem; +} + +.learn-toc-links { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); 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; +.learn-toc-links a { + border: var(--surface-border); + border-radius: 999px; + background: rgba(0, 0, 0, 0.16); + color: var(--card-ink); + text-decoration: none; + font-size: 0.72rem; font-weight: 700; + padding: 0.24rem 0.42rem; + text-align: center; + line-height: 1.2; } -.section-note { - color: var(--ink-muted); +.learn-toc-links a:hover { + background: rgba(0, 0, 0, 0.3); +} + +.learn-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.75rem; +} + +.learn-card { + border: var(--surface-border); + border-radius: var(--surface-radius-sm); + background: var(--card-bg-soft); + padding: 0.75rem; +} + +.learn-card h4 { + font-size: 0.93rem; + color: var(--card-ink); + margin-bottom: 0.3rem; +} + +.learn-card p { + font-size: 0.84rem; + color: var(--card-sub); + line-height: 1.45; +} + +.learn-section { + margin-top: 1rem; + border-top: var(--surface-border); + padding-top: 0.9rem; +} + +.learn-section-title { + font-size: 1rem; + color: var(--card-ink); + margin-bottom: 0.25rem; +} + +.learn-section-intro { + font-size: 0.82rem; + color: var(--card-sub); + margin-bottom: 0.6rem; +} + +.learn-range-list { + display: grid; + gap: 0.45rem; +} + +.learn-range-item { + display: grid; + grid-template-columns: 18px 108px minmax(180px, 1fr) 1.5fr; + align-items: center; + gap: 0.55rem; + border: var(--surface-border); + border-radius: 10px; + background: var(--card-bg-soft); + padding: 0.5rem 0.6rem; +} + +.range-swatch { + width: 12px; + height: 12px; + border-radius: 999px; + border: 1px solid rgba(255, 255, 255, 0.4); +} + +.range-value { 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; + color: var(--card-ink); 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; +.range-label { + font-size: 0.8rem; + color: var(--card-ink); + font-weight: 600; } -.radar-map, -.map-overview { - width: 100%; - height: 235px; - border-radius: var(--radius-md); - border: 1px solid var(--line); +.range-note { + font-size: 0.77rem; + color: var(--card-sub); +} + +.learn-table { + display: grid; + border: var(--surface-border); + border-radius: 10px; overflow: hidden; } -.radar-map-full { - height: calc(100dvh - 20rem); - min-height: 310px; +.learn-table-row { + display: grid; + grid-template-columns: 120px 1.2fr 1.6fr; + gap: 0.5rem; + padding: 0.5rem 0.6rem; + background: var(--card-bg-soft); + border-bottom: 1px solid rgba(255, 255, 255, 0.08); } +.learn-table-row:last-child { + border-bottom: none; +} + +.learn-table-row span { + font-size: 0.78rem; + color: var(--card-sub); +} + +.learn-table-head { + background: rgba(0, 0, 0, 0.18); +} + +.learn-table-head span { + color: var(--card-ink); + font-size: 0.74rem; + font-weight: 700; + letter-spacing: 0.05em; + text-transform: uppercase; +} + +.learn-table-row span:first-child { + color: var(--card-ink); + font-weight: 700; +} + +.aqi-good { background: #00a650; } +.aqi-moderate { background: #ffd000; } +.aqi-sensitive { background: #ff8c00; } +.aqi-unhealthy { background: #e53935; } +.aqi-very-unhealthy { background: #8e24aa; } +.aqi-hazardous { background: #7e0023; } + +.wind-calm { background: #4fc3f7; } +.wind-breezy { background: #43a047; } +.wind-windy { background: #fdd835; } +.wind-strong { background: #fb8c00; } +.wind-severe { background: #e53935; } + +.radar-light { background: #1e88e5; } +.radar-moderate { background: #fdd835; } +.radar-heavy { background: #f4511e; } +.radar-extreme { background: #8e24aa; } + +.comfort-dry { background: #4fc3f7; } +.comfort-pleasant { background: #66bb6a; } +.comfort-sticky { background: #fdd835; } +.comfort-oppressive { background: #fb8c00; } +.comfort-tropical { background: #e53935; } + +.vis-good { background: #43a047; } +.vis-fair { background: #fdd835; } +.vis-poor { background: #fb8c00; } +.vis-danger { background: #e53935; } + +.uv-low { background: #43a047; } +.uv-moderate { background: #fdd835; } +.uv-high { background: #fb8c00; } +.uv-very-high { background: #e53935; } +.uv-extreme { background: #8e24aa; } + +.cloud-clear { background: #64b5f6; } +.cloud-partly { background: #90caf9; } +.cloud-mostly { background: #78909c; } +.cloud-overcast { background: #546e7a; } + +.learn-list { + margin: 0; + padding-left: 1.1rem; + display: grid; + gap: 0.35rem; + color: var(--card-sub); + font-size: 0.84rem; + line-height: 1.45; +} + +.learn-mini-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.55rem; +} + +.learn-mini-card { + border: var(--surface-border); + border-radius: 10px; + background: var(--card-bg-soft); + padding: 0.55rem 0.6rem; +} + +.learn-mini-card h5 { + margin: 0 0 0.25rem; + font-size: 0.8rem; + color: var(--card-ink); +} + +.learn-mini-card p { + margin: 0; + font-size: 0.78rem; + color: var(--card-sub); + line-height: 1.4; +} + +.learn-faq { + display: grid; + gap: 0.45rem; +} + +.learn-faq-item { + border: var(--surface-border); + border-radius: 10px; + background: var(--card-bg-soft); + padding: 0.5rem 0.6rem; +} + +.learn-faq-item summary { + cursor: pointer; + font-size: 0.82rem; + font-weight: 700; + color: var(--card-ink); +} + +.learn-faq-item p { + margin: 0.45rem 0 0; + font-size: 0.79rem; + color: var(--card-sub); + line-height: 1.45; +} + +.learn-checklist { + margin: 0; + padding-left: 0; + list-style: none; + display: grid; + gap: 0.4rem; +} + +.learn-checklist li { + border: var(--surface-border); + border-radius: 10px; + background: var(--card-bg-soft); + padding: 0.45rem 0.6rem 0.45rem 1.95rem; + position: relative; + font-size: 0.8rem; + color: var(--card-sub); + line-height: 1.4; +} + +.learn-checklist li::before { + content: '✓'; + position: absolute; + left: 0.65rem; + top: 0.45rem; + color: var(--accent); + font-weight: 800; +} + +.learn-glossary-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.5rem; +} + +.glossary-item { + border: var(--surface-border); + border-radius: 10px; + background: var(--card-bg-soft); + padding: 0.55rem 0.6rem; +} + +.glossary-item h5 { + margin: 0 0 0.2rem; + font-size: 0.8rem; + color: var(--card-ink); +} + +.glossary-item p { + margin: 0; + font-size: 0.77rem; + color: var(--card-sub); + line-height: 1.4; +} + +.learn-back-top { + position: fixed; + right: 1rem; + bottom: calc(0.9rem + env(safe-area-inset-bottom, 0px)); + z-index: 460; + border: 1px solid rgba(255, 255, 255, 0.25); + border-radius: 999px; + background: rgba(20, 18, 14, 0.94); + color: #fff; + text-decoration: none; + font-size: 0.78rem; + font-weight: 700; + padding: 0.42rem 0.72rem; + box-shadow: 0 6px 18px rgba(0, 0, 0, 0.35); +} + +.learn-back-top:hover { + background: rgba(20, 18, 14, 1); +} + +/* ── 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-share-btn { + margin-top: 0.5rem; + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 999px; + background: rgba(0, 0, 0, 0.2); + color: var(--card-ink); + font-family: var(--mono); + font-size: 0.72rem; + letter-spacing: 0.05em; + text-transform: uppercase; + padding: 0.3rem 0.62rem; + cursor: pointer; +} + +.hero-share-btn:hover { + background: rgba(0, 0, 0, 0.34); +} + +.hero-share-status { + margin-top: 0.25rem; + min-height: 1rem; + font-size: 0.7rem; + color: var(--card-sub); +} + +.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: 1rem; + 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.08em; + 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: 1rem; 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.08em; + 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-actions { + display: inline-flex; + gap: 0.45rem; + align-items: center; +} + +.alerts-share-status { + margin: 0.3rem 0 0.5rem; + font-size: 0.74rem; + color: var(--card-sub); +} + +.alerts-list { + display: grid; + gap: 0.5rem; +} + +.alerts-group { + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 10px; + padding: 0.45rem; + background: rgba(0, 0, 0, 0.08); +} + +.alerts-group-head { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 0.35rem; + padding: 0.1rem 0.2rem; +} + +.alerts-group-title { + font-size: 0.76rem; + letter-spacing: 0.05em; + text-transform: uppercase; + color: var(--card-ink); +} + +.alerts-group-hint { + font-size: 0.7rem; + color: var(--card-sub); +} + +.alerts-group-extreme .alerts-group-title, +.alerts-group-severe .alerts-group-title { + color: #f28b82; +} + +.alerts-group-moderate .alerts-group-title { + color: #ffd166; +} + +.alert-item { + display: grid; + gap: 0.3rem; + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 10px; + padding: 0.58rem; + background: var(--card-bg-soft); + font-size: 0.74rem; +} + +.alert-top { + display: flex; + justify-content: space-between; + gap: 0.5rem; + align-items: center; +} + +.alert-severity { + border-radius: 999px; + border: 1px solid rgba(255, 255, 255, 0.2); + padding: 0.06rem 0.45rem; + font-size: 0.65rem; + letter-spacing: 0.05em; + text-transform: uppercase; + font-weight: 700; +} + +.severity-extreme, +.severity-severe { + background: rgba(229, 57, 53, 0.26); + color: #ffd3d1; +} + +.severity-moderate { + background: rgba(255, 209, 102, 0.25); + color: #ffe4a3; +} + +.severity-minor, +.severity-unknown { + background: rgba(120, 144, 156, 0.25); + color: #e3eef5; +} + +.alert-timing { + font-size: 0.7rem; + color: var(--card-sub); +} + +.alert-message { + font-size: 0.74rem; + color: var(--card-sub); + line-height: 1.35; +} + +.alert-action { + border-top: 1px dashed rgba(255, 255, 255, 0.14); + padding-top: 0.3rem; + font-size: 0.73rem; + color: var(--card-ink); + font-weight: 600; +} + +.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: var(--surface-border); + border-radius: var(--surface-radius-sm); + background: var(--card-bg-soft); + color: var(--card-sub); + font-size: 1rem; + 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: calc(100vh - 470px); + min-height: 420px; + 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.88rem; 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; - flex-wrap: wrap; align-items: center; - gap: 0.45rem; - margin-top: 0.62rem; + 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 { - width: 46px; - height: 46px; + background: var(--ink); + color: var(--paper); border: none; - border-radius: 12px; - background: #0f3248; - color: #f5fbfe; + width: 28px; height: 28px; + display: flex; align-items: center; justify-content: center; + font-size: 0.7rem; cursor: pointer; - font-size: 0.86rem; + flex-shrink: 0; + transition: opacity 0.15s; } -.layer-btn { background: var(--brand); } +.radar-btn:hover { opacity: 0.72; } +.layer-btn { background: var(--accent); } .radar-timeline { - order: 3; - flex-basis: 100%; - height: 32px; + flex: 1; display: flex; gap: 2px; + align-items: center; + height: 28px; } .tl-tick { flex: 1; - border-radius: 7px; - border: 1px solid var(--line); - background: rgba(255, 255, 255, 0.52); + height: 100%; + background: var(--paper-dark); + border: 1px solid var(--border); cursor: pointer; + transition: background 0.1s; + position: relative; } -.tl-tick.active { background: var(--info); border-color: var(--info); } -.tl-tick.forecast { opacity: 0.6; } +.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.88rem; + 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; } -.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); + 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, -.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); +.radar-attribution { + font-size: 0.62rem; + color: var(--ink-light); + padding-top: 0.5rem; } +/* ── Hourly scroll ── */ .hourly-scroll { overflow-x: auto; - -webkit-overflow-scrolling: touch; - margin-bottom: 0.85rem; - padding: 0.42rem; + 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: 90px; - padding: 0.72rem 0.45rem; + min-width: 72px; + padding: 0.75rem 0.5rem; text-align: center; - border-radius: 12px; - border: 1px solid var(--line); - background: var(--surface); - margin-right: 0.3rem; + border-right: 0.5px solid var(--border); + background: var(--card-bg); + transition: background 0.12s; } -.hour-cell.now { - background: linear-gradient(180deg, rgba(29, 135, 191, 0.18) 0%, var(--surface) 100%); -} +.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.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); } +.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 */ +/* ── Daily forecast ── */ .daily-list { - padding: 0.4rem; - margin-bottom: 0.9rem; + border: none; + display: grid; + gap: 0.55rem; + margin-bottom: 2rem; } .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; + grid-template-columns: 120px 44px 1fr 80px 60px; align-items: center; - flex-wrap: wrap; - gap: 0.42rem; - margin-top: 0.52rem; - font-size: 0.71rem; - color: var(--ink-muted); + 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; } -.wind-legend-scale { - width: 126px; - height: 10px; - border-radius: 999px; - border: 1px solid var(--line); - background: linear-gradient(to right, #d2f0ff, #84ccff, #3f9fff, #1879df); +.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; } -.saved-wrap { margin-top: 0.1rem; } +/* ── 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)); + } + .map-overview { + height: calc(100vh - 470px - env(safe-area-inset-top) - env(safe-area-inset-bottom)); + } +} -.saved-head { +/* ── 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: 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); + align-items: center; + justify-content: center; + gap: 0.15rem; 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; +.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; } -.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) { +/* ── Responsive ── */ +@media (max-width: 640px) { 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)); - } - + .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 { - position: static; + 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 1fr; + 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; - gap: 1rem; - margin-bottom: 1rem; - padding: 0; - border: none; - box-shadow: none; - background: transparent; - backdrop-filter: none; } - .masthead h1 { margin: 0; font-size: 2rem; } + .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; + } - .tabs { - position: fixed; - left: 0; - right: 0; - bottom: 0; - width: 100vw; - max-width: 100vw; - z-index: 300; + .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: 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; + grid-template-columns: minmax(0, 1fr) auto auto auto; + gap: 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; + .search-row input { + font-size: 0.95rem; + padding: 0.58rem 0.68rem; } - - .tab-icon { - font-family: 'Apple Color Emoji', 'Segoe UI Emoji', 'Noto Color Emoji', sans-serif; - font-size: 1.18rem; - line-height: 1; + .search-row button { + font-size: 0.64rem; + padding: 0.58rem 0.62rem; + letter-spacing: 0.04em; } - - .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; } + #locate-btn { font-size: 1rem !important; padding: 0.58rem 0.55rem !important; } + #units-btn { min-width: 56px; } .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; + flex-direction: column; align-items: center; - gap: 0.8rem; + 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; } + .hero-share-btn { font-size: 0.7rem; } + .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.88rem; } + .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: 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: 350px; } + .maps-data { grid-template-columns: 1fr; } + .maps-legend { grid-template-columns: 1fr; } + .wind-stats { grid-template-columns: 1fr 1fr; } + + .alerts-actions { + display: grid; + gap: 0.15rem; + justify-items: end; } - .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; + .alerts-group-head { + flex-direction: column; + align-items: flex-start; + gap: 0.2rem; } - .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); + .learn-grid { grid-template-columns: 1fr; } + + .learn-toc-links { + grid-template-columns: repeat(2, minmax(0, 1fr)); } - .settings-row { - flex-direction: row; - justify-content: space-between; - align-items: center; + .learn-range-item { + grid-template-columns: 16px 1fr; + align-items: start; } - .section-head { - flex-direction: row; - justify-content: space-between; - align-items: center; - margin-left: 0; - margin-right: 0; + .range-value, + .range-label, + .range-note { + grid-column: 2; } - .radar-timeline { - order: 0; - flex-basis: auto; - flex: 1; - min-width: 180px; + .learn-table-row { + grid-template-columns: 1fr; + gap: 0.2rem; } - .radar-ts { margin-left: auto; } - - .radar-map-full { - height: calc(100vh - 300px); - min-height: 440px; + .learn-table-head { + display: none; } - .tab-panel { min-height: auto; } + .learn-table-row span { + font-size: 0.8rem; + } + + .learn-mini-grid, + .learn-glossary-grid { + grid-template-columns: 1fr; + } + + .learn-back-top { + right: 0.65rem; + bottom: calc(5rem + env(safe-area-inset-bottom, 0px)); + font-size: 0.74rem; + padding: 0.36rem 0.6rem; + } + + .map-overlay-readouts { + left: 0.45rem; + right: 0.45rem; + bottom: 0.45rem; + gap: 0.35rem; + } + + .map-readout-chip { + font-size: 0.72rem; + padding: 0.26rem 0.52rem; + } } + diff --git a/app/public/sw.js b/app/public/sw.js index 22c715d..7350fe9 100644 --- a/app/public/sw.js +++ b/app/public/sw.js @@ -1,12 +1,16 @@ 'use strict'; -const CACHE = 'weather-v4'; +self.addEventListener('message', event => { + if (event.data === 'skipWaiting') self.skipWaiting(); +}); -// App shell — cached on install for offline use +const CACHE = 'weather-v5'; + +// App shell cached on install for offline resilience. const PRECACHE = [ '/', - '/app.js?v=4', - '/style.css?v=4', + '/app.js', + '/style.css', '/manifest.json', '/icons/icon.svg' ]; @@ -22,9 +26,7 @@ self.addEventListener('install', event => { self.addEventListener('activate', event => { event.waitUntil( caches.keys() - .then(keys => Promise.all( - keys.filter(k => k !== CACHE).map(k => caches.delete(k)) - )) + .then(keys => Promise.all(keys.filter(k => k !== CACHE).map(k => caches.delete(k)))) .then(() => self.clients.claim()) ); }); @@ -33,7 +35,7 @@ self.addEventListener('fetch', event => { const { request } = event; const url = new URL(request.url); - // Skip non-GET, API calls, and map/radar tile requests + // Never intercept API/tile traffic. if ( request.method !== 'GET' || url.pathname.startsWith('/api/') || @@ -45,14 +47,14 @@ self.addEventListener('fetch', event => { return; } - const isAppShellRequest = + const isShell = 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) { + if (isShell) { + // Network-first keeps app updates fresh; cache as fallback. event.respondWith( fetch(request) .then(response => { @@ -67,11 +69,11 @@ self.addEventListener('fetch', event => { return; } + // Cache-first for static assets. 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') || @@ -85,39 +87,4 @@ self.addEventListener('fetch', event => { }); }) ); -}); - -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); - }) - ); -}); +}); \ No newline at end of file diff --git a/app/server.js b/app/server.js index 177207e..4a09a86 100644 --- a/app/server.js +++ b/app/server.js @@ -1,130 +1,30 @@ 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; +const weatherCache = new Map(); -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) +// Keep shell assets fresh so SW/app versions do not drift. 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 + } else if ( + req.path.endsWith('.css') || + req.path.endsWith('.js') || + req.path === '/sw.js' || + req.path === '/manifest.json' + ) { 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(), - }); +// Prevent browser favicon requests from showing noisy 404s. +app.get('/favicon.ico', (_req, res) => { + res.status(204).end(); }); // Geocoding proxy @@ -133,8 +33,58 @@ app.get('/api/geocode', async (req, res) => { 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); + + 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 }); } @@ -146,31 +96,6 @@ app.get('/api/reverse-geocode', async (req, res) => { 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), @@ -179,13 +104,18 @@ app.get('/api/reverse-geocode', async (req, res) => { addressdetails: '1', }); - const data = await fetchJsonWithRetry(`https://nominatim.openstreetmap.org/reverse?${params}`, { + 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'; @@ -209,13 +139,14 @@ app.get('/api/weather', async (req, res) => { 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','surface_pressure','visibility' + 'wind_speed_10m','wind_direction_10m','wind_gusts_10m','surface_pressure','visibility' ].join(','), hourly: [ 'temperature_2m','precipitation_probability','weather_code', @@ -231,9 +162,44 @@ app.get('/api/weather', async (req, res) => { 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); + + 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 }); } }); @@ -250,10 +216,34 @@ app.get('/api/air-quality', async (req, res) => { 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); + 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) { - res.status(500).json({ error: 'Air quality fetch failed', detail: e.message }); + console.error('Air quality fetch error:', e); + res.setHeader('X-Air-Quality-Fallback', 'empty'); + res.json({ current: {} }); } }); @@ -262,211 +252,73 @@ 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}`, { + 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) { - res.status(500).json({ error: 'Alerts fetch failed', detail: e.message }); + console.error('Alerts fetch error:', e.message); + res.setHeader('X-Alerts-Fallback', 'empty'); + res.json({ features: [] }); } }); -// 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); - +// Radar/satellite tile configuration +app.get('/api/radar/times', async (req, res) => { 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 now = Math.floor(Date.now() / 1000); + + // IEM NEXRAD tile service (US radar, public/free) with historical offsets for animation. + const radarLayerBase = 'https://mesonet.agron.iastate.edu/cache/tile.py/1.0.0'; + const radarOffsetsMin = [55, 50, 45, 40, 35, 30, 25, 20, 15, 10, 5, 0]; + const radarPast = radarOffsetsMin.map(offset => { + const layer = offset === 0 ? 'nexrad-n0q-900913' : `nexrad-n0q-900913-m${String(offset).padStart(2, '0')}m`; + return { + time: now - (offset * 60), + url: `${radarLayerBase}/${layer}/{z}/{x}/{y}.png`, + isForecast: false, + maxNativeZoom: 12, + }; }); - 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; + // IEM GOES East cloud/satellite frames for animation. + const satOffsetsMin = [30, 25, 20, 15, 10, 5, 0]; + const satelliteInfrared = satOffsetsMin.map(offset => { + const layer = offset === 0 ? 'goes_east' : `goes_east_m${String(offset).padStart(2, '0')}m`; + return { + time: now - (offset * 60), + url: `https://mesonet.agron.iastate.edu/cache/tile.py/1.0.0/${layer}/{z}/{x}/{y}.png`, + isForecast: false, + maxNativeZoom: 10, + }; }); res.json({ - source: 'weather.gov alerts', - count: features.length, - features, - ageMinutes: Number.isFinite(ageMinutes) ? ageMinutes : 15, + provider: 'iem-radar-rainviewer-sat', + radar: { + past: radarPast, + nowcast: [], + }, + satellite: { + infrared: satelliteInfrared, + }, }); } 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) { + console.error('Radar config error:', e.message); res.status(500).json({ error: 'Radar times failed', detail: e.message }); } }); diff --git a/docker-compose.yml b/docker-compose.yml index a7617fe..f5ad626 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,8 @@ services: weather: - image: ghcr.io/nmemmert/weather:latest + build: + context: ./app + dockerfile: Dockerfile container_name: weather-app restart: unless-stopped ports: diff --git a/served.html b/served.html new file mode 100644 index 0000000..5060835 Binary files /dev/null and b/served.html differ