/* Main app redesign layer — builds on globals from app.js (state, wmo, tempDisplay, radarEmbed, …). app.js calls AppUI.onWeather / onAir / onStation after rendering. */ (() => { 'use strict'; const $ = (id) => document.getElementById(id); const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null); const us = () => state.units === 'us'; const REDUCED = window.matchMedia('(prefers-reduced-motion: reduce)').matches; // ── Tab tracking (drives skeleton visibility, top-bar state) ───────────────── function currentTabName() { return document.querySelector('.tab.active, .bottom-tab.active')?.dataset.tab || 'conditions'; } document.body.dataset.tab = currentTabName(); const baseActivate = window.activateTab; if (typeof baseActivate === 'function') { window.activateTab = function (name) { baseActivate(name); document.body.dataset.tab = name; window.scrollTo({ top: 0 }); if (name === 'conditions' && window.AppUI) setTimeout(window.AppUI.refreshFx, 30); }; } // ── Top bar: location chip opens search ────────────────────────────────────── $('tb-search')?.addEventListener('click', () => { window.activateTab('settings'); setTimeout(() => { const input = $('search-input'); if (input) { input.scrollIntoView({ block: 'center', behavior: 'smooth' }); input.focus({ preventScroll: true }); } }, 120); }); function syncTopbarLocation() { const name = $('loc-name')?.textContent?.trim(); if ($('tb-loc')) $('tb-loc').textContent = name || 'Location'; } // ── Sky: gradient + precipitation / star effects ───────────────────────────── function skyFor(code, isDay, stationRaining) { if (stationRaining) return 'rain'; if (code >= 95) return 'storm'; if ([71, 73, 75, 77, 85, 86].includes(code)) return 'snow'; if ([51, 53, 55, 56, 57, 61, 63, 65, 66, 67, 80, 81, 82].includes(code)) return 'rain'; if ([45, 48].includes(code)) return 'fog'; if (code === 2 || code === 3) return isDay ? 'cloudy-day' : 'cloudy-night'; return isDay ? 'clear-day' : 'clear-night'; } const fx = { raf: 0, kind: null, parts: [], w: 0, h: 0, flash: 0 }; function fxResize(canvas) { const dpr = Math.min(window.devicePixelRatio || 1, 2); fx.w = canvas.clientWidth; fx.h = canvas.clientHeight; canvas.width = fx.w * dpr; canvas.height = fx.h * dpr; canvas.getContext('2d').setTransform(dpr, 0, 0, dpr, 0, 0); } function fxSeed(kind) { const n = kind === 'storm' ? 130 : kind === 'rain' ? 85 : kind === 'snow' ? 70 : kind === 'clear-night' ? 55 : 0; fx.parts = Array.from({ length: n }, () => ({ x: Math.random() * fx.w, y: Math.random() * fx.h, v: kind === 'snow' ? 0.4 + Math.random() * 0.9 : kind === 'clear-night' ? 0 : 6 + Math.random() * 7, r: kind === 'snow' ? 1 + Math.random() * 2.2 : kind === 'clear-night' ? 0.4 + Math.random() * 1.2 : 0.8 + Math.random() * 0.8, p: Math.random() * Math.PI * 2, })); } function fxFrame(t) { const canvas = $('sky-fx'); if (!canvas) return; const ctx = canvas.getContext('2d'); ctx.clearRect(0, 0, fx.w, fx.h); const kind = fx.kind; if (kind === 'rain' || kind === 'storm') { ctx.strokeStyle = 'rgba(200,220,255,0.55)'; ctx.lineWidth = 1.1; ctx.lineCap = 'round'; ctx.beginPath(); for (const p of fx.parts) { ctx.moveTo(p.x, p.y); ctx.lineTo(p.x - 2.2, p.y + p.v * 1.6); p.y += p.v; p.x -= 0.55; if (p.y > fx.h) { p.y = -10; p.x = Math.random() * (fx.w + 20); } } ctx.stroke(); if (kind === 'storm') { if (fx.flash <= 0 && Math.random() < 0.004) fx.flash = 1; if (fx.flash > 0) { ctx.fillStyle = `rgba(255,255,255,${(fx.flash * 0.28).toFixed(3)})`; ctx.fillRect(0, 0, fx.w, fx.h); fx.flash -= 0.07; } } } else if (kind === 'snow') { ctx.fillStyle = 'rgba(255,255,255,0.85)'; for (const p of fx.parts) { ctx.beginPath(); ctx.arc(p.x + Math.sin(t / 900 + p.p) * 6, p.y, p.r, 0, Math.PI * 2); ctx.fill(); p.y += p.v; if (p.y > fx.h + 4) { p.y = -4; p.x = Math.random() * fx.w; } } } else if (kind === 'clear-night') { for (const p of fx.parts) { ctx.fillStyle = `rgba(255,255,255,${(0.35 + 0.55 * Math.abs(Math.sin(t / 1400 + p.p))).toFixed(2)})`; ctx.beginPath(); ctx.arc(p.x, p.y * 0.8, p.r, 0, Math.PI * 2); ctx.fill(); } } if (!REDUCED && !document.hidden) fx.raf = requestAnimationFrame(fxFrame); } function setSky(kind) { const hero = $('hero'), canvas = $('sky-fx'); if (!hero || !canvas) return; hero.dataset.sky = kind; cancelAnimationFrame(fx.raf); fx.kind = kind; fxResize(canvas); fxSeed(kind); if (fx.parts.length) fxFrame(performance.now()); else canvas.getContext('2d').clearRect(0, 0, fx.w, fx.h); } window.addEventListener('resize', () => { const c = $('sky-fx'); if (c && fx.kind) { fxResize(c); fxSeed(fx.kind); } }); document.addEventListener('visibilitychange', () => { if (!document.hidden && fx.parts.length && !REDUCED) { cancelAnimationFrame(fx.raf); fxFrame(performance.now()); } }); // ── Nowcast: next 2 hours from 15-minute precipitation ─────────────────────── function nowcastSlots(data) { const m = data.minutely_15; const now = Date.now(); if (m?.time?.length && m.precipitation) { let i = m.time.findIndex((t) => new Date(t).getTime() + 15 * 60000 > now); if (i < 0) i = 0; const slots = []; for (let k = i; k < m.time.length; k++) slots.push({ t: new Date(m.time[k]).getTime(), v: m.precipitation[k] || 0 }); if (slots.length >= 4) return { slots, source: '15min' }; } // Fallback: hourly rain chance spread over 15-minute slots const h = data.hourly; let si = h.time.findIndex((t) => new Date(t).getTime() >= now - 3600000); if (si < 0) si = 0; const slots = []; for (let k = si; k < Math.min(si + 6, h.time.length); k++) { const t0 = new Date(h.time[k]).getTime(); const pp = (h.precipitation_probability?.[k] || 0) / 100; const mm = h.rain?.[k] || 0; for (let q = 0; q < 4; q++) slots.push({ t: t0 + q * 900000, v: pp >= 0.5 ? Math.max(mm / 4, us() ? 0.006 : 0.15) : 0, pp }); } return { slots: slots.filter((s) => s.t + 900000 > now), source: 'hourly' }; } const WET = () => (us() ? 0.004 : 0.1); const HEAVY = () => (us() ? 0.04 : 1.0); const mins = (ms) => Math.max(0, Math.round(ms / 60000)); function human(m) { return m >= 90 ? `${Math.round(m / 6) / 10}h`.replace('.0h', 'h') : `${m} min`; } function rainOutlook(slots) { const now = Date.now(); const wet = (s) => s.v >= WET(); const first = slots[0]; if (!slots.length) return { text: '', short: '' }; if (first && wet(first)) { const dry = slots.findIndex((s) => !wet(s)); if (dry < 0) return { text: 'Rain continuing for the next few hours', short: 'Rain continuing', kind: 'raining' }; const end = mins(slots[dry].t - now); return { text: `Rain ending in about ${human(end)}`, short: `Rain ends ~${human(end)}`, kind: 'raining' }; } const idx = slots.findIndex(wet); if (idx < 0) return { text: `No rain expected in the next ${Math.round((slots[slots.length - 1].t + 900000 - now) / 3600000)} hours`, short: 'Dry ahead', kind: 'dry' }; const start = mins(slots[idx].t - now); let len = 0; for (let k = idx; k < slots.length && wet(slots[k]); k++) len += 15; const reachesEnd = idx + len / 15 >= slots.length; const dur = reachesEnd ? `${human(len)}+` : `about ${human(len)}`; const when = start <= 5 ? 'Rain starting now' : `Rain starting in ${human(start)}`; return { text: `${when}, lasting ${dur}`, short: start <= 5 ? 'Rain now' : `Rain in ${human(start)}`, kind: 'soon', start }; } function renderNowcast(data) { const host = $('nowcast-bars'); if (!host) return { text: '', short: '' }; const { slots, source } = nowcastSlots(data); const next8 = slots.slice(0, 8); const scale = Math.max(HEAVY() * 1.5, ...next8.map((s) => s.v)); host.innerHTML = next8.map((s, i) => { const hPct = s.v >= WET() ? Math.max(14, Math.min(100, (s.v / scale) * 100)) : 7; const cls = s.v >= HEAVY() ? 'heavy' : s.v >= WET() ? 'wet' : ''; const label = i === 0 ? 'Now' : i % 2 === 0 ? new Date(s.t).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }).replace(' ', '').toLowerCase() : ''; const unit = us() ? 'in' : 'mm'; return `
${label}
`; }).join(''); const outlook = rainOutlook(slots); $('nowcast-note').textContent = source === '15min' ? outlook.short : `${outlook.short} · hourly est.`; return outlook; } // ── "Bring" chips react to the rain outlook and the coming cold ────────────── function enrichBring(data, outlook) { const grid = $('recommendations'); if (!grid) return; const have = (label) => [...grid.querySelectorAll('.rec-label')].some((e) => e.textContent.toLowerCase().includes(label)); const add = (icon, label) => grid.insertAdjacentHTML('afterbegin', `
${icon}${label}
`); const h = data.hourly; let si = h.time.findIndex((t) => new Date(t).getTime() >= Date.now()); if (si < 0) si = 0; const next12 = h.temperature_2m.slice(si, si + 12); const coldF = (t) => (us() ? t : t * 9 / 5 + 32); if (next12.length && coldF(Math.min(...next12)) <= 50 && !have('jacket') && !have('coat')) add('🧥', 'Jacket'); if ((outlook?.kind === 'soon' || outlook?.kind === 'raining') && !have('umbrella')) add('☂️', 'Umbrella'); } // ── Summary line ───────────────────────────────────────────────────────────── function renderSummary(data, outlook) { const el = $('summary-line'); if (!el) return; const c = data.current, d = data.daily; const w = wmo(c.weather_code); const hi = tempDisplay(d.temperature_2m_max[0]), lo = tempDisplay(d.temperature_2m_min[0]); const parts = []; if (outlook?.kind === 'soon' || outlook?.kind === 'raining') parts.push(`${outlook.text}.`); else parts.push(`${w.desc} now, high ${hi}°, low ${lo}°.`); const wind = c.wind_speed_10m; const windy = (us() ? wind >= 18 : wind >= 29); const subs = []; if (outlook?.kind === 'soon' || outlook?.kind === 'raining') subs.push(`${w.desc} · high ${hi}°, low ${lo}°`); if (windy) subs.push(`Breezy: ${speedDisplay(wind)} ${speedUnit()}${c.wind_gusts_10m ? `, gusts ${speedDisplay(c.wind_gusts_10m)}` : ''}`); const nAlerts = state.currentAlerts?.features?.length || 0; if (nAlerts) subs.push(`⚠ ${nAlerts} active alert${nAlerts > 1 ? 's' : ''} — see Alerts`); el.innerHTML = `${parts.join(' ')}${subs.length ? `${subs.join(' · ')}` : ''}`; } // ── Hourly scrub chart (SVG, no dependencies) ──────────────────────────────── function renderHourlyChart(data) { const host = $('hourly-chart'), readout = $('hc-readout'); if (!host) return; const h = data.hourly; let start = h.time.findIndex((t) => new Date(t).getTime() >= Date.now() - 1800000); if (start < 0) start = 0; const n = Math.min(state.hourlyExtended ? 48 : 24, h.time.length - start); const idx = Array.from({ length: n }, (_, i) => start + i); const feels = state.hourlyFeelsLike; const temp = idx.map((i) => (feels ? h.apparent_temperature?.[i] ?? h.temperature_2m[i] : h.temperature_2m[i])); const pop = idx.map((i) => h.precipitation_probability?.[i] ?? 0); const wind = idx.map((i) => h.wind_speed_10m?.[i] ?? 0); const times = idx.map((i) => new Date(h.time[i])); const W = 640, H = 200, L = 34, R = 12, T = 14, B = 26; const pw = W - L - R, ph = H - T - B; const tMin = Math.floor(Math.min(...temp) - 2), tMax = Math.ceil(Math.max(...temp) + 2); const wMax = Math.max(15, ...wind); const x = (i) => L + (n === 1 ? 0 : (i / (n - 1)) * pw); const yT = (v) => T + (1 - (v - tMin) / (tMax - tMin)) * ph; const yW = (v) => T + (1 - v / wMax) * ph; const barW = Math.max(3, pw / n - 3); const bars = pop.map((p, i) => p > 0 ? `` : '').join(''); const line = (arr, yf) => arr.map((v, i) => `${i ? 'L' : 'M'}${x(i).toFixed(1)},${yf(v).toFixed(1)}`).join(''); const tLine = line(temp, yT); const area = `${tLine}L${x(n - 1).toFixed(1)},${T + ph}L${x(0).toFixed(1)},${T + ph}Z`; // x-axis labels + midnight dividers let xl = ''; const step = n > 30 ? 6 : 3; times.forEach((t, i) => { const mid = t.getHours() === 0; if (mid && i > 0) xl += `${times[i].toLocaleDateString([], { weekday: 'short' })}`; if (i % step === 0) xl += `${i === 0 ? 'Now' : t.toLocaleTimeString([], { hour: 'numeric' }).replace(' ', '').toLowerCase()}`; }); // y-axis (temperature) let yl = ''; for (let k = 0; k <= 3; k++) { const v = tMin + ((tMax - tMin) * k) / 3, yy = yT(v); yl += `${tempDisplay(v)}°`; } host.innerHTML = ` ${yl}${bars} ${xl} `; const svg = host.querySelector('svg'); const fmtT = (d) => d.toLocaleDateString([], { weekday: 'short' }) + ' ' + d.toLocaleTimeString([], { hour: 'numeric' }); const show = (i) => { const code = h.weather_code?.[idx[i]]; readout.innerHTML = `${i === 0 ? 'Now' : fmtT(times[i])}${code != null ? wmo(code).icon : ''} ${tempDisplay(temp[i])}°💧 ${pop[i]}%💨 ${speedDisplay(wind[i])} ${speedUnit()}`; const cur = svg.querySelector('#hc-cursor'); cur.style.display = ''; svg.querySelector('#hc-line').setAttribute('x1', x(i)); svg.querySelector('#hc-line').setAttribute('x2', x(i)); svg.querySelector('#hc-dot').setAttribute('cx', x(i)); svg.querySelector('#hc-dot').setAttribute('cy', yT(temp[i])); }; const move = (ev) => { const r = svg.getBoundingClientRect(); const vx = ((ev.clientX - r.left) / r.width) * W; show(Math.max(0, Math.min(n - 1, Math.round(((vx - L) / pw) * (n - 1))))); }; svg.addEventListener('pointermove', move); svg.addEventListener('pointerdown', move); const reset = () => { svg.querySelector('#hc-cursor').style.display = 'none'; show(0); svg.querySelector('#hc-cursor').style.display = 'none'; }; svg.addEventListener('pointerleave', reset); svg.addEventListener('pointercancel', reset); show(0); svg.querySelector('#hc-cursor').style.display = 'none'; } // ── Air quality gauge + UV category ────────────────────────────────────────── function aqiInfo(v) { if (v == null) return ['', '#94a3b8']; if (v <= 50) return ['Good', '#22c55e']; if (v <= 100) return ['Moderate', '#eab308']; if (v <= 150) return ['Sensitive', '#f97316']; if (v <= 200) return ['Unhealthy', '#ef4444']; if (v <= 300) return ['Very unhealthy', '#a855f7']; return ['Hazardous', '#991b1b']; } function uvCategory(uv) { if (uv == null) return ''; return uv < 3 ? 'Low' : uv < 6 ? 'Moderate' : uv < 8 ? 'High' : uv < 11 ? 'Very high' : 'Extreme'; } function renderAqi(air) { const v = num(air?.current?.us_aqi); const [label, color] = aqiInfo(v); const cat = $('aqi-cat'), marker = $('aqi-marker'); if (cat) { cat.textContent = label; cat.style.color = color; } if (marker) marker.style.left = v == null ? '0%' : `${Math.min(100, (v / 300) * 100).toFixed(1)}%`; } // ── Station context (yesterday / normal) ───────────────────────────────────── let summaryCache = { at: 0, data: null }; async function loadStationSummary() { if (summaryCache.data && Date.now() - summaryCache.at < 5 * 60000) return summaryCache.data; try { const r = await fetch('/api/station/summary'); if (!r.ok) return null; summaryCache = { at: Date.now(), data: await r.json() }; return summaryCache.data; } catch { return null; } } async function renderStationContext() { const el = $('hero-context'); if (!el) return; const s = await loadStationSummary(); const y = s?.yesterday; if (!y || y.hi == null || y.lo == null) { el.classList.add('hidden'); return; } const conv = (f) => (us() ? Math.round(f) : Math.round((f - 32) * 5 / 9)); let text = `Yesterday ${conv(y.hi)}° / ${conv(y.lo)}°`; const todayHi = state.currentWeather?.daily?.temperature_2m_max?.[0]; if (s.normal?.hi != null && todayHi != null) { const normHi = us() ? s.normal.hi : (s.normal.hi - 32) * 5 / 9; const diff = Math.round(todayHi - normHi); if (Math.abs(diff) >= 1) text += ` · ${Math.abs(diff)}° ${diff > 0 ? 'warmer' : 'cooler'} than usual today`; else text += ' · about normal for today'; } el.textContent = text; el.classList.remove('hidden'); } // ── Radar: location marker with rain countdown + alert polygons ────────────── let radarMarker = null, radarMarkerEl = null; function updateRadar(city, outlook) { const map = typeof radarEmbed !== 'undefined' ? radarEmbed?.map : null; if (!map || typeof maplibregl === 'undefined' || city?.latitude == null) return; const label = outlook?.kind === 'soon' || outlook?.kind === 'raining' ? `📍 ${outlook.short}` : `📍 ${city.name || 'You'}`; if (!radarMarker) { radarMarkerEl = document.createElement('div'); radarMarkerEl.className = 'radar-loc-marker'; radarMarkerEl.innerHTML = '
'; radarMarker = new maplibregl.Marker({ element: radarMarkerEl, anchor: 'bottom' }).setLngLat([city.longitude, city.latitude]).addTo(map); } else radarMarker.setLngLat([city.longitude, city.latitude]); radarMarkerEl.querySelector('.radar-loc-label').textContent = label; const feats = (state.currentAlerts?.features || []).filter((f) => f.geometry); const apply = () => { try { const fc = { type: 'FeatureCollection', features: feats }; if (map.getSource('ui-alerts')) { map.getSource('ui-alerts').setData(fc); return; } if (!feats.length) return; map.addSource('ui-alerts', { type: 'geojson', data: fc }); map.addLayer({ id: 'ui-alerts-fill', type: 'fill', source: 'ui-alerts', paint: { 'fill-color': '#ef4444', 'fill-opacity': 0.14 } }); map.addLayer({ id: 'ui-alerts-line', type: 'line', source: 'ui-alerts', paint: { 'line-color': '#ef4444', 'line-width': 2, 'line-dasharray': [2, 1] } }); } catch { /* style swapped mid-update; next refresh retries */ } }; if (map.isStyleLoaded()) apply(); else map.once('idle', apply); } // ── Public hooks ───────────────────────────────────────────────────────────── let lastOutlook = null; window.AppUI = { onWeather(data, city) { syncTopbarLocation(); const peek = $('wind-drawer-peek'); if (peek) peek.textContent = peek.textContent.replace(/^[\s·]+/, ''); const c = data.current; const d = state.ambientDevice?.lastData; const stationFresh = !!(d?.dateutc && Date.now() - d.dateutc < 15 * 60000 && state.ambientUseStation); if (!stationFresh) { $('station-badge')?.classList.add('hidden'); $('hero-context')?.classList.add('hidden'); } setSky(skyFor(c.weather_code, !!c.is_day, stationFresh && d.rainratein > 0)); $('uv-cat').textContent = uvCategory(data.daily?.uv_index_max?.[0]); lastOutlook = renderNowcast(data); enrichBring(data, lastOutlook); if ((lastOutlook?.kind === 'soon' || lastOutlook?.kind === 'raining') && $('cur-precip')) $('cur-precip').textContent = lastOutlook.short; renderSummary(data, lastOutlook); renderHourlyChart(data); updateRadar(city, lastOutlook); if (state.currentAir) renderAqi(state.currentAir); if (stationFresh) renderStationContext(); }, onAir(air) { renderAqi(air); }, refreshFx() { const c = $('sky-fx'); if (c && fx.kind) { cancelAnimationFrame(fx.raf); fxResize(c); fxSeed(fx.kind); if (fx.parts.length) fxFrame(performance.now()); } }, onStation() { syncTopbarLocation(); renderStationContext(); }, }; syncTopbarLocation(); new MutationObserver(syncTopbarLocation).observe($('loc-name') || document.body, { childList: true, characterData: true, subtree: true }); })();