/* 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); }; } // Keep sticky section headings flush under the (safe-area aware) top bar const topbarEl = document.querySelector('.topbar'); if (topbarEl && 'ResizeObserver' in window) { new ResizeObserver(() => document.documentElement.style.setProperty('--topbar-h', topbarEl.offsetHeight + 'px')).observe(topbarEl); } // ── 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, last: 0, onScreen: true }; const PHONE = window.matchMedia('(max-width: 640px)').matches; const fxShouldRun = () => fx.parts.length && !REDUCED && !document.hidden && fx.onScreen && document.body.dataset.tab === 'conditions'; function fxKick() { if (!fx.raf && fxShouldRun()) fx.raf = requestAnimationFrame(fxFrame); } 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 base = kind === 'storm' ? 130 : kind === 'rain' ? 85 : kind === 'snow' ? 70 : kind === 'clear-night' ? 55 : 0; const n = Math.round(base * (PHONE ? 0.55 : 1)); 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) { fx.raf = 0; const canvas = $('sky-fx'); if (!canvas || !fxShouldRun()) return; // hidden tab / scrolled away / app in background: stop drawing entirely if (t - fx.last < 33) { fx.raf = requestAnimationFrame(fxFrame); return; } // cap at ~30 fps fx.last = t; 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(); } } 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.raf = 0; fx.kind = kind; fxResize(canvas); fxSeed(kind); if (fx.parts.length) fxKick(); 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', fxKick); if ('IntersectionObserver' in window && $('hero')) new IntersectionObserver((es) => { fx.onScreen = es[0].isIntersecting; fxKick(); }).observe($('hero')); // ── 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); } // ── Map tab: independent, remembered view (not tied to the main-page radar) ── let mapCenteredFor = null; const mapCityKey = (c) => `${(+c.latitude).toFixed(2)},${(+c.longitude).toFixed(2)}`; function centerMap(city, force) { const m = typeof mapsOverview !== 'undefined' ? mapsOverview?.map : null; if (!m || city?.latitude == null) return; const key = mapCityKey(city); if (!force && mapCenteredFor === key) return; // keep wherever the user panned/zoomed to let saved = null; try { saved = JSON.parse(localStorage.getItem('mapView') || 'null'); } catch {} if (!force && !mapCenteredFor && saved && saved.key === key && Number.isFinite(saved.zoom)) m.jumpTo({ center: [saved.lng, saved.lat], zoom: saved.zoom }); else mapsOverview.panTo(city.latitude, city.longitude, force ? 7 : 5); // wider regional view than the main-page radar mapCenteredFor = key; } (() => { const m = typeof mapsOverview !== 'undefined' ? mapsOverview?.map : null; if (!m) return; let t = 0; m.on('moveend', () => { if (!mapCenteredFor) return; clearTimeout(t); t = setTimeout(() => { try { const c = m.getCenter(); localStorage.setItem('mapView', JSON.stringify({ key: mapCenteredFor, lng: c.lng, lat: c.lat, zoom: m.getZoom() })); } catch {} }, 400); }); $('map-recenter-btn')?.addEventListener('click', () => { if (state.currentCity) centerMap(state.currentCity, true); }); })(); // ── Location permission status (so "blocked" is visible, not guessed) ──────── const GeoStatus = (() => { const el = $('geo-status'); let status = null; async function render() { if (!el || !navigator.permissions || !navigator.geolocation) return; try { if (!status) { status = await navigator.permissions.query({ name: 'geolocation' }); status.addEventListener('change', render); } } catch { return; } const s = status.state; const ios = /iPad|iPhone|iPod/.test(navigator.userAgent) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1); const standalone = window.matchMedia('(display-mode: standalone)').matches || navigator.standalone === true; if (s === 'granted') { el.className = 'geo-status ok'; el.textContent = '📍 Location: allowed. Tap ⌖ to use it.'; } else if (s === 'prompt') { el.className = 'geo-status'; el.textContent = '📍 Location: not asked yet. Tap ⌖ and choose Allow when your phone asks.'; } else if (s === 'denied') { el.className = 'geo-status warn'; el.textContent = ios ? `📍 Location: your phone has this ${standalone ? 'app' : 'site'} set to Don't Allow. Fix it in Settings → Privacy & Security → Location Services → ${standalone ? '"NeCloud Weather"' : 'Safari Websites'}. A Safari content blocker can also do this: try aA → Turn Off Content Blockers.` : '📍 Location: blocked for this site. Click the lock icon in the address bar → Location → Allow.'; } else { el.classList.add('hidden'); return; } el.classList.remove('hidden'); } render(); return { refresh: render }; })(); // ── 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(); }, centerMap, refreshGeoStatus: () => GeoStatus.refresh(), onAir(air) { renderAqi(air); }, refreshFx() { const c = $('sky-fx'); if (c && fx.kind) { cancelAnimationFrame(fx.raf); fx.raf = 0; fxResize(c); fxSeed(fx.kind); fxKick(); } }, onStation() { syncTopbarLocation(); renderStationContext(); }, }; // ── Install as an app (PWA): guidance for iPhone/iPad and Android ──────────── const Install = (() => { const ua = navigator.userAgent || ''; const isIOS = /iPad|iPhone|iPod/.test(ua) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1); const isAndroid = /Android/i.test(ua); const standalone = () => window.matchMedia('(display-mode: standalone)').matches || window.navigator.standalone === true; let deferred = null; // Chrome/Edge/Samsung install prompt window.addEventListener('beforeinstallprompt', (e) => { e.preventDefault(); deferred = e; refresh(); }); window.addEventListener('appinstalled', () => { deferred = null; localStorage.setItem('installDone', '1'); refresh(); }); const SHARE = ''; const DOTS = ''; const steps = { ios: `
  1. Open this page in Safari. (On iOS 16.4+ Chrome and Edge can do it too.)
  2. Tap the Share button ${SHARE} at the bottom of the screen (top right on iPad).
  3. Scroll down and tap Add to Home Screen.
  4. Tap Add. The app icon appears on your Home Screen.

Open it from the Home Screen icon for the full-screen app. iPhone has no automatic install pop-up, so this manual step is the only way.

`, android: `
  1. Open this page in Chrome.
  2. Tap the menu ${DOTS} (top right).
  3. Tap Install app (or Add to Home screen).
  4. Tap Install. The app shows up in your app drawer and on your Home screen.

Samsung Internet: menu (☰) → Add page to → Home screen. Firefox: menu → Install.

`, }; const other = '

Desktop Chrome/Edge: click the install icon in the address bar (or menu → Install NeCloud Weather).

'; const dashNote = '

The Station Dashboard installs separately: open /dashboard and follow the same steps to get its own icon.

'; function platform() { return isIOS ? 'ios' : isAndroid ? 'android' : null; } function body(active) { const p = active || platform() || 'ios'; const tabs = `
`; const promptBtn = deferred && p === 'android' ? '' : ''; return `${tabs}
${promptBtn}${steps[p]}${!platform() ? other : ''}${dashNote}
`; } function wire(root) { root.querySelectorAll('.ins-tab').forEach((b) => b.addEventListener('click', () => { root.innerHTML = body(b.dataset.p); wire(root); })); root.querySelector('#ins-native')?.addEventListener('click', async () => { if (!deferred) return; deferred.prompt(); try { await deferred.userChoice; } catch {} deferred = null; refresh(); }); } function renderCard() { const card = $('install-card'); if (!card) return; if (standalone()) { card.innerHTML = '

✓ You\'re using the installed app.

' + dashNote; return; } card.innerHTML = `

Add NeCloud Weather to your phone like a normal app: full screen, its own icon, and it opens instantly.

${body()}
`; wire(card.querySelector('.ins-wrap')); } // Banner: phones only, not installed, not dismissed within 30 days function renderBanner() { const el = $('install-banner'); if (!el) return; let dismissed = 0; try { dismissed = +localStorage.getItem('installDismissed') || 0; } catch {} const show = !standalone() && (isIOS || isAndroid) && Date.now() - dismissed > 30 * 86400000 && !localStorage.getItem('installDone'); el.classList.toggle('hidden', !show); $('ib-how').textContent = deferred ? 'Install' : 'How'; } function openDialog() { const dlg = $('install-dialog'), b = $('install-dialog-body'); if (!dlg || !b) return; b.innerHTML = body(); wire(b); if (typeof dlg.showModal === 'function') dlg.showModal(); else dlg.setAttribute('open', ''); } function refresh() { renderBanner(); renderCard(); } $('ib-how')?.addEventListener('click', async () => { if (deferred) { deferred.prompt(); try { await deferred.userChoice; } catch {} deferred = null; refresh(); } else openDialog(); }); $('ib-close')?.addEventListener('click', () => { try { localStorage.setItem('installDismissed', String(Date.now())); } catch {} $('install-banner').classList.add('hidden'); }); $('install-dialog-close')?.addEventListener('click', () => $('install-dialog').close()); $('install-dialog')?.addEventListener('click', (e) => { if (e.target === e.currentTarget) e.currentTarget.close(); }); refresh(); return { refresh, openDialog }; })(); syncTopbarLocation(); new MutationObserver(syncTopbarLocation).observe($('loc-name') || document.body, { childList: true, characterData: true, subtree: true }); })();