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 = `
Radar tiles ยฉ RainViewer ยท Map ยฉ OpenStreetMap