diff --git a/app/public/app.js b/app/public/app.js index 0931785..555bab3 100644 --- a/app/public/app.js +++ b/app/public/app.js @@ -22,17 +22,57 @@ const WMO = { function wmo(code) { return WMO[code] || { desc: 'Unknown', icon: '๐ŸŒก๏ธ' }; } -const DAYS = ['Sunday','Monday','Tuesday','Wednesday','Thursday','Friday','Saturday']; +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: [], +}; // โ”€โ”€ 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 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'); // โ”€โ”€ Helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ const show = el => el.classList.remove('hidden'); @@ -51,13 +91,172 @@ function formatHour(iso) { 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'; + 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 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 โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ document.querySelectorAll('.tab').forEach(btn => { btn.addEventListener('click', () => { @@ -68,6 +267,9 @@ document.querySelectorAll('.tab').forEach(btn => { if (btn.dataset.tab === 'radar-full') { setTimeout(() => { radarFull.map && radarFull.map.invalidateSize(); }, 50); } + if (btn.dataset.tab === 'maps') { + setTimeout(() => { mapsOverview.map && mapsOverview.map.invalidateSize(); }, 50); + } }); }); @@ -75,7 +277,7 @@ document.getElementById('open-radar-tab').addEventListener('click', () => { document.querySelector('.tab[data-tab="radar-full"]').click(); }); -// โ”€โ”€ Geocoding โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// โ”€โ”€ APIs โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ async function geocode(q) { const r = await fetch(`/api/geocode?q=${encodeURIComponent(q)}`); if (!r.ok) throw new Error('Geocoding failed'); @@ -83,14 +285,25 @@ async function geocode(q) { } async function fetchWeather(lat, lon, tz) { - const r = await fetch(`/api/weather?lat=${lat}&lon=${lon}&tz=${encodeURIComponent(tz||'auto')}`); + const r = await fetch(`/api/weather?lat=${lat}&lon=${lon}&tz=${encodeURIComponent(tz||'auto')}&units=us`); 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(); +} + // โ”€โ”€ Radar engine โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -// Creates a self-contained animated radar on a Leaflet map -function createRadar(mapEl, timelineEl, playBtn, tsEl) { +function createRadar(mapEl, timelineEl, playBtn, tsEl, layerBtn) { const map = L.map(mapEl, { center: [39.5, -98.35], zoom: 4, @@ -98,42 +311,50 @@ function createRadar(mapEl, timelineEl, playBtn, tsEl) { attributionControl: false, }); - // Dark basemap L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', { maxZoom: 19, }).addTo(map); - let frames = []; // [{time, path, isForcast}] + let radarFrames = []; + let satelliteFrames = []; + let mode = 'radar'; let currentIdx = 0; - let layers = {}; // time โ†’ L.TileLayer + 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; - // Add layer if not cached - if (!layers[frame.time]) { - layers[frame.time] = L.tileLayer( - `https://tilecache.rainviewer.com${frame.path}/256/{z}/{x}/{y}/2/1_1.png`, - { opacity: 0.75, maxZoom: 15 } - ); + if (!layers[mode][frame.time]) { + layers[mode][frame.time] = L.tileLayer(frameTileUrl(frame), { opacity: 0.75, maxZoom: 15 }); } - // Hide all, show current - Object.values(layers).forEach(l => { if (map.hasLayer(l)) map.removeLayer(l); }); - layers[frame.time].addTo(map); + Object.values(layers.radar).forEach(l => { if (map.hasLayer(l)) map.removeLayer(l); }); + Object.values(layers.satellite).forEach(l => { if (map.hasLayer(l)) map.removeLayer(l); }); + layers[mode][frame.time].addTo(map); - // Update UI tsEl.textContent = tsLabel(frame.time); document.querySelectorAll(`#${timelineEl.id} .tl-tick`).forEach((el, i) => { el.classList.toggle('active', i === idx); @@ -142,7 +363,7 @@ function createRadar(mapEl, timelineEl, playBtn, tsEl) { function buildTimeline() { timelineEl.innerHTML = ''; - frames.forEach((f, i) => { + activeFrames().forEach((f, i) => { const tick = document.createElement('div'); tick.className = 'tl-tick' + (f.isForecast ? ' forecast' : ''); tick.title = tsLabel(f.time); @@ -152,10 +373,11 @@ function createRadar(mapEl, timelineEl, playBtn, tsEl) { } function step() { + const frames = activeFrames(); + if (!frames.length) return; const next = (currentIdx + 1) % frames.length; showFrame(next); if (next === 0) { - // pause at loop end briefly stopPlay(); setTimeout(startPlay, 800); } @@ -175,21 +397,39 @@ function createRadar(mapEl, timelineEl, playBtn, tsEl) { 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(); - frames = []; - // Past frames - (data.radar?.past || []).forEach(f => frames.push({ time: f.time, path: f.path, isForecast: false })); - // Forecast frames - (data.radar?.nowcast || []).forEach(f => frames.push({ time: f.time, path: f.path, isForecast: true })); + 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 (frames.length === 0) return; + if (!radarFrames.length && satelliteFrames.length) mode = 'satellite'; + if (!radarFrames.length && !satelliteFrames.length) return; + + if (layerBtn) { + layerBtn.disabled = !(radarFrames.length && satelliteFrames.length); + layerBtn.textContent = mode === 'radar' ? 'R' : 'S'; + } buildTimeline(); - showFrame(frames.length - 1); // show most recent + const frames = activeFrames(); + showFrame(frames.length - 1); } catch (e) { console.error('Radar load failed', e); } @@ -208,33 +448,152 @@ function createRadar(mapEl, timelineEl, playBtn, tsEl) { } loadFrames(); - return { map, panTo, startPlay, stopPlay }; } -// โ”€โ”€ Instantiate both radar instances โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ const radarEmbed = createRadar( document.getElementById('radar-embed'), document.getElementById('embed-timeline'), document.getElementById('embed-play'), - document.getElementById('embed-timestamp') + 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-timestamp'), + document.getElementById('full-layer') ); -// โ”€โ”€ Render weather data โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -function render(data, city) { +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 renderLightningStrikes(city) { + if (!mapsOverview.map || !state.showLightning) return; + clearLightningMarkers(); + + // Simulate lightning strikes in the vicinity of the city (for now, use random placement) + // In a production app, you'd fetch from a lightning API like Blitzortung or similar + // For demo: show 3-5 random strikes within 0.5 degrees + const strokeCount = Math.random() < 0.7 ? 0 : (Math.floor(Math.random() * 4) + 1); + + for (let i = 0; i < strokeCount; i++) { + const latOffset = (Math.random() - 0.5) * 0.5; + const lonOffset = (Math.random() - 0.5) * 0.5; + const lat = parseFloat(city.latitude) + latOffset; + const lon = parseFloat(city.longitude) + lonOffset; + + const marker = L.circleMarker([lat, lon], { + radius: 4, + fillColor: '#ffeb3b', + fillOpacity: 0.8, + color: '#ff6f00', + weight: 1.5, + className: 'lightning-marker', + }).bindPopup('Lightning strike'); + + marker.addTo(mapsOverview.map); + state.lightningMarkers.push(marker); + } +} + +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')); + +// โ”€โ”€ 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(', '); + document.getElementById('loc-sub').textContent = [city.admin1, city.country].filter(Boolean).join(', '); const now = new Date(); document.getElementById('loc-time').innerHTML = @@ -242,40 +601,48 @@ function render(data, city) { 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 = Math.round(c.temperature_2m); - document.getElementById('cur-feels').textContent = Math.round(c.apparent_temperature); - document.getElementById('cur-hi').textContent = Math.round(d.temperature_2m_max[0]); - document.getElementById('cur-lo').textContent = Math.round(d.temperature_2m_min[0]); - document.getElementById('cur-precip').textContent = c.precipitation > 0 ? `${c.precipitation.toFixed(2)}" precip` : 'No precipitation'; - document.getElementById('cur-hum').textContent = c.relative_humidity_2m; - document.getElementById('cur-wind').textContent = `${Math.round(c.wind_speed_10m)} ${compassDir(c.wind_direction_10m)}`; - document.getElementById('cur-pres').textContent = Math.round(c.surface_pressure); - document.getElementById('cur-vis').textContent = c.visibility != null ? (c.visibility / 1609).toFixed(1) : '--'; - document.getElementById('cur-uv').textContent = Math.round(d.uv_index_max[0] ?? 0); + 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; - // Hourly - const nowHour = new Date().toISOString().slice(0, 13); - const startIdx = h.time.findIndex(t => t >= nowHour); 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}
-
${Math.round(h.temperature_2m[i])}ยฐ
-
${pp > 0 ? pp + '%' : ''}
+
${tempDisplay(h.temperature_2m[i])}ยฐ
+
${pp > 0 ? `${pp}% ${ptype}` : ptype}
`; track.appendChild(cell); } - // Daily const list = document.getElementById('daily-list'); list.innerHTML = ''; for (let i = 0; i < d.time.length; i++) { @@ -286,8 +653,8 @@ function render(data, city) {
${formatDayShort(d.time[i])}
${dw.icon}
${dw.desc}
-
${Math.round(d.temperature_2m_max[i])}ยฐ / ${Math.round(d.temperature_2m_min[i])}ยฐ
-
${d.precipitation_probability_max[i] > 0 ? d.precipitation_probability_max[i] + '% rain
' : ''}${Math.round(d.wind_speed_10m_max[i])} mph
+
${tempDisplay(d.temperature_2m_max[i])}ยฐ / ${tempDisplay(d.temperature_2m_min[i])}ยฐ
+
${d.precipitation_probability_max[i] > 0 ? d.precipitation_probability_max[i] + '% rain
' : ''}${speedDisplay(d.wind_speed_10m_max[i])} ${speedUnit()}
`; list.appendChild(row); } @@ -295,23 +662,89 @@ function render(data, city) { document.getElementById('updated-at').textContent = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); - // Pan both radar maps to the city - const zoom = 6; - radarEmbed.panTo(city.latitude, city.longitude, zoom); - radarFull.panTo(city.latitude, city.longitude, zoom); + 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); show(weatherDiv); } -// โ”€โ”€ Load weather for a city โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -async function loadWeather(city) { +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 { - const data = await fetchWeather(city.latitude, city.longitude, city.timezone); - render(data, city); + 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); @@ -320,6 +753,52 @@ async function loadWeather(city) { } } +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 = { + name: `Auto-detected (${lat.toFixed(2)}, ${lon.toFixed(2)})`, + latitude: lat, + longitude: lon, + country: '', + admin1: '', + timezone: 'auto', + }; + try { + const geoData = await geocode(`${lat.toFixed(2)},${lon.toFixed(2)}`); + const best = geoData.results?.[0]; + if (best) { + city.name = best.name || city.name; + city.country = best.country || ''; + city.admin1 = best.admin1 || ''; + city.timezone = best.timezone || 'auto'; + } + } catch { + // Reverse geocode is optional for startup auto-locate. + } + searchInput.value = city.name; + await loadAll(city, 'auto-detect'); + return true; + } catch { + return false; + } +} + // โ”€โ”€ Search autocomplete โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ let debounceTimer = null; @@ -327,24 +806,28 @@ 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); - loadWeather(r); + loadAll(r, 'search-result'); }); suggestEl.appendChild(li); }); show(suggestEl); - } catch { hide(suggestEl); } + } catch { + hide(suggestEl); + } }, 300); }); @@ -357,10 +840,11 @@ function doSearch() { 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; } - loadWeather(results[0]); + loadAll(results[0], 'search-result'); }).catch(() => showErr('Search failed. Please try again.')); } @@ -373,31 +857,190 @@ locateBtn.addEventListener('click', () => { clearErr(); hide(weatherDiv); show(loadingEl); + navigator.geolocation.getCurrentPosition(async pos => { const { latitude: lat, longitude: lon } = pos.coords; try { const geoData = await geocode(`${lat.toFixed(2)},${lon.toFixed(2)}`); const city = geoData.results?.[0] || { name: `${lat.toFixed(2)}, ${lon.toFixed(2)}`, - latitude: lat, longitude: lon, - country: '', admin1: '', timezone: 'auto' + latitude: lat, + longitude: lon, + country: '', + admin1: '', + timezone: 'auto' }; - city.latitude = lat; city.longitude = lon; - const data = await fetchWeather(lat, lon, city.timezone || 'auto'); - render(data, city); - } catch (e) { + city.latitude = lat; + city.longitude = lon; + await loadAll(city, 'gps-button'); + } catch { showErr('Could not fetch weather for your location.'); } finally { hide(loadingEl); } - }, () => { hide(loadingEl); showErr('Location access denied.'); }); + }, () => { + hide(loadingEl); + showErr('Location access denied.'); + }); }); -// โ”€โ”€ Default city โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +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) === 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)); + lightningStatus.textContent = state.showLightning ? 'On' : 'Off'; + 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)); + windStatus.textContent = state.showWind ? 'On' : 'Off'; + if (state.currentCity && mapsOverview.map) { + clearWindArrows(); + if (state.showWind && state.currentWeather) renderWindArrows(state.currentCity); + } +}); + +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 and wind toggle states + lightningToggle.checked = state.showLightning; + lightningStatus.textContent = state.showLightning ? 'On' : 'Off'; + windToggle.checked = state.showWind; + windStatus.textContent = state.showWind ? 'On' : 'Off'; + + 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 loadWeather(city); } - } catch { /* silent */ } + if (city) { + searchInput.value = city.name; + await loadAll(city, 'default-city'); + } + } catch { + // silent startup fallback + } })(); diff --git a/app/public/index.html b/app/public/index.html index 6e06197..d45ef07 100644 --- a/app/public/index.html +++ b/app/public/index.html @@ -25,6 +25,9 @@ Open-Meteo ยท RainViewer @@ -35,6 +38,7 @@ + @@ -54,7 +58,10 @@
-
+
+
+ +
@@ -81,6 +88,18 @@
Cloud Cover
--%
+
+
Sunrise
--
+
Sunset
--
+
Moon Phase
--
+
+ +
+
US AQI
--
+
PM2.5
--
+
Pollen
--
+
+
Radar @@ -92,6 +111,7 @@
+
--
@@ -126,6 +146,7 @@
+
--
@@ -138,6 +159,83 @@

Radar tiles ยฉ RainViewer ยท Map ยฉ OpenStreetMap

+ + + + + + + + +
diff --git a/app/public/style.css b/app/public/style.css index 295f0bb..546c81a 100644 --- a/app/public/style.css +++ b/app/public/style.css @@ -148,6 +148,88 @@ a:hover { text-decoration: underline; } padding: 0.6rem 0.9rem !important; } +#units-btn { + background: var(--paper-dark) !important; + color: var(--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); +} + +.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; @@ -212,6 +294,7 @@ a:hover { text-decoration: underline; } .loc-name { font-family: var(--serif); font-size: 1.6rem; font-weight: 400; line-height: 1.1; } .loc-sub { font-size: 0.68rem; color: var(--ink-light); letter-spacing: 0.06em; margin-top: 0.2rem; text-transform: uppercase; } .loc-time { font-size: 0.68rem; color: var(--ink-light); 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; } /* โ”€โ”€ Current hero โ”€โ”€ */ .current-grid { @@ -253,6 +336,129 @@ a:hover { text-decoration: underline; } margin-bottom: 2rem; } +.astro-grid, +.aq-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 1px; + background: var(--border); + border: 1px solid var(--border); + margin-bottom: 1rem; +} + +.astro-card, +.aq-card { + background: var(--paper-dark); + padding: 0.85rem 0.75rem; +} + +.astro-val, +.aq-val { + font-size: 0.95rem; + font-weight: 500; +} + +.alerts-wrap { + border: 1px solid var(--border); + padding: 0.75rem; + margin-bottom: 1.2rem; + background: var(--paper-dark); +} + +.alerts-list { + display: grid; + gap: 0.5rem; +} + +.alert-item { + display: grid; + gap: 0.2rem; + border: 1px solid var(--border); + padding: 0.55rem; + background: var(--paper); + font-size: 0.72rem; +} + +.panel-note { + font-size: 0.72rem; + color: var(--ink-light); + border: 1px solid var(--border); + padding: 0.75rem; + background: var(--paper-dark); +} + +.settings-wrap { + border: 1px solid var(--border); + background: var(--paper-dark); + padding: 0.9rem; + display: grid; + gap: 1rem; +} + +.settings-row { + display: flex; + align-items: center; + justify-content: space-between; + border: 1px solid var(--border); + background: var(--paper); + 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 var(--border); + background: var(--paper-darker); + color: var(--ink); + padding: 0.45rem 0.7rem; + font-family: var(--mono); + font-size: 0.72rem; + cursor: pointer; +} + +.settings-btn:hover { background: var(--paper-dark); } + +.maps-wrap { + border: 1px solid var(--border); + background: var(--paper-dark); + padding: 0.75rem; +} + +.map-overview { + height: 360px; + border: 1px solid var(--border); +} + +.maps-meta { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1px; + margin-top: 0.75rem; + background: var(--border); + border: 1px solid var(--border); +} + +.maps-card { + background: var(--paper); + padding: 0.7rem; +} + +.maps-value { + font-size: 0.85rem; + color: var(--ink); +} + .stat { background: var(--paper-dark); padding: 0.9rem 0.75rem; text-align: center; } .stat-label { font-size: 0.58rem; letter-spacing: 0.1em; color: var(--ink-light); text-transform: uppercase; margin-bottom: 0.3rem; } .stat-val { font-size: 1rem; font-weight: 500; } @@ -330,6 +536,7 @@ a:hover { text-decoration: underline; } } .radar-btn:hover { opacity: 0.72; } +.layer-btn { background: var(--accent); } .radar-timeline { flex: 1; @@ -500,12 +707,21 @@ a:hover { text-decoration: underline; } .masthead { grid-template-columns: 1fr 1fr; grid-template-rows: auto auto; } .masthead h1 { font-size: 1.6rem; } .tagline { display: none; } - .tabs { grid-column: 1 / -1; } + .tabs { grid-column: 1 / -1; overflow-x: auto; } + .tab { min-width: 88px; } .stats-bar { grid-template-columns: repeat(3, 1fr); } + .astro-grid, + .aq-grid { grid-template-columns: 1fr; } .current-grid { grid-template-columns: 1fr; } .temp-big { font-size: 4rem; } .day-row { grid-template-columns: 80px 36px 1fr 70px; } .day-meta { display: none; } .radar-map { height: 240px; } .legend-bar { width: 50px; } + .loc-actions { align-items: flex-start; margin-top: 0.5rem; } + .loc-header { flex-direction: column; align-items: flex-start; } + .settings-row { flex-direction: column; align-items: flex-start; gap: 0.5rem; } + .settings-value { text-align: left; } + .map-overview { height: 280px; } + .maps-meta { grid-template-columns: 1fr; } } diff --git a/app/server.js b/app/server.js index cf0ed08..bbf75d0 100644 --- a/app/server.js +++ b/app/server.js @@ -22,9 +22,10 @@ app.get('/api/geocode', async (req, res) => { // Weather proxy app.get('/api/weather', async (req, res) => { - const { lat, lon, tz } = req.query; + 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, @@ -35,17 +36,17 @@ app.get('/api/weather', async (req, res) => { ].join(','), hourly: [ 'temperature_2m','precipitation_probability','weather_code', - 'wind_speed_10m','apparent_temperature' + '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: 7, - wind_speed_unit: 'mph', - temperature_unit: 'fahrenheit', - precipitation_unit: 'inch' + forecast_days: 16, + wind_speed_unit: useMetric ? 'kmh' : 'mph', + temperature_unit: useMetric ? 'celsius' : 'fahrenheit', + precipitation_unit: useMetric ? 'mm' : 'inch' }); const r = await fetch(`https://api.open-meteo.com/v1/forecast?${params}`); const data = await r.json(); @@ -55,6 +56,44 @@ app.get('/api/weather', async (req, res) => { } }); +// 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 r = await fetch(`https://air-quality-api.open-meteo.com/v1/air-quality?${params}`); + const data = await r.json(); + 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 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', + }, + }); + const data = await r.json(); + res.json(data); + } catch (e) { + res.status(500).json({ error: 'Alerts fetch failed', detail: e.message }); + } +}); + // RainViewer timestamps proxy app.get('/api/radar/times', async (req, res) => { try {