When configured, a new "Use as primary source" toggle appears in Settings under Ambient Weather Station (defaults on). When on, the location header shows the station name and "Ambient Weather Station" subtitle instead of the city; the hero badge is hidden as redundant. Toggling off restores the city name immediately and re-shows the hero badge. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
3086 lines
117 KiB
JavaScript
3086 lines
117 KiB
JavaScript
'use strict';
|
||
|
||
// ── WMO codes ────────────────────────────────────────────────────────────────
|
||
const WMO = {
|
||
0:{desc:'Clear sky',icon:'☀️'},1:{desc:'Mainly clear',icon:'🌤️'},
|
||
2:{desc:'Partly cloudy',icon:'⛅'},3:{desc:'Overcast',icon:'☁️'},
|
||
45:{desc:'Foggy',icon:'🌫️'},48:{desc:'Icy fog',icon:'🌫️'},
|
||
51:{desc:'Light drizzle',icon:'🌦️'},53:{desc:'Drizzle',icon:'🌦️'},
|
||
55:{desc:'Heavy drizzle',icon:'🌧️'},56:{desc:'Freezing drizzle',icon:'🌨️'},
|
||
57:{desc:'Heavy freezing drizzle',icon:'🌨️'},
|
||
61:{desc:'Light rain',icon:'🌧️'},63:{desc:'Rain',icon:'🌧️'},
|
||
65:{desc:'Heavy rain',icon:'🌧️'},66:{desc:'Freezing rain',icon:'🌨️'},
|
||
67:{desc:'Heavy freezing rain',icon:'🌨️'},
|
||
71:{desc:'Light snow',icon:'🌨️'},73:{desc:'Snow',icon:'❄️'},
|
||
75:{desc:'Heavy snow',icon:'❄️'},77:{desc:'Snow grains',icon:'🌨️'},
|
||
80:{desc:'Light showers',icon:'🌦️'},81:{desc:'Showers',icon:'🌧️'},
|
||
82:{desc:'Heavy showers',icon:'⛈️'},85:{desc:'Snow showers',icon:'🌨️'},
|
||
86:{desc:'Heavy snow showers',icon:'❄️'},
|
||
95:{desc:'Thunderstorm',icon:'⛈️'},96:{desc:'Thunderstorm w/ hail',icon:'⛈️'},
|
||
99:{desc:'Severe thunderstorm',icon:'⛈️'},
|
||
};
|
||
|
||
function wmo(code) { return WMO[code] || { desc: 'Unknown', icon: '🌡️' }; }
|
||
|
||
function wmoGroup(code) {
|
||
if ([0,1].includes(code)) return 'clear';
|
||
if ([2,3,45,48].includes(code)) return 'cloud';
|
||
if ([51,53,55,56,57,61,63,65,66,67,80,81,82].includes(code)) return 'rain';
|
||
if ([71,73,75,77,85,86].includes(code)) return 'snow';
|
||
if ([95,96,99].includes(code)) return 'storm';
|
||
return '';
|
||
}
|
||
|
||
function applyTheme(t) {
|
||
state.theme = t || 'system';
|
||
if (t === 'dark') document.documentElement.dataset.theme = 'dark';
|
||
else if (t === 'light') document.documentElement.dataset.theme = 'light';
|
||
else delete document.documentElement.dataset.theme;
|
||
localStorage.setItem(STORAGE_KEYS.theme, state.theme);
|
||
document.querySelectorAll('.theme-btn').forEach(btn => {
|
||
btn.classList.toggle('active', btn.dataset.theme === state.theme);
|
||
});
|
||
}
|
||
|
||
function updateAlertBadge(count) {
|
||
['alert-badge-nav', 'alert-badge-bottom'].forEach(id => {
|
||
const el = document.getElementById(id);
|
||
if (!el) return;
|
||
if (count > 0) { el.textContent = count > 9 ? '9+' : count; el.classList.remove('hidden'); }
|
||
else { el.classList.add('hidden'); }
|
||
});
|
||
}
|
||
|
||
function parseNhcCoord(str) {
|
||
if (typeof str === 'number') return str;
|
||
if (!str) return NaN;
|
||
const m = String(str).match(/^([\d.]+)([NSEW])$/i);
|
||
if (!m) return NaN;
|
||
const v = parseFloat(m[1]);
|
||
return (m[2].toUpperCase() === 'S' || m[2].toUpperCase() === 'W') ? -v : v;
|
||
}
|
||
|
||
function renderHurricanePanel(storms) {
|
||
const panel = document.getElementById('hurricane-panel');
|
||
if (!panel) return;
|
||
if (!storms || !storms.length) { panel.innerHTML = ''; return; }
|
||
const catLabel = c => {
|
||
if (!c) return '';
|
||
const up = c.toUpperCase();
|
||
if (up === 'HU') return 'cat-1';
|
||
if (up === 'TS') return 'cat-ts';
|
||
if (up === 'TD') return 'cat-td';
|
||
const m = c.match(/(\d)/);
|
||
if (m) return `cat-${m[1]}`;
|
||
return 'cat-td';
|
||
};
|
||
const catName = c => {
|
||
if (!c) return 'Tropical';
|
||
const up = c.toUpperCase();
|
||
if (up === 'HU') return 'Hurricane';
|
||
if (up === 'TS') return 'Tropical Storm';
|
||
if (up === 'TD') return 'Tropical Depression';
|
||
return c;
|
||
};
|
||
panel.innerHTML = storms.map(s => {
|
||
const lat = parseNhcCoord(s.latitude);
|
||
const lon = parseNhcCoord(s.longitude);
|
||
return `
|
||
<div class="hurricane-storm-card">
|
||
<div class="hurricane-header">
|
||
<span class="hurricane-name">${s.name || 'Storm'}</span>
|
||
<span class="hurricane-cat-badge ${catLabel(s.classification)}">${catName(s.classification)}</span>
|
||
</div>
|
||
<div class="hurricane-stats">
|
||
<span>Winds: ${s.intensity || '--'} mph</span>
|
||
${s.pressure ? `<span>Pressure: ${s.pressure} mb</span>` : ''}
|
||
<span>${isNaN(lat) ? s.latitude : lat.toFixed(1)+'°'}, ${isNaN(lon) ? s.longitude : Math.abs(lon).toFixed(1)+'°'+(lon < 0 ? 'W' : 'E')}</span>
|
||
${s.movement ? `<span>Moving ${s.movement}</span>` : ''}
|
||
</div>
|
||
</div>
|
||
`}).join('');
|
||
}
|
||
|
||
function renderPrecipChart(h, startIdx) {
|
||
const wrap = document.getElementById('precip-chart-wrap');
|
||
if (!wrap || !h?.precipitation_probability) return;
|
||
const hrs = Math.min(state.hourlyExtended ? 48 : 24, h.time.length - startIdx);
|
||
const vals = h.precipitation_probability.slice(startIdx, startIdx + hrs);
|
||
const maxV = Math.max(...vals, 10);
|
||
const W = Math.max(640, hrs * 20);
|
||
const H = 52;
|
||
const barW = Math.floor(W / hrs) - 1;
|
||
const bars = vals.map((v, i) => {
|
||
const bh = Math.round((v / maxV) * 36);
|
||
const x = i * (barW + 1);
|
||
const color = v >= 70 ? '#2c7bb6' : v >= 40 ? '#74add1' : '#abd9e9';
|
||
return `<rect x="${x}" y="${36 - bh}" width="${barW}" height="${bh}" fill="${color}" rx="2"/>
|
||
${v > 0 ? `<text x="${x + barW / 2}" y="50" text-anchor="middle" font-size="8" fill="currentColor">${v}%</text>` : ''}`;
|
||
}).join('');
|
||
wrap.innerHTML = `<svg width="${W}" height="${H}" style="color:var(--ink-light)">${bars}</svg>`;
|
||
}
|
||
|
||
function urlBase64ToUint8Array(b64) {
|
||
const pad = '='.repeat((4 - b64.length % 4) % 4);
|
||
const raw = atob((b64 + pad).replace(/-/g, '+').replace(/_/g, '/'));
|
||
return Uint8Array.from([...raw].map(c => c.charCodeAt(0)));
|
||
}
|
||
|
||
async function subscribePush() {
|
||
const btn = document.getElementById('push-subscribe-btn');
|
||
const statusEl = document.getElementById('push-status');
|
||
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
|
||
if (statusEl) { statusEl.textContent = 'Push not supported in this browser.'; show(statusEl); }
|
||
return;
|
||
}
|
||
try {
|
||
if (btn) { btn.disabled = true; btn.textContent = 'Enabling…'; }
|
||
const keyRes = await fetch('/api/vapid-public-key');
|
||
const { publicKey } = await keyRes.json();
|
||
const reg = await navigator.serviceWorker.ready;
|
||
const perm = await Notification.requestPermission();
|
||
if (perm !== 'granted') throw new Error('Permission denied');
|
||
const sub = await reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: urlBase64ToUint8Array(publicKey) });
|
||
const city = state.currentCity;
|
||
await fetch('/api/push-subscribe', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
subscription: sub.toJSON(),
|
||
lat: city?.latitude, lon: city?.longitude,
|
||
ntfyTopic: state.ntfyTopic,
|
||
thresholds: state.alertThresholds,
|
||
digestHour: state.digestHour,
|
||
}),
|
||
});
|
||
state.pushSub = sub;
|
||
if (btn) { btn.textContent = 'Disable'; btn.disabled = false; }
|
||
if (statusEl) { statusEl.textContent = '✓ Push alerts enabled!'; show(statusEl); }
|
||
} catch (e) {
|
||
if (btn) { btn.textContent = 'Enable'; btn.disabled = false; }
|
||
if (statusEl) { statusEl.textContent = `✗ ${e.message}`; show(statusEl); }
|
||
}
|
||
}
|
||
|
||
async function renderHurricaneOverlay(map) {
|
||
if (!map || !map.loaded()) return;
|
||
['hurricane-overlay'].forEach(id => {
|
||
if (map.getLayer(id)) map.removeLayer(id);
|
||
if (map.getSource(id)) map.removeSource(id);
|
||
});
|
||
if (!state.unifiedMapLayers.hurricane) return;
|
||
try {
|
||
const data = await fetch('/api/hurricanes').then(r => r.json());
|
||
const storms = data.storms || [];
|
||
if (!storms.length) return;
|
||
const features = storms.map(s => {
|
||
const lat = parseNhcCoord(s.latitude);
|
||
const lon = parseNhcCoord(s.longitude);
|
||
if (isNaN(lat) || isNaN(lon)) return null;
|
||
const cat = (s.classification || '').toUpperCase();
|
||
return {
|
||
type: 'Feature',
|
||
geometry: { type: 'Point', coordinates: [lon, lat] },
|
||
properties: { name: s.name || 'Storm', cat, intensity: s.intensity || '--' },
|
||
};
|
||
}).filter(Boolean);
|
||
map.addSource('hurricane-overlay', { type: 'geojson', data: { type: 'FeatureCollection', features } });
|
||
map.addLayer({
|
||
id: 'hurricane-overlay', type: 'circle', source: 'hurricane-overlay',
|
||
paint: {
|
||
'circle-radius': 14,
|
||
'circle-color': ['match', ['get', 'cat'], 'HU', '#f44336', 'TS', '#9c27b0', '#2196f3'],
|
||
'circle-opacity': 0.55,
|
||
'circle-stroke-width': 2.5,
|
||
'circle-stroke-color': ['match', ['get', 'cat'], 'HU', '#f44336', 'TS', '#9c27b0', '#2196f3'],
|
||
},
|
||
});
|
||
map.on('click', 'hurricane-overlay', e => {
|
||
const p = e.features[0].properties;
|
||
const label = p.cat === 'HU' ? 'Hurricane' : p.cat === 'TS' ? 'Tropical Storm' : 'Tropical Depression';
|
||
new maplibregl.Popup().setLngLat(e.lngLat)
|
||
.setHTML(`<b>${p.name}</b><br>${label}<br>Winds: ${p.intensity} mph`).addTo(map);
|
||
});
|
||
map.on('mouseenter', 'hurricane-overlay', () => { map.getCanvas().style.cursor = 'pointer'; });
|
||
map.on('mouseleave', 'hurricane-overlay', () => { map.getCanvas().style.cursor = ''; });
|
||
} catch {}
|
||
}
|
||
|
||
async function renderWildfireOverlay(map) {
|
||
if (!map || !map.loaded()) return;
|
||
['wildfire-fill', 'wildfire-outline'].forEach(id => {
|
||
if (map.getLayer(id)) map.removeLayer(id);
|
||
});
|
||
if (map.getSource('wildfire-overlay')) map.removeSource('wildfire-overlay');
|
||
if (!state.unifiedMapLayers.wildfire) return;
|
||
try {
|
||
const data = await fetch('/api/wildfires').then(r => r.json());
|
||
if (data?.type !== 'FeatureCollection' || !Array.isArray(data.features)) return;
|
||
map.addSource('wildfire-overlay', { type: 'geojson', data });
|
||
map.addLayer({ id: 'wildfire-fill', type: 'fill', source: 'wildfire-overlay',
|
||
paint: { 'fill-color': '#ff6d00', 'fill-opacity': 0.25 } });
|
||
map.addLayer({ id: 'wildfire-outline', type: 'line', source: 'wildfire-overlay',
|
||
paint: { 'line-color': '#ff6d00', 'line-width': 1.5 } });
|
||
map.on('click', 'wildfire-fill', e => {
|
||
const p = e.features[0].properties || {};
|
||
new maplibregl.Popup().setLngLat(e.lngLat)
|
||
.setHTML(`<b>${p.IncidentName || 'Wildfire'}</b><br>${Math.round(p.GISAcres || 0).toLocaleString()} acres<br>${p.PercentContained ?? '--'}% contained`).addTo(map);
|
||
});
|
||
map.on('mouseenter', 'wildfire-fill', () => { map.getCanvas().style.cursor = 'pointer'; });
|
||
map.on('mouseleave', 'wildfire-fill', () => { map.getCanvas().style.cursor = ''; });
|
||
} catch {}
|
||
}
|
||
|
||
async function renderSPCOverlay(map) {
|
||
if (!map || !map.loaded()) return;
|
||
['spc-overlay'].forEach(id => {
|
||
if (map.getLayer(id)) map.removeLayer(id);
|
||
if (map.getSource(id)) map.removeSource(id);
|
||
});
|
||
if (!state.unifiedMapLayers.spc) return;
|
||
try {
|
||
const data = await fetch('/api/spc/reports').then(r => r.json());
|
||
map.addSource('spc-overlay', { type: 'geojson', data });
|
||
map.addLayer({
|
||
id: 'spc-overlay', type: 'circle', source: 'spc-overlay',
|
||
paint: {
|
||
'circle-radius': 6,
|
||
'circle-color': ['match', ['get', 'type'], 'torn', '#e53935', 'wind', '#fb8c00', '#43a047'],
|
||
'circle-opacity': 0.7,
|
||
'circle-stroke-width': 1.5,
|
||
'circle-stroke-color': ['match', ['get', 'type'], 'torn', '#e53935', 'wind', '#fb8c00', '#43a047'],
|
||
},
|
||
});
|
||
map.on('click', 'spc-overlay', e => {
|
||
const p = e.features[0].properties;
|
||
const label = p.type === 'torn' ? 'Tornado' : p.type === 'wind' ? 'Wind damage' : 'Hail';
|
||
new maplibregl.Popup().setLngLat(e.lngLat)
|
||
.setHTML(`<b>${label}</b><br>${p.location}<br>${p.comments}`).addTo(map);
|
||
});
|
||
map.on('mouseenter', 'spc-overlay', () => { map.getCanvas().style.cursor = 'pointer'; });
|
||
map.on('mouseleave', 'spc-overlay', () => { map.getCanvas().style.cursor = ''; });
|
||
} catch {}
|
||
}
|
||
|
||
async function refreshSavedCityWeather() {
|
||
for (const city of state.savedCities) {
|
||
const key = cityKey(city);
|
||
try {
|
||
const res = await fetch(`/api/weather?lat=${city.latitude}&lon=${city.longitude}`);
|
||
if (res.ok) state.savedCityWeather[key] = await res.json();
|
||
} catch {}
|
||
}
|
||
renderSavedCities();
|
||
}
|
||
|
||
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',
|
||
ntfyTopic: 'weather.ntfyTopic',
|
||
showLightning: 'weather.showLightning',
|
||
showWind: 'weather.showWind',
|
||
showRadarLayer: 'weather.map.radar',
|
||
showSatelliteLayer: 'weather.map.satellite',
|
||
showLightningLayer: 'weather.map.lightning',
|
||
showWindLayer: 'weather.map.wind',
|
||
showAqiLayer: 'weather.map.aqi',
|
||
showAlertsLayer: 'weather.map.alerts',
|
||
showTempMarker: 'weather.map.temp',
|
||
showSunMoonMarkers: 'weather.map.sunMoon',
|
||
showHurricaneLayer: 'weather.map.hurricane',
|
||
showWildfireLayer: 'weather.map.wildfire',
|
||
showSpcLayer: 'weather.map.spc',
|
||
unifiedMapBaseLayer: 'weather.map.baseLayer',
|
||
unifiedMapRadarMode: 'weather.map.radarMode',
|
||
theme: 'weather.theme',
|
||
alertThresholds: 'weather.alertThresholds',
|
||
digestHour: 'weather.digestHour',
|
||
pwsUrl: 'weather.pwsUrl',
|
||
ambientApiKey: 'weather.ambientApiKey',
|
||
ambientAppKey: 'weather.ambientAppKey',
|
||
ambientUseStation: 'weather.ambientUseStation',
|
||
alertIdsSeen: 'weather.alertIdsSeen',
|
||
};
|
||
|
||
const state = {
|
||
units: localStorage.getItem(STORAGE_KEYS.units) || 'us',
|
||
autoDetect: localStorage.getItem(STORAGE_KEYS.autoDetect) !== 'false',
|
||
ntfyTopic: localStorage.getItem(STORAGE_KEYS.ntfyTopic) || '',
|
||
showLightning: localStorage.getItem(STORAGE_KEYS.showLightning) !== 'false',
|
||
showWind: localStorage.getItem(STORAGE_KEYS.showWind) !== 'false',
|
||
currentCity: null,
|
||
currentWeather: null,
|
||
currentAir: null,
|
||
currentAlerts: null,
|
||
currentSource: 'startup',
|
||
savedCities: JSON.parse(localStorage.getItem(STORAGE_KEYS.savedCities) || '[]'),
|
||
alertIdsSeen: new Set(JSON.parse(localStorage.getItem('weather.alertIdsSeen') || '[]')),
|
||
autoRefreshTimer: null,
|
||
lastRefreshAt: 0,
|
||
lightningMarkers: [],
|
||
windArrows: [],
|
||
aqiMarkers: [],
|
||
alertMarkers: [],
|
||
tempMarker: null,
|
||
sunMoonMarkers: [],
|
||
showExtendedForecast: false,
|
||
unifiedMapLayers: {
|
||
radar: localStorage.getItem(STORAGE_KEYS.showRadarLayer) !== 'false',
|
||
satellite: localStorage.getItem(STORAGE_KEYS.showSatelliteLayer) !== 'false',
|
||
lightning: localStorage.getItem(STORAGE_KEYS.showLightningLayer) !== 'false',
|
||
wind: localStorage.getItem(STORAGE_KEYS.showWindLayer) !== 'false',
|
||
aqi: localStorage.getItem(STORAGE_KEYS.showAqiLayer) !== 'false',
|
||
alerts: localStorage.getItem(STORAGE_KEYS.showAlertsLayer) !== 'false',
|
||
temp: localStorage.getItem(STORAGE_KEYS.showTempMarker) !== 'false',
|
||
sunMoon: localStorage.getItem(STORAGE_KEYS.showSunMoonMarkers) !== 'false',
|
||
hurricane: localStorage.getItem(STORAGE_KEYS.showHurricaneLayer) === 'true',
|
||
wildfire: localStorage.getItem(STORAGE_KEYS.showWildfireLayer) === 'true',
|
||
spc: localStorage.getItem(STORAGE_KEYS.showSpcLayer) === 'true',
|
||
},
|
||
unifiedMapBaseLayer: localStorage.getItem(STORAGE_KEYS.unifiedMapBaseLayer) || 'dark',
|
||
unifiedMapRadarMode: localStorage.getItem(STORAGE_KEYS.unifiedMapRadarMode) || 'radar',
|
||
theme: localStorage.getItem(STORAGE_KEYS.theme) || 'system',
|
||
alertThresholds: JSON.parse(localStorage.getItem(STORAGE_KEYS.alertThresholds) || '{}'),
|
||
digestHour: Number(localStorage.getItem(STORAGE_KEYS.digestHour) ?? 7),
|
||
pwsUrl: localStorage.getItem(STORAGE_KEYS.pwsUrl) || '',
|
||
ambientApiKey: localStorage.getItem(STORAGE_KEYS.ambientApiKey) || '',
|
||
ambientAppKey: localStorage.getItem(STORAGE_KEYS.ambientAppKey) || '',
|
||
ambientUseStation: localStorage.getItem(STORAGE_KEYS.ambientUseStation) !== 'false',
|
||
ambientDevice: null,
|
||
pushSub: null,
|
||
hourlyExtended: false,
|
||
hourlyFeelsLike: false,
|
||
savedCityWeather: {},
|
||
};
|
||
|
||
// ── DOM ──────────────────────────────────────────────────────────────────────
|
||
const searchInput = document.getElementById('search-input');
|
||
const searchBtn = document.getElementById('search-btn');
|
||
const locateBtn = document.getElementById('locate-btn');
|
||
const suggestEl = document.getElementById('suggestions');
|
||
const errorMsg = document.getElementById('error-msg');
|
||
const loadingEl = document.getElementById('loading');
|
||
const weatherDiv = document.getElementById('weather');
|
||
const unitsBtn = document.getElementById('units-btn');
|
||
const unitsBtnSettings = document.getElementById('units-btn-settings');
|
||
const shareBtn = document.getElementById('share-btn');
|
||
const shareHeroBtn = document.getElementById('share-hero-btn');
|
||
const shareWeatherCardBtn = document.getElementById('share-weather-card-btn');
|
||
const saveCurrentBtn = document.getElementById('save-current-btn');
|
||
const savedLocationsEl = document.getElementById('saved-locations');
|
||
const notifyBtn = document.getElementById('notify-btn');
|
||
const shareHeroStatusEl = document.getElementById('share-hero-status');
|
||
const shareWeatherStatusEl = document.getElementById('share-weather-status');
|
||
const autoDetectToggle = document.getElementById('auto-detect-toggle');
|
||
const alertsEmptyEl = document.getElementById('alerts-empty');
|
||
const settingsLocationEl = document.getElementById('settings-location');
|
||
const mapsLocationEl = document.getElementById('maps-location');
|
||
const mapsWindEl = document.getElementById('maps-wind');
|
||
const lightningToggle = document.getElementById('lightning-toggle');
|
||
const windToggle = document.getElementById('wind-toggle');
|
||
const dailyToggleBtn = document.getElementById('daily-toggle-btn');
|
||
const dailyForecastLabel = document.getElementById('daily-forecast-label');
|
||
|
||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||
const show = el => el.classList.remove('hidden');
|
||
const hide = el => el.classList.add('hidden');
|
||
const showErr = msg => { errorMsg.textContent = msg; show(errorMsg); };
|
||
const clearErr = () => { errorMsg.textContent = ''; hide(errorMsg); };
|
||
|
||
function compassDir(deg) {
|
||
return ['N','NE','E','SE','S','SW','W','NW'][Math.round(deg / 45) % 8];
|
||
}
|
||
|
||
function compassDirFull(deg) {
|
||
return ['North','Northeast','East','Southeast','South','Southwest','West','Northwest'][Math.round(deg / 45) % 8];
|
||
}
|
||
|
||
function formatHour(iso) {
|
||
const h = new Date(iso).getHours();
|
||
return h === 0 ? '12 AM' : h < 12 ? `${h} AM` : h === 12 ? '12 PM' : `${h-12} PM`;
|
||
}
|
||
|
||
function formatDayShort(iso) {
|
||
const d = new Date(iso + 'T00:00:00');
|
||
const today = new Date();
|
||
const tomorrow = new Date(today);
|
||
tomorrow.setDate(today.getDate() + 1);
|
||
if (d.toDateString() === today.toDateString()) return 'Today';
|
||
if (d.toDateString() === tomorrow.toDateString()) return 'Tomorrow';
|
||
return DAYS[d.getDay()];
|
||
}
|
||
|
||
function formatClock(iso) {
|
||
return new Date(iso).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||
}
|
||
|
||
function tempDisplay(v) {
|
||
if (v == null || Number.isNaN(v)) return '--';
|
||
const n = state.units === 'us' ? v : ((v - 32) * 5) / 9;
|
||
return Math.round(n);
|
||
}
|
||
|
||
function speedDisplay(v) {
|
||
if (v == null || Number.isNaN(v)) return '--';
|
||
const n = state.units === 'us' ? v : v * 1.60934;
|
||
return Math.round(n);
|
||
}
|
||
|
||
function precipDisplay(v) {
|
||
if (v == null || Number.isNaN(v)) return '--';
|
||
const n = state.units === 'us' ? v : v * 25.4;
|
||
return n.toFixed(1);
|
||
}
|
||
|
||
function visDisplay(vMeters) {
|
||
if (vMeters == null || Number.isNaN(vMeters)) return '--';
|
||
const miles = vMeters / 1609;
|
||
if (state.units === 'us') return miles.toFixed(1);
|
||
return (miles * 1.60934).toFixed(1);
|
||
}
|
||
|
||
function tempUnit() { return state.units === 'us' ? 'F' : 'C'; }
|
||
function speedUnit() { return state.units === 'us' ? 'mph' : 'km/h'; }
|
||
function visUnit() { return state.units === 'us' ? 'mi' : 'km'; }
|
||
|
||
function updateUnitsButton() {
|
||
const label = state.units === 'us' ? 'US' : 'Metric';
|
||
unitsBtn.textContent = label;
|
||
if (unitsBtnSettings) unitsBtnSettings.textContent = label;
|
||
}
|
||
|
||
function syncAutoDetectToggle() {
|
||
autoDetectToggle.checked = state.autoDetect;
|
||
}
|
||
|
||
function updateNotifyButton() {
|
||
if (!('Notification' in window)) {
|
||
notifyBtn.textContent = 'Notifications unsupported';
|
||
notifyBtn.disabled = true;
|
||
return;
|
||
}
|
||
if (!window.isSecureContext) {
|
||
notifyBtn.textContent = 'Needs HTTPS';
|
||
notifyBtn.disabled = true;
|
||
return;
|
||
}
|
||
|
||
notifyBtn.disabled = false;
|
||
if (Notification.permission === 'granted') {
|
||
notifyBtn.textContent = 'Notifications enabled';
|
||
} else if (Notification.permission === 'denied') {
|
||
notifyBtn.textContent = 'Notifications blocked';
|
||
} else {
|
||
notifyBtn.textContent = 'Enable notifications';
|
||
}
|
||
}
|
||
|
||
function moonPhase(dateLike) {
|
||
const date = new Date(dateLike);
|
||
const synodic = 29.53058867;
|
||
const knownNewMoon = new Date('2000-01-06T18:14:00Z');
|
||
const days = (date - knownNewMoon) / 86400000;
|
||
const phase = ((days % synodic) + synodic) % synodic;
|
||
const index = Math.floor((phase / synodic) * 8) % 8;
|
||
return ['New', 'Waxing Crescent', 'First Quarter', 'Waxing Gibbous', 'Full', 'Waning Gibbous', 'Last Quarter', 'Waning Crescent'][index];
|
||
}
|
||
|
||
function precipType(code, tempF, rain, snowfall) {
|
||
if ((snowfall ?? 0) > 0) return 'Snow';
|
||
if ([56,57,66,67].includes(code) || ((rain ?? 0) > 0 && tempF <= 32)) return 'Sleet';
|
||
if ((rain ?? 0) > 0 || [51,53,55,61,63,65,80,81,82].includes(code)) return 'Rain';
|
||
return 'Dry';
|
||
}
|
||
|
||
function calcDewpoint(tempF, rh) {
|
||
const tc = (tempF - 32) * 5 / 9;
|
||
const alpha = Math.log(rh / 100) + (17.62 * tc) / (243.12 + tc);
|
||
const dewC = (243.12 * alpha) / (17.62 - alpha);
|
||
return state.units === 'us' ? Math.round(dewC * 9 / 5 + 32) : Math.round(dewC);
|
||
}
|
||
|
||
function humidityFeel(rh) {
|
||
if (rh < 25) return 'Very dry';
|
||
if (rh < 35) return 'Dry';
|
||
if (rh < 55) return 'Comfortable';
|
||
if (rh < 65) return 'Moderate';
|
||
if (rh < 75) return 'Humid';
|
||
return 'Very humid';
|
||
}
|
||
|
||
function renderRecommendations(data) {
|
||
const c = data.current;
|
||
const d = data.daily;
|
||
const temp = c.temperature_2m;
|
||
const code = c.weather_code;
|
||
const uv = d.uv_index_max[0] ?? 0;
|
||
const wind = c.wind_speed_10m;
|
||
const items = [];
|
||
|
||
if (temp <= 32) items.push({ icon: '🧥', label: 'Heavy coat' });
|
||
else if (temp <= 50) items.push({ icon: '🧣', label: 'Jacket' });
|
||
else if (temp >= 85) items.push({ icon: '👕', label: 'Light clothes' });
|
||
|
||
if ([51,53,55,61,63,65,66,67,80,81,82].includes(code)) {
|
||
items.push({ icon: '☂️', label: 'Umbrella' });
|
||
items.push({ icon: '🥾', label: 'Waterproof\nshoes' });
|
||
}
|
||
if ([71,73,75,77,85,86].includes(code)) {
|
||
items.push({ icon: '🥾', label: 'Snow boots' });
|
||
items.push({ icon: '🧤', label: 'Gloves' });
|
||
}
|
||
if (code >= 95) items.push({ icon: '⚡', label: 'Stay\nindoors' });
|
||
if (c.is_day && uv >= 6) {
|
||
items.push({ icon: '🕶️', label: 'Sunglasses' });
|
||
items.push({ icon: '🧴', label: 'Sunscreen' });
|
||
}
|
||
if (wind >= 20) items.push({ icon: '💨', label: 'Windbreaker' });
|
||
items.push({ icon: '💧', label: 'Water bottle' });
|
||
|
||
const grid = document.getElementById('recommendations');
|
||
if (!grid) return;
|
||
grid.innerHTML = items.map(it =>
|
||
`<div class="rec-item"><span class="rec-icon">${it.icon}</span><span class="rec-label">${it.label}</span></div>`
|
||
).join('');
|
||
}
|
||
|
||
function apparentLabel(tempF, apparentF) {
|
||
if (tempF <= 50 && apparentF <= tempF - 2) return 'Wind chill';
|
||
if (tempF >= 80 && apparentF >= tempF + 2) return 'Heat index';
|
||
return 'Feels like';
|
||
}
|
||
|
||
function cityKey(city) {
|
||
return `${Number(city.latitude).toFixed(3)},${Number(city.longitude).toFixed(3)}`;
|
||
}
|
||
|
||
function persistSavedCities() {
|
||
localStorage.setItem(STORAGE_KEYS.savedCities, JSON.stringify(state.savedCities));
|
||
}
|
||
|
||
function persistLastCity(city) {
|
||
localStorage.setItem(STORAGE_KEYS.lastCity, JSON.stringify(city));
|
||
}
|
||
|
||
function getLastCity() {
|
||
try { return JSON.parse(localStorage.getItem(STORAGE_KEYS.lastCity) || 'null'); }
|
||
catch { return null; }
|
||
}
|
||
|
||
function cityLabel(city) {
|
||
return [city?.name, city?.admin1, city?.country].filter(Boolean).join(', ');
|
||
}
|
||
|
||
function updateShareUrl(city) {
|
||
if (!city) return;
|
||
const params = new URLSearchParams(window.location.search);
|
||
params.set('lat', city.latitude);
|
||
params.set('lon', city.longitude);
|
||
params.set('name', city.name || 'Location');
|
||
history.replaceState(null, '', `${window.location.pathname}?${params.toString()}`);
|
||
}
|
||
|
||
function updateSourceLocation(city) {
|
||
const label = cityLabel(city) || (city ? `${Number(city.latitude).toFixed(2)}, ${Number(city.longitude).toFixed(2)}` : '--');
|
||
if (settingsLocationEl) settingsLocationEl.textContent = label;
|
||
if (mapsLocationEl) mapsLocationEl.textContent = label;
|
||
}
|
||
|
||
function renderSavedCities() {
|
||
savedLocationsEl.innerHTML = '';
|
||
if (!state.savedCities.length) {
|
||
savedLocationsEl.textContent = 'None yet';
|
||
return;
|
||
}
|
||
|
||
const container = document.createElement('div');
|
||
container.className = 'saved-cards';
|
||
|
||
state.savedCities.forEach(city => {
|
||
const key = cityKey(city);
|
||
const wx = state.savedCityWeather[key];
|
||
const card = document.createElement('div');
|
||
card.className = 'saved-mini-card';
|
||
card.title = city.name;
|
||
|
||
const nameEl = document.createElement('div');
|
||
nameEl.className = 'mini-city-name';
|
||
nameEl.textContent = city.name;
|
||
|
||
const wxRow = document.createElement('div');
|
||
wxRow.className = 'mini-weather-row';
|
||
if (wx?.current) {
|
||
const w = wmo(wx.current.weather_code);
|
||
wxRow.innerHTML = `<span>${w.icon}</span><span>${tempDisplay(wx.current.temperature_2m)}°${tempUnit()}</span>`;
|
||
} else {
|
||
wxRow.textContent = '—';
|
||
}
|
||
|
||
const remove = document.createElement('button');
|
||
remove.className = 'mini-remove';
|
||
remove.type = 'button';
|
||
remove.textContent = '×';
|
||
remove.addEventListener('click', e => {
|
||
e.stopPropagation();
|
||
state.savedCities = state.savedCities.filter(c => cityKey(c) !== key);
|
||
persistSavedCities();
|
||
renderSavedCities();
|
||
});
|
||
|
||
card.addEventListener('click', () => loadAll(city, 'saved-location'));
|
||
card.appendChild(nameEl);
|
||
card.appendChild(wxRow);
|
||
card.appendChild(remove);
|
||
container.appendChild(card);
|
||
});
|
||
|
||
savedLocationsEl.appendChild(container);
|
||
}
|
||
|
||
// ── Tab switching ─────────────────────────────────────────────────────────────
|
||
|
||
function activateTab(tabName) {
|
||
document.querySelectorAll('.tab, .bottom-tab').forEach(t => t.classList.remove('active'));
|
||
document.querySelectorAll('.tab-panel').forEach(p => hide(p));
|
||
document.querySelectorAll(`[data-tab="${tabName}"]`).forEach(t => t.classList.add('active'));
|
||
show(document.getElementById('tab-' + tabName));
|
||
|
||
if (tabName === 'conditions') {
|
||
setTimeout(() => { radarEmbed && radarEmbed.refresh && radarEmbed.refresh(); }, 60);
|
||
}
|
||
if (tabName === 'unified-map') {
|
||
setTimeout(() => {
|
||
if (unifiedMap && unifiedMap.refresh) unifiedMap.refresh();
|
||
refreshMapsOverview();
|
||
}, 60);
|
||
setTimeout(() => {
|
||
if (unifiedMap && unifiedMap.refresh) unifiedMap.refresh();
|
||
refreshMapsOverview();
|
||
}, 340);
|
||
}
|
||
if (tabName === 'settings') {
|
||
refreshSavedCityWeather();
|
||
}
|
||
}
|
||
document.querySelectorAll('.tab, .bottom-tab').forEach(btn => {
|
||
btn.addEventListener('click', () => activateTab(btn.dataset.tab));
|
||
});
|
||
|
||
|
||
const openMapTabBtn = document.getElementById('open-map-tab');
|
||
if (openMapTabBtn) {
|
||
openMapTabBtn.addEventListener('click', () => {
|
||
activateTab('unified-map');
|
||
});
|
||
}
|
||
// ── APIs ─────────────────────────────────────────────────────────────────────
|
||
async function geocode(q) {
|
||
const r = await fetch(`/api/geocode?q=${encodeURIComponent(q)}`);
|
||
if (!r.ok) {
|
||
const detail = await r.text().catch(() => '');
|
||
throw new Error(`Geocoding failed (${r.status})${detail ? `: ${detail.slice(0, 180)}` : ''}`);
|
||
}
|
||
return r.json();
|
||
}
|
||
|
||
async function reverseGeocode(lat, lon) {
|
||
const r = await fetch(`/api/reverse-geocode?lat=${encodeURIComponent(lat)}&lon=${encodeURIComponent(lon)}`);
|
||
if (!r.ok) throw new Error('Reverse geocoding failed');
|
||
return r.json();
|
||
}
|
||
|
||
async function fetchJsonWithTimeout(url, timeoutMs) {
|
||
const controller = new AbortController();
|
||
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
||
const response = await fetch(url, { cache: 'no-store', signal: controller.signal });
|
||
clearTimeout(timeoutId);
|
||
|
||
if (!response.ok) {
|
||
const detail = await response.text().catch(() => '');
|
||
throw new Error(`Request failed (${response.status})${detail ? `: ${detail.slice(0, 180)}` : ''}`);
|
||
}
|
||
return response.json();
|
||
}
|
||
|
||
async function fetchWeather(lat, lon, tz) {
|
||
const proxyUrl = `/api/weather?lat=${lat}&lon=${lon}&tz=${encodeURIComponent(tz||'auto')}&units=${encodeURIComponent(state.units)}`;
|
||
|
||
for (let attempt = 1; attempt <= 3; attempt++) {
|
||
try {
|
||
return await fetchJsonWithTimeout(proxyUrl, 22000);
|
||
} catch (err) {
|
||
const reason = err?.name === 'AbortError' ? 'timeout' : (err?.message || err);
|
||
if (attempt >= 3) {
|
||
throw new Error(`Weather proxy failed after ${attempt} attempts: ${reason}`);
|
||
}
|
||
await new Promise(resolve => setTimeout(resolve, 500 * attempt));
|
||
}
|
||
}
|
||
}
|
||
|
||
async function fetchAirQuality(lat, lon, tz) {
|
||
const url = `/api/air-quality?lat=${lat}&lon=${lon}&tz=${encodeURIComponent(tz||'auto')}`;
|
||
const r = await fetch(url);
|
||
if (!r.ok) throw new Error(`Air quality fetch failed with status ${r.status}`);
|
||
return r.json();
|
||
}
|
||
|
||
async function fetchAlerts(lat, lon) {
|
||
const r = await fetch(`/api/alerts?lat=${lat}&lon=${lon}`);
|
||
if (!r.ok) throw new Error('Alerts fetch failed');
|
||
return r.json();
|
||
}
|
||
|
||
function detectedCityFallback(lat, lon) {
|
||
return {
|
||
name: `Detected location (${lat.toFixed(2)}, ${lon.toFixed(2)})`,
|
||
latitude: lat,
|
||
longitude: lon,
|
||
country: '',
|
||
admin1: '',
|
||
timezone: 'auto',
|
||
};
|
||
}
|
||
|
||
async function buildDetectedCity(lat, lon) {
|
||
const fallback = detectedCityFallback(lat, lon);
|
||
|
||
try {
|
||
const place = await reverseGeocode(lat, lon);
|
||
return {
|
||
...fallback,
|
||
...place,
|
||
latitude: lat,
|
||
longitude: lon,
|
||
timezone: place.timezone || 'auto',
|
||
};
|
||
} catch {
|
||
return fallback;
|
||
}
|
||
}
|
||
|
||
// ── Radar engine ──────────────────────────────────────────────────────────────
|
||
function createRadar(mapEl, timelineEl, playBtn, tsEl, layerBtn) {
|
||
const FRAME_INTERVAL_MS = 650;
|
||
const FRAME_FADE_MS = 240;
|
||
|
||
const baseMapType = mapEl?.id === 'unified-map' ? state.unifiedMapBaseLayer : 'dark';
|
||
|
||
function buildStyle(type) {
|
||
const tileUrl = type === 'light'
|
||
? 'https://a.basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png'
|
||
: type === 'satellite'
|
||
? 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}'
|
||
: 'https://a.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png';
|
||
return {
|
||
version: 8,
|
||
sources: {
|
||
base: { type: 'raster', tiles: [tileUrl], tileSize: 256,
|
||
attribution: '© OpenStreetMap contributors, © CARTO' }
|
||
},
|
||
layers: [{ id: 'base', type: 'raster', source: 'base' }],
|
||
};
|
||
}
|
||
|
||
const map = new maplibregl.Map({
|
||
container: mapEl,
|
||
style: buildStyle(baseMapType),
|
||
center: [-98.35, 39.5],
|
||
zoom: 4,
|
||
attributionControl: false,
|
||
});
|
||
|
||
let radarFrames = [];
|
||
let satelliteFrames = [];
|
||
let mode = 'radar';
|
||
let currentIdx = 0;
|
||
let layers = { radar: {}, satellite: {} }; // frame.time -> layerId string
|
||
let playing = false;
|
||
let animTimer = null;
|
||
let locationMarker = null;
|
||
let visibleLayerId = null;
|
||
let framesReady = false;
|
||
let loadingFrames = false;
|
||
let mapReady = false;
|
||
const pendingOps = [];
|
||
|
||
map.once('load', () => {
|
||
mapReady = true;
|
||
pendingOps.forEach(fn => fn());
|
||
pendingOps.length = 0;
|
||
});
|
||
|
||
function whenReady(fn) {
|
||
if (mapReady) fn();
|
||
else pendingOps.push(fn);
|
||
}
|
||
|
||
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, forMode) {
|
||
if (frame?.url) return frame.url;
|
||
const m = forMode !== undefined ? forMode : mode;
|
||
if (m === '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 addFrameLayer(frame, forMode) {
|
||
const id = `rv-${forMode}-${frame.time}`;
|
||
if (!map.getSource(id)) {
|
||
map.addSource(id, { type: 'raster', tiles: [frameTileUrl(frame, forMode)], tileSize: 256 });
|
||
map.addLayer({
|
||
id, type: 'raster', source: id,
|
||
paint: {
|
||
'raster-opacity': 0,
|
||
'raster-opacity-transition': { duration: FRAME_FADE_MS, delay: 0 },
|
||
'raster-fade-duration': 0,
|
||
},
|
||
});
|
||
layers[forMode][frame.time] = id;
|
||
}
|
||
return id;
|
||
}
|
||
|
||
function showFrame(idx) {
|
||
const frames = activeFrames();
|
||
currentIdx = idx;
|
||
const frame = frames[idx];
|
||
if (!frame) return;
|
||
|
||
whenReady(() => {
|
||
const layerId = layers[mode][frame.time] || addFrameLayer(frame, mode);
|
||
|
||
if (visibleLayerId && visibleLayerId !== layerId && map.getLayer(visibleLayerId)) {
|
||
map.setPaintProperty(visibleLayerId, 'raster-opacity', 0);
|
||
}
|
||
|
||
map.setPaintProperty(layerId, 'raster-opacity', 0.78);
|
||
visibleLayerId = layerId;
|
||
});
|
||
|
||
if (tsEl) tsEl.textContent = tsLabel(frame.time);
|
||
document.querySelectorAll(`#${timelineEl.id} .tl-tick`).forEach((el, i) => {
|
||
el.classList.toggle('active', i === idx);
|
||
});
|
||
}
|
||
|
||
function buildTimeline() {
|
||
timelineEl.innerHTML = '';
|
||
activeFrames().forEach((f, i) => {
|
||
const tick = document.createElement('div');
|
||
tick.className = 'tl-tick' + (f.isForecast ? ' forecast' : '');
|
||
tick.title = tsLabel(f.time);
|
||
tick.addEventListener('click', () => { stopPlay(); showFrame(i); });
|
||
timelineEl.appendChild(tick);
|
||
});
|
||
}
|
||
|
||
function step() {
|
||
const frames = activeFrames();
|
||
if (!frames.length) return;
|
||
const next = (currentIdx + 1) % frames.length;
|
||
showFrame(next);
|
||
if (next === 0) {
|
||
stopPlay();
|
||
setTimeout(startPlay, 800);
|
||
}
|
||
}
|
||
|
||
function startPlay() {
|
||
playing = true;
|
||
playBtn.textContent = '⏸';
|
||
animTimer = setInterval(step, FRAME_INTERVAL_MS);
|
||
}
|
||
|
||
function stopPlay() {
|
||
playing = false;
|
||
playBtn.textContent = '▶';
|
||
clearInterval(animTimer);
|
||
}
|
||
|
||
playBtn.addEventListener('click', () => playing ? stopPlay() : startPlay());
|
||
|
||
if (layerBtn) {
|
||
layerBtn.addEventListener('click', () => {
|
||
if (!(radarFrames.length && satelliteFrames.length)) return;
|
||
mode = mode === 'radar' ? 'satellite' : 'radar';
|
||
layerBtn.textContent = mode === 'radar' ? 'R' : 'S';
|
||
visibleLayerId = null;
|
||
buildTimeline();
|
||
const idx = Math.min(currentIdx, activeFrames().length - 1);
|
||
showFrame(Math.max(idx, 0));
|
||
});
|
||
}
|
||
|
||
async function loadFrames() {
|
||
if (loadingFrames || framesReady) return;
|
||
loadingFrames = true;
|
||
let retries = 0;
|
||
const maxRetries = 2;
|
||
|
||
try {
|
||
while (retries < maxRetries) {
|
||
try {
|
||
const controller = new AbortController();
|
||
const timeoutId = setTimeout(() => controller.abort(), 20000);
|
||
|
||
const r = await fetch('/api/radar/times', { signal: controller.signal });
|
||
clearTimeout(timeoutId);
|
||
|
||
if (!r.ok) {
|
||
throw new Error(`Radar fetch returned ${r.status}`);
|
||
}
|
||
const data = await r.json();
|
||
|
||
radarFrames = [];
|
||
satelliteFrames = [];
|
||
(data.radar?.past || []).forEach(f => radarFrames.push({
|
||
time: f.time, path: f.path, url: f.url,
|
||
maxNativeZoom: f.maxNativeZoom, isForecast: false,
|
||
}));
|
||
(data.satellite?.infrared || []).forEach(f => satelliteFrames.push({
|
||
time: f.time, path: f.path, url: f.url,
|
||
maxNativeZoom: f.maxNativeZoom, isForecast: false,
|
||
}));
|
||
|
||
if (!radarFrames.length && satelliteFrames.length) mode = 'satellite';
|
||
if (!radarFrames.length && !satelliteFrames.length) {
|
||
console.warn('No radar or satellite frames available');
|
||
return;
|
||
}
|
||
|
||
if (layerBtn) {
|
||
layerBtn.disabled = !(radarFrames.length && satelliteFrames.length);
|
||
layerBtn.textContent = mode === 'radar' ? 'R' : 'S';
|
||
}
|
||
|
||
// Pre-add all frame layers so tiles begin fetching before animation starts
|
||
whenReady(() => {
|
||
radarFrames.forEach(f => addFrameLayer(f, 'radar'));
|
||
satelliteFrames.forEach(f => addFrameLayer(f, 'satellite'));
|
||
});
|
||
|
||
buildTimeline();
|
||
const frames = activeFrames();
|
||
showFrame(Math.max(frames.length - 1, 0));
|
||
framesReady = true;
|
||
return;
|
||
} catch (e) {
|
||
retries++;
|
||
if (e.name === 'AbortError') {
|
||
console.warn(`Radar load attempt ${retries} timed out - API slow or unavailable`);
|
||
} else {
|
||
console.error(`Radar load attempt ${retries} failed:`, e.message);
|
||
}
|
||
|
||
if (retries >= maxRetries) {
|
||
console.error('Radar load failed after', maxRetries, 'attempts');
|
||
return;
|
||
}
|
||
|
||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||
}
|
||
}
|
||
} finally {
|
||
loadingFrames = false;
|
||
}
|
||
}
|
||
|
||
function panTo(lat, lon, zoom) {
|
||
map.flyTo({ center: [lon, lat], zoom: zoom || map.getZoom(), duration: 600 });
|
||
if (locationMarker) { locationMarker.remove(); locationMarker = null; }
|
||
const el = document.createElement('div');
|
||
el.className = 'map-location-dot';
|
||
locationMarker = new maplibregl.Marker({ element: el })
|
||
.setLngLat([lon, lat])
|
||
.addTo(map);
|
||
}
|
||
|
||
function isMapVisible() {
|
||
return mapEl.offsetWidth > 0 && mapEl.offsetHeight > 0;
|
||
}
|
||
|
||
function refresh() {
|
||
if (!isMapVisible()) return;
|
||
map.resize();
|
||
loadFrames();
|
||
}
|
||
|
||
function setRadarVisible(visible) {
|
||
Object.values(layers.radar).forEach(id => {
|
||
if (map.getLayer(id)) map.setLayoutProperty(id, 'visibility', visible ? 'visible' : 'none');
|
||
});
|
||
}
|
||
|
||
function setSatVisible(visible) {
|
||
Object.values(layers.satellite).forEach(id => {
|
||
if (map.getLayer(id)) map.setLayoutProperty(id, 'visibility', visible ? 'visible' : 'none');
|
||
});
|
||
}
|
||
|
||
// Wait for visibility before first load to avoid zero-size MapLibre rendering.
|
||
setTimeout(() => {
|
||
let tries = 0;
|
||
const maxTries = 40;
|
||
const timer = setInterval(() => {
|
||
tries++;
|
||
if (isMapVisible()) {
|
||
refresh();
|
||
clearInterval(timer);
|
||
} else if (tries >= maxTries) {
|
||
clearInterval(timer);
|
||
}
|
||
}, 250);
|
||
}, 100);
|
||
|
||
return { map, panTo, startPlay, stopPlay, refresh, setRadarVisible, setSatVisible };
|
||
}
|
||
|
||
const radarEmbedEl = document.getElementById('radar-embed');
|
||
const radarEmbed = radarEmbedEl
|
||
? createRadar(
|
||
radarEmbedEl,
|
||
document.getElementById('embed-timeline'),
|
||
document.getElementById('embed-play'),
|
||
document.getElementById('embed-timestamp'),
|
||
document.getElementById('embed-layer')
|
||
)
|
||
: { map: null, panTo: () => {}, startPlay: () => {}, stopPlay: () => {}, refresh: () => {} };
|
||
|
||
// ── Radar embed fullscreen toggle ─────────────────────────────────────────────
|
||
(function initRadarFullscreen() {
|
||
const btn = document.getElementById('embed-fullscreen');
|
||
const wrap = document.getElementById('radar-embed-wrap');
|
||
if (!btn || !wrap) return;
|
||
|
||
function updateIcon() {
|
||
btn.textContent = document.fullscreenElement === wrap ? '✕' : '⛶';
|
||
btn.title = document.fullscreenElement === wrap ? 'Exit fullscreen' : 'Toggle fullscreen';
|
||
// MapLibre needs a resize signal after the container size changes.
|
||
setTimeout(() => radarEmbed.map && radarEmbed.map.resize(), 120);
|
||
}
|
||
|
||
btn.addEventListener('click', () => {
|
||
if (document.fullscreenElement === wrap) {
|
||
document.exitFullscreen().catch(() => {});
|
||
} else {
|
||
wrap.requestFullscreen().catch(() => {});
|
||
}
|
||
});
|
||
|
||
document.addEventListener('fullscreenchange', updateIcon);
|
||
})();
|
||
|
||
const radarFullEl = document.getElementById('radar-full');
|
||
const radarFull = radarFullEl
|
||
? createRadar(
|
||
radarFullEl,
|
||
document.getElementById('full-timeline'),
|
||
document.getElementById('full-play'),
|
||
document.getElementById('full-timestamp'),
|
||
document.getElementById('full-layer')
|
||
)
|
||
: { map: null, panTo: () => {}, startPlay: () => {}, stopPlay: () => {}, refresh: () => {} };
|
||
|
||
(function initMapFullscreen() {
|
||
const fsBtn = document.getElementById('map-fullscreen-btn');
|
||
if (!fsBtn) return;
|
||
|
||
function isFs() { return document.body.classList.contains('map-fs'); }
|
||
|
||
function syncBtn() {
|
||
if (isFs()) { fsBtn.textContent = '✕'; fsBtn.title = 'Exit fullscreen'; }
|
||
else { fsBtn.textContent = '⛶'; fsBtn.title = 'Fullscreen'; }
|
||
}
|
||
|
||
function enterFs() {
|
||
document.body.classList.add('map-fs');
|
||
syncBtn();
|
||
setTimeout(() => { unifiedMap && unifiedMap.refresh && unifiedMap.refresh(); }, 120);
|
||
}
|
||
|
||
function exitFs() {
|
||
document.body.classList.remove('map-fs');
|
||
syncBtn();
|
||
setTimeout(() => { unifiedMap && unifiedMap.refresh && unifiedMap.refresh(); }, 120);
|
||
}
|
||
|
||
// Sync on load in case of stale state
|
||
syncBtn();
|
||
|
||
fsBtn.addEventListener('click', () => {
|
||
if (isFs()) exitFs(); else enterFs();
|
||
});
|
||
})();
|
||
|
||
function createOverviewMap(mapEl) {
|
||
const map = new maplibregl.Map({
|
||
container: mapEl,
|
||
style: {
|
||
version: 8,
|
||
sources: { base: { type: 'raster',
|
||
tiles: ['https://a.tile.openstreetmap.org/{z}/{x}/{y}.png'],
|
||
tileSize: 256, attribution: '© OpenStreetMap' } },
|
||
layers: [{ id: 'base', type: 'raster', source: 'base' }],
|
||
},
|
||
center: [-98.35, 39.5],
|
||
zoom: 4,
|
||
attributionControl: false,
|
||
});
|
||
|
||
let marker = null;
|
||
|
||
function panTo(lat, lon, zoom) {
|
||
map.flyTo({ center: [lon, lat], zoom: zoom || map.getZoom(), duration: 600 });
|
||
if (marker) { marker.remove(); marker = null; }
|
||
const el = document.createElement('div');
|
||
el.className = 'map-location-dot';
|
||
marker = new maplibregl.Marker({ element: el })
|
||
.setLngLat([lon, lat])
|
||
.addTo(map);
|
||
}
|
||
|
||
return { map, panTo };
|
||
}
|
||
|
||
function clearLightningMarkers() {
|
||
state.lightningMarkers.forEach(m => m.remove());
|
||
state.lightningMarkers = [];
|
||
}
|
||
|
||
function clearWindArrows() {
|
||
if (!mapsOverview.map) return;
|
||
['wind-arrows'].forEach(id => {
|
||
if (mapsOverview.map.getLayer(id)) mapsOverview.map.removeLayer(id);
|
||
if (mapsOverview.map.getSource(id)) mapsOverview.map.removeSource(id);
|
||
});
|
||
state.windArrows = [];
|
||
}
|
||
|
||
function refreshMapsOverview() {
|
||
if (!mapsOverview.map) return;
|
||
|
||
mapsOverview.map.resize();
|
||
|
||
if (!state.currentCity) return;
|
||
|
||
const zoom = mapsOverview.map.getZoom ? mapsOverview.map.getZoom() : 6;
|
||
mapsOverview.panTo(state.currentCity.latitude, state.currentCity.longitude, zoom || 6);
|
||
|
||
clearLightningMarkers();
|
||
clearWindArrows();
|
||
clearAqiMarkers();
|
||
clearAlertMarkers();
|
||
clearSunMoonMarkers();
|
||
clearTempMarker();
|
||
|
||
if (state.showLightning || state.unifiedMapLayers.lightning) renderLightningStrikes(state.currentCity);
|
||
if ((state.showWind || state.unifiedMapLayers.wind) && state.currentWeather) renderWindArrows(state.currentCity);
|
||
renderAqiMarker(state.currentCity);
|
||
renderAlertMarker(state.currentCity);
|
||
renderTempMarker(state.currentCity);
|
||
renderSunMoonMarker(state.currentCity);
|
||
renderHurricaneOverlay(mapsOverview.map);
|
||
renderWildfireOverlay(mapsOverview.map);
|
||
renderSPCOverlay(mapsOverview.map);
|
||
}
|
||
|
||
function renderLightningStrikes(city) {
|
||
if (!mapsOverview.map || !state.showLightning) return;
|
||
clearLightningMarkers();
|
||
|
||
// Simulated strikes were removed. Only live strike feeds should render here.
|
||
}
|
||
|
||
function renderWindArrows(city) {
|
||
if (!mapsOverview.map || !state.showWind || !state.currentWeather) return;
|
||
clearWindArrows();
|
||
|
||
const current = state.currentWeather.current;
|
||
if (!current || current.wind_speed_10m == null) return;
|
||
|
||
const windSpeed = current.wind_speed_10m;
|
||
const windGust = current.wind_gusts_10m || 0;
|
||
const windDir = current.wind_direction_10m || 0;
|
||
|
||
const windSpeedEl = document.getElementById('wind-speed');
|
||
const windGustEl = document.getElementById('wind-gust');
|
||
const windDirectionEl = document.getElementById('wind-direction');
|
||
if (windSpeedEl) windSpeedEl.textContent = `${speedDisplay(windSpeed)} ${speedUnit()}`;
|
||
if (windGustEl) windGustEl.textContent = windGust > 0 ? `${speedDisplay(windGust)} ${speedUnit()}` : '--';
|
||
if (windDirectionEl) windDirectionEl.textContent = compassDirFull(windDir);
|
||
|
||
const lat = parseFloat(city.latitude);
|
||
const lon = parseFloat(city.longitude);
|
||
const arrowCount = Math.max(3, Math.min(9, Math.ceil(windSpeed / 3)));
|
||
const baseRadius = 0.12;
|
||
const color = windSpeed > 20 ? '#ff3333' : windSpeed > 10 ? '#ffaa00' : '#4a90e2';
|
||
const features = [];
|
||
|
||
for (let i = 0; i < arrowCount; i++) {
|
||
const bandPos = i - (arrowCount - 1) / 2;
|
||
const offsetLat = lat + (bandPos * 0.045);
|
||
const offsetLon = lon + (Math.abs(bandPos) % 2 ? 0.025 : -0.025);
|
||
const radians = (windDir * Math.PI) / 180;
|
||
const arrowLength = baseRadius + (Math.abs(bandPos) * 0.028) + Math.min(windSpeed / 120, 0.08);
|
||
const endLat = offsetLat + arrowLength * Math.cos(radians);
|
||
const endLon = offsetLon + arrowLength * Math.sin(radians);
|
||
|
||
features.push({ type: 'Feature', geometry: { type: 'LineString',
|
||
coordinates: [[offsetLon, offsetLat], [endLon, endLat]] }, properties: {} });
|
||
|
||
const headSize = 0.05;
|
||
features.push({ type: 'Feature', geometry: { type: 'LineString', coordinates: [
|
||
[endLon - headSize * Math.sin(radians + Math.PI * 0.15), endLat - headSize * Math.cos(radians + Math.PI * 0.15)],
|
||
[endLon, endLat],
|
||
[endLon - headSize * Math.sin(radians - Math.PI * 0.15), endLat - headSize * Math.cos(radians - Math.PI * 0.15)],
|
||
]}, properties: {} });
|
||
}
|
||
|
||
const map = mapsOverview.map;
|
||
if (!map.loaded()) return;
|
||
map.addSource('wind-arrows', { type: 'geojson', data: { type: 'FeatureCollection', features } });
|
||
map.addLayer({
|
||
id: 'wind-arrows', type: 'line', source: 'wind-arrows',
|
||
paint: { 'line-color': color, 'line-width': 2.5, 'line-opacity': 1.0 },
|
||
});
|
||
}
|
||
|
||
function clearAqiMarkers() {
|
||
state.aqiMarkers.forEach(m => m.remove());
|
||
state.aqiMarkers = [];
|
||
}
|
||
|
||
function clearAlertMarkers() {
|
||
state.alertMarkers.forEach(m => m.remove());
|
||
state.alertMarkers = [];
|
||
}
|
||
|
||
function clearSunMoonMarkers() {
|
||
state.sunMoonMarkers.forEach(m => m.remove());
|
||
state.sunMoonMarkers = [];
|
||
}
|
||
|
||
function clearTempMarker() {
|
||
if (state.tempMarker) { state.tempMarker.remove(); state.tempMarker = null; }
|
||
}
|
||
|
||
function makeLabeledMarker(lon, lat, dotColor, labelHtml, popupHtml) {
|
||
const el = document.createElement('div');
|
||
el.className = 'map-labeled-marker';
|
||
el.innerHTML = `<div class="map-dot" style="background:${dotColor}"></div><span class="map-dot-label">${labelHtml}</span>`;
|
||
const popup = new maplibregl.Popup({ offset: 12, closeButton: false }).setHTML(popupHtml);
|
||
return new maplibregl.Marker({ element: el, anchor: 'left' })
|
||
.setLngLat([lon, lat])
|
||
.setPopup(popup);
|
||
}
|
||
|
||
function renderAqiMarker(city) {
|
||
if (!mapsOverview.map || !state.unifiedMapLayers.aqi || !state.currentAir?.current) return;
|
||
clearAqiMarkers();
|
||
const aqi = state.currentAir.current.us_aqi;
|
||
if (aqi == null) return;
|
||
const color = aqi > 150 ? '#d32f2f' : aqi > 100 ? '#f57c00' : aqi > 50 ? '#fbc02d' : '#388e3c';
|
||
const aqiLon = Number(city.longitude) - 0.06;
|
||
const aqiLat = Number(city.latitude) - 0.045;
|
||
const marker = makeLabeledMarker(aqiLon, aqiLat, color, `AQI ${aqi}`, `AQI ${aqi}`);
|
||
marker.addTo(mapsOverview.map);
|
||
state.aqiMarkers.push(marker);
|
||
}
|
||
|
||
function renderAlertMarker(city) {
|
||
if (!mapsOverview.map || !state.unifiedMapLayers.alerts || !state.currentAlerts?.features?.length) return;
|
||
clearAlertMarkers();
|
||
const count = state.currentAlerts.features.length;
|
||
const marker = makeLabeledMarker(
|
||
Number(city.longitude), Number(city.latitude),
|
||
'#d32f2f', `⚠ ${count}`, `${count} active alert(s)`
|
||
);
|
||
marker.addTo(mapsOverview.map);
|
||
state.alertMarkers.push(marker);
|
||
}
|
||
|
||
function renderTempMarker(city) {
|
||
if (!mapsOverview.map || !state.unifiedMapLayers.temp || !state.currentWeather?.current) return;
|
||
clearTempMarker();
|
||
const temp = tempDisplay(state.currentWeather.current.temperature_2m);
|
||
const label = `${temp}°${tempUnit()}`;
|
||
const marker = makeLabeledMarker(
|
||
Number(city.longitude) - 0.055, Number(city.latitude) + 0.05,
|
||
'#1e88e5', label, label
|
||
);
|
||
marker.addTo(mapsOverview.map);
|
||
state.tempMarker = marker;
|
||
}
|
||
|
||
function renderSunMoonMarker(city) {
|
||
if (!mapsOverview.map || !state.unifiedMapLayers.sunMoon || !state.currentWeather?.daily) return;
|
||
clearSunMoonMarkers();
|
||
const daily = state.currentWeather.daily;
|
||
const sunrise = formatClock(daily.sunrise?.[0]);
|
||
const sunset = formatClock(daily.sunset?.[0]);
|
||
const marker = makeLabeledMarker(
|
||
Number(city.longitude) + 0.06, Number(city.latitude) + 0.05,
|
||
'#fbc02d', `☀ ${sunrise}`, `Sunrise: ${sunrise}<br>Sunset: ${sunset}`
|
||
);
|
||
marker.addTo(mapsOverview.map);
|
||
state.sunMoonMarkers.push(marker);
|
||
}
|
||
|
||
const unifiedMapModeButton = document.createElement('button');
|
||
let unifiedMapMode = 'radar';
|
||
|
||
const unifiedMapEl = document.getElementById('unified-map');
|
||
const legacyMapEl = document.getElementById('map-overview');
|
||
const mapsOverview = unifiedMapEl
|
||
? createRadar(
|
||
unifiedMapEl,
|
||
document.getElementById('timeline-slider'),
|
||
document.getElementById('timeline-play'),
|
||
document.getElementById('timeline-timestamp'),
|
||
unifiedMapModeButton
|
||
)
|
||
: (legacyMapEl
|
||
? createOverviewMap(legacyMapEl)
|
||
: { map: null, panTo: () => {}, refresh: () => {} });
|
||
|
||
const unifiedMap = mapsOverview;
|
||
|
||
unifiedMapModeButton.addEventListener('click', () => {
|
||
unifiedMapMode = unifiedMapMode === 'radar' ? 'satellite' : 'radar';
|
||
localStorage.setItem(STORAGE_KEYS.unifiedMapRadarMode, unifiedMapMode);
|
||
});
|
||
|
||
if (unifiedMapEl && state.unifiedMapRadarMode === 'satellite') {
|
||
unifiedMapModeButton.click();
|
||
}
|
||
|
||
// ── Rendering ────────────────────────────────────────────────────────────────
|
||
function setMapOverlayReadouts({ temp, aqi, sunmoon } = {}) {
|
||
const tempEl = document.getElementById('overlay-temp');
|
||
const aqiEl = document.getElementById('overlay-aqi');
|
||
const sunmoonEl = document.getElementById('overlay-sunmoon');
|
||
|
||
if (tempEl && temp != null && temp !== '') tempEl.textContent = `Temp ${temp}`;
|
||
if (aqiEl && aqi != null && aqi !== '') aqiEl.textContent = `AQI ${aqi}`;
|
||
if (sunmoonEl && sunmoon) sunmoonEl.textContent = sunmoon;
|
||
}
|
||
|
||
function renderWeather(data, city) {
|
||
const c = data.current;
|
||
const h = data.hourly;
|
||
const d = data.daily;
|
||
|
||
document.getElementById('loc-name').textContent = city.name;
|
||
document.getElementById('loc-sub').textContent = [city.admin1, city.country].filter(Boolean).join(', ');
|
||
|
||
const now = new Date();
|
||
document.getElementById('loc-time').textContent =
|
||
`${DAYS[now.getDay()]}, ${MONTHS[now.getMonth()]} ${now.getDate()} · ${now.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`;
|
||
|
||
const w = wmo(c.weather_code);
|
||
const iconEl = document.getElementById('cur-icon');
|
||
iconEl.textContent = w.icon;
|
||
iconEl.dataset.wmoGroup = wmoGroup(c.weather_code);
|
||
document.getElementById('cur-desc').textContent = w.desc;
|
||
document.getElementById('cur-temp').textContent = tempDisplay(c.temperature_2m);
|
||
document.querySelector('.deg').textContent = `°${tempUnit()}`;
|
||
document.getElementById('feels-line').innerHTML = `${apparentLabel(c.temperature_2m, c.apparent_temperature)} <span id="cur-feels">${tempDisplay(c.apparent_temperature)}</span>°${tempUnit()}`;
|
||
document.getElementById('cur-hi').textContent = tempDisplay(d.temperature_2m_max[0]);
|
||
document.getElementById('cur-lo').textContent = tempDisplay(d.temperature_2m_min[0]);
|
||
document.getElementById('cur-precip').textContent = c.precipitation > 0 ? `${precipDisplay(c.precipitation)} ${state.units === 'us' ? 'in' : 'mm'} precip` : 'No precipitation';
|
||
document.getElementById('cur-hum').textContent = c.relative_humidity_2m;
|
||
document.getElementById('hum-feel').textContent = humidityFeel(c.relative_humidity_2m);
|
||
document.getElementById('cur-wind').textContent = speedDisplay(c.wind_speed_10m);
|
||
document.getElementById('cur-wind-dir').textContent = compassDirFull(c.wind_direction_10m);
|
||
const peekEl = document.getElementById('wind-drawer-peek');
|
||
if (peekEl) peekEl.textContent = `· ${speedDisplay(c.wind_speed_10m)} ${speedUnit()} ${compassDir(c.wind_direction_10m)} · ${c.relative_humidity_2m}% humidity`;
|
||
document.getElementById('cur-gust').textContent = c.wind_gusts_10m != null ? speedDisplay(c.wind_gusts_10m) : '—';
|
||
document.getElementById('cur-dew').textContent = calcDewpoint(c.temperature_2m, c.relative_humidity_2m);
|
||
document.getElementById('cur-pres').textContent = Math.round(c.surface_pressure);
|
||
document.getElementById('cur-vis').textContent = visDisplay(c.visibility);
|
||
|
||
const panelLocation = document.getElementById('panel-location-name');
|
||
const panelWindSpeed = document.getElementById('panel-wind-speed');
|
||
const panelWindDir = document.getElementById('panel-wind-dir');
|
||
const panelTemp = document.getElementById('panel-temp');
|
||
const panelAqi = document.getElementById('panel-aqi');
|
||
const panelCloud = document.getElementById('panel-cloud');
|
||
const panelWrap = document.getElementById('data-panel');
|
||
if (panelLocation) panelLocation.textContent = cityLabel(city) || city.name || '--';
|
||
if (panelWindSpeed) panelWindSpeed.textContent = `${speedDisplay(c.wind_speed_10m)} ${speedUnit()}`;
|
||
if (panelWindDir) panelWindDir.textContent = compassDirFull(c.wind_direction_10m || 0);
|
||
if (panelTemp) panelTemp.textContent = `${tempDisplay(c.temperature_2m)}°${tempUnit()}`;
|
||
if (panelAqi) panelAqi.textContent = state.currentAir?.current?.us_aqi ?? '--';
|
||
if (panelCloud) panelCloud.textContent = c.cloud_cover != null ? `${Math.round(c.cloud_cover)}%` : '--';
|
||
if (panelWrap) panelWrap.classList.remove('hidden');
|
||
document.getElementById('cur-uv').textContent = Math.round(d.uv_index_max[0] ?? 0);
|
||
document.getElementById('cur-cloud').textContent = c.cloud_cover;
|
||
document.querySelectorAll('.wind-unit, .gust-unit').forEach(el => el.textContent = speedUnit());
|
||
document.querySelectorAll('.vis-unit').forEach(el => el.textContent = visUnit());
|
||
|
||
document.getElementById('sunrise-val').textContent = formatClock(d.sunrise[0]);
|
||
document.getElementById('sunset-val').textContent = formatClock(d.sunset[0]);
|
||
document.getElementById('moon-phase-val').textContent = moonPhase(d.time[0]);
|
||
|
||
const sunriseLabel = formatClock(d.sunrise[0]);
|
||
const sunsetLabel = formatClock(d.sunset[0]);
|
||
setMapOverlayReadouts({
|
||
temp: `${tempDisplay(c.temperature_2m)}°${tempUnit()}`,
|
||
sunmoon: `Sun ${sunriseLabel}/${sunsetLabel}`,
|
||
});
|
||
|
||
// Sun timeline progress
|
||
const sunriseMs = new Date(d.sunrise[0]).getTime();
|
||
const sunsetMs = new Date(d.sunset[0]).getTime();
|
||
const nowMs = Date.now();
|
||
const totalMs = sunsetMs - sunriseMs;
|
||
const sunPct = Math.max(0, Math.min(100, ((nowMs - sunriseMs) / totalMs) * 100));
|
||
const progressEl = document.getElementById('sun-progress');
|
||
const dotEl = document.getElementById('sun-dot');
|
||
if (progressEl) progressEl.style.width = sunPct + '%';
|
||
if (dotEl) dotEl.style.left = sunPct + '%';
|
||
const daylightEl = document.getElementById('daylight-info');
|
||
if (daylightEl) daylightEl.textContent = `${(totalMs / 3600000).toFixed(1)}h daylight`;
|
||
|
||
renderRecommendations(data);
|
||
|
||
const nowTs = Date.now();
|
||
let startIdx = h.time.findIndex(t => new Date(t).getTime() >= nowTs);
|
||
if (startIdx < 0) startIdx = 0;
|
||
|
||
const track = document.getElementById('hourly-track');
|
||
track.innerHTML = '';
|
||
const hourLimit = Math.min(startIdx + (state.hourlyExtended ? 48 : 24), h.time.length);
|
||
for (let i = startIdx; i < hourLimit; 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 displayTemp = state.hourlyFeelsLike
|
||
? (h.apparent_temperature?.[i] ?? h.temperature_2m[i])
|
||
: h.temperature_2m[i];
|
||
const barH = Math.round((pp / 100) * 28);
|
||
const barColor = pp >= 70 ? '#2c7bb6' : pp >= 40 ? '#74add1' : '#abd9e9';
|
||
const cell = document.createElement('div');
|
||
cell.className = 'hour-cell' + (i === startIdx ? ' now' : '');
|
||
cell.innerHTML = `
|
||
<div class="hour-time">${i === startIdx ? 'Now' : formatHour(h.time[i])}</div>
|
||
<div class="hour-icon">${hw.icon}</div>
|
||
<div class="hour-temp">${tempDisplay(displayTemp)}°</div>
|
||
<div class="hour-bar-wrap">
|
||
<div class="hour-bar" style="height:${barH}px;background:${barColor}" title="Rain chance: ${pp}%"></div>
|
||
</div>
|
||
<div class="hour-precip">${pp > 0 ? `💧${pp}%` : ''}</div>
|
||
`;
|
||
track.appendChild(cell);
|
||
}
|
||
state.showExtendedForecast = false;
|
||
renderDailyForecast(d);
|
||
|
||
document.getElementById('updated-at').textContent =
|
||
new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||
|
||
radarEmbed.panTo(city.latitude, city.longitude, 6);
|
||
radarFull.panTo(city.latitude, city.longitude, 6);
|
||
const mapZoom = mapsOverview?.map?.getZoom ? mapsOverview.map.getZoom() : 6;
|
||
mapsOverview.panTo(city.latitude, city.longitude, mapZoom || 6);
|
||
if (mapsWindEl) mapsWindEl.textContent = `${speedDisplay(c.wind_speed_10m)} ${speedUnit()} ${compassDir(c.wind_direction_10m)}`;
|
||
updateSourceLocation(city);
|
||
updateShareUrl(city);
|
||
persistLastCity(city);
|
||
refreshMapsOverview();
|
||
|
||
show(weatherDiv);
|
||
setTimeout(() => { radarEmbed.refresh && radarEmbed.refresh(); }, 80);
|
||
|
||
// Apply station overlay if we have fresh station data
|
||
if (state.ambientDevice) applyStationOverlay(state.ambientDevice);
|
||
}
|
||
|
||
function renderDailyForecast(daily) {
|
||
const list = document.getElementById('daily-list');
|
||
if (!list || !daily?.time?.length) return;
|
||
|
||
const totalDays = daily.time.length;
|
||
const maxDays = state.showExtendedForecast ? Math.min(16, totalDays) : Math.min(7, totalDays);
|
||
|
||
list.innerHTML = '';
|
||
for (let i = 0; i < maxDays; i++) {
|
||
const dw = wmo(daily.weather_code[i]);
|
||
const row = document.createElement('div');
|
||
row.className = 'day-row' + (i === 0 ? ' today' : '');
|
||
row.innerHTML = `
|
||
<div class="day-name">${formatDayShort(daily.time[i])}</div>
|
||
<div class="day-icon">${dw.icon}</div>
|
||
<div class="day-desc">${dw.desc}</div>
|
||
<div class="day-temps">${tempDisplay(daily.temperature_2m_max[i])}°<span class="day-lo"> / ${tempDisplay(daily.temperature_2m_min[i])}°</span></div>
|
||
<div class="day-meta">${daily.precipitation_probability_max[i] > 0 ? daily.precipitation_probability_max[i] + '% rain<br>' : ''}${speedDisplay(daily.wind_speed_10m_max[i])} ${speedUnit()}</div>
|
||
`;
|
||
list.appendChild(row);
|
||
}
|
||
|
||
if (dailyForecastLabel) {
|
||
dailyForecastLabel.textContent = state.showExtendedForecast ? '16-Day Forecast' : '7-Day Forecast';
|
||
}
|
||
|
||
if (dailyToggleBtn) {
|
||
if (totalDays > 7) {
|
||
show(dailyToggleBtn);
|
||
dailyToggleBtn.textContent = state.showExtendedForecast ? 'Show 7-day' : 'Show 16-day';
|
||
} else {
|
||
hide(dailyToggleBtn);
|
||
}
|
||
}
|
||
}
|
||
|
||
function renderAirQuality(data) {
|
||
const cur = data?.current || {};
|
||
const pollen = ['alder_pollen', 'birch_pollen', 'grass_pollen', 'mugwort_pollen', 'olive_pollen', 'ragweed_pollen']
|
||
.map(k => cur[k] || 0)
|
||
.reduce((max, v) => Math.max(max, v), 0);
|
||
|
||
document.getElementById('aqi-us').textContent = cur.us_aqi ?? '--';
|
||
document.getElementById('pm25').textContent = cur.pm2_5 != null ? `${Number(cur.pm2_5).toFixed(1)} ug/m3` : '--';
|
||
document.getElementById('pollen').textContent = pollen > 0 ? pollen.toFixed(1) : '--';
|
||
|
||
const panelAqi = document.getElementById('panel-aqi');
|
||
if (panelAqi) panelAqi.textContent = cur.us_aqi ?? '--';
|
||
|
||
setMapOverlayReadouts({ aqi: cur.us_aqi ?? '--' });
|
||
}
|
||
|
||
function normalizeSeverity(severity) {
|
||
const s = String(severity || 'Unknown').toLowerCase();
|
||
if (s.includes('extreme')) return 'Extreme';
|
||
if (s.includes('severe')) return 'Severe';
|
||
if (s.includes('moderate')) return 'Moderate';
|
||
if (s.includes('minor')) return 'Minor';
|
||
return 'Unknown';
|
||
}
|
||
|
||
function severityRank(severity) {
|
||
const map = { Extreme: 0, Severe: 1, Moderate: 2, Minor: 3, Unknown: 4 };
|
||
return map[severity] ?? 4;
|
||
}
|
||
|
||
function plainAlertAction(eventName, severity) {
|
||
const eventText = String(eventName || '').toLowerCase();
|
||
const sev = normalizeSeverity(severity);
|
||
if (eventText.includes('tornado')) return 'Move to an interior room on the lowest level away from windows immediately.';
|
||
if (eventText.includes('thunderstorm')) return 'Pause outdoor activity, secure loose items, and be ready to shelter indoors quickly.';
|
||
if (eventText.includes('flood')) return 'Avoid low-water crossings and reroute away from flood-prone roads.';
|
||
if (eventText.includes('winter') || eventText.includes('snow') || eventText.includes('ice') || eventText.includes('blizzard')) return 'Delay travel if possible and prepare for slick roads and slower stopping distances.';
|
||
if (eventText.includes('heat')) return 'Hydrate often, reduce exertion, and seek shade or cooling when possible.';
|
||
if (eventText.includes('wind')) return 'Secure outdoor objects and use extra caution when driving high-profile vehicles.';
|
||
if (eventText.includes('fire') || eventText.includes('red flag')) return 'Avoid sparks or open flame sources and follow local fire restrictions.';
|
||
if (sev === 'Extreme' || sev === 'Severe') return 'Treat this as high risk and take protective action now.';
|
||
if (sev === 'Moderate') return 'Use caution and adjust plans to reduce exposure to hazardous conditions.';
|
||
return 'Stay aware of updates and keep backup plans ready.';
|
||
}
|
||
|
||
function buildAlertTiming(p) {
|
||
const start = p?.onset || p?.effective || p?.sent;
|
||
const end = p?.expires;
|
||
const startTxt = start ? formatClock(start) : 'now';
|
||
const endTxt = end ? formatClock(end) : 'until further notice';
|
||
return `${startTxt} - ${endTxt}`;
|
||
}
|
||
|
||
function buildStormEtaText() {
|
||
const nowMs = Date.now();
|
||
const alertTimes = (state.currentAlerts?.features || [])
|
||
.map(f => {
|
||
const p = f.properties || {};
|
||
return new Date(p.onset || p.effective || p.sent || 0).getTime();
|
||
})
|
||
.filter(ms => Number.isFinite(ms) && ms > nowMs)
|
||
.sort((a, b) => a - b);
|
||
if (alertTimes.length) {
|
||
const mins = Math.max(1, Math.round((alertTimes[0] - nowMs) / 60000));
|
||
if (mins < 60) return `~${mins} min`;
|
||
return `~${Math.round(mins / 60)} hr`;
|
||
}
|
||
|
||
const h = state.currentWeather?.hourly;
|
||
if (!h?.time || !h?.weather_code) return 'No storm signal in next 24h';
|
||
for (let i = 0; i < h.time.length && i < 24; i++) {
|
||
const ts = new Date(h.time[i]).getTime();
|
||
if (ts < nowMs) continue;
|
||
if (Number(h.weather_code[i]) >= 95) {
|
||
const mins = Math.max(1, Math.round((ts - nowMs) / 60000));
|
||
return mins < 60 ? `~${mins} min` : `~${Math.round(mins / 60)} hr`;
|
||
}
|
||
}
|
||
return 'No storm signal in next 24h';
|
||
}
|
||
|
||
function computeRiskLabel() {
|
||
const severities = (state.currentAlerts?.features || []).map(f => normalizeSeverity(f.properties?.severity));
|
||
if (severities.some(s => s === 'Extreme' || s === 'Severe')) return 'High';
|
||
if (severities.some(s => s === 'Moderate')) return 'Elevated';
|
||
if (severities.some(s => s === 'Minor')) return 'Guarded';
|
||
return 'Low';
|
||
}
|
||
|
||
function buildShareChecklist(risk) {
|
||
if (risk === 'High') return ['Have shelter plan ready', 'Delay non-essential travel', 'Keep devices fully charged'];
|
||
if (risk === 'Elevated') return ['Monitor radar and alerts hourly', 'Adjust outdoor timing', 'Secure loose outdoor items'];
|
||
if (risk === 'Guarded') return ['Use caution for outdoor plans', 'Keep backup indoor option', 'Recheck before departure'];
|
||
return ['Good conditions overall', 'Check updates before longer trips', 'Carry normal weather gear'];
|
||
}
|
||
|
||
function buildWeatherCardText() {
|
||
const city = cityLabel(state.currentCity) || 'Selected location';
|
||
const c = state.currentWeather?.current || {};
|
||
const currentAqi = state.currentAir?.current?.us_aqi ?? '--';
|
||
const risk = computeRiskLabel();
|
||
const eta = buildStormEtaText();
|
||
const alerts = state.currentAlerts?.features || [];
|
||
const checklist = buildShareChecklist(risk);
|
||
|
||
const lines = [
|
||
`Weather Card: ${city}`,
|
||
`Temp: ${tempDisplay(c.temperature_2m)}°${tempUnit()} (Feels ${tempDisplay(c.apparent_temperature)}°${tempUnit()})`,
|
||
`Wind: ${speedDisplay(c.wind_speed_10m)} ${speedUnit()} ${compassDirFull(c.wind_direction_10m || 0)}`,
|
||
`AQI: ${currentAqi}`,
|
||
`Storm ETA: ${eta}`,
|
||
`Risk Level: ${risk}`,
|
||
`Active Alerts: ${alerts.length}`,
|
||
'Checklist:',
|
||
...checklist.map(item => `- ${item}`),
|
||
];
|
||
return lines.join('\n');
|
||
}
|
||
|
||
function buildHeroCardText() {
|
||
const city = cityLabel(state.currentCity) || 'Selected location';
|
||
const c = state.currentWeather?.current || {};
|
||
const w = wmo(c.weather_code);
|
||
const risk = computeRiskLabel();
|
||
const currentAqi = state.currentAir?.current?.us_aqi ?? '--';
|
||
return [
|
||
`Now in ${city}`,
|
||
`${w.icon} ${w.desc}`,
|
||
`Temp ${tempDisplay(c.temperature_2m)}°${tempUnit()} (Feels ${tempDisplay(c.apparent_temperature)}°${tempUnit()})`,
|
||
`High ${tempDisplay(state.currentWeather?.daily?.temperature_2m_max?.[0])}°${tempUnit()} / Low ${tempDisplay(state.currentWeather?.daily?.temperature_2m_min?.[0])}°${tempUnit()}`,
|
||
`Wind ${speedDisplay(c.wind_speed_10m)} ${speedUnit()} ${compassDirFull(c.wind_direction_10m || 0)}`,
|
||
`AQI ${currentAqi} | Risk ${risk}`,
|
||
].join('\n');
|
||
}
|
||
|
||
function drawRoundedRect(ctx, x, y, w, h, r) {
|
||
const radius = Math.min(r, w / 2, h / 2);
|
||
ctx.beginPath();
|
||
ctx.moveTo(x + radius, y);
|
||
ctx.lineTo(x + w - radius, y);
|
||
ctx.quadraticCurveTo(x + w, y, x + w, y + radius);
|
||
ctx.lineTo(x + w, y + h - radius);
|
||
ctx.quadraticCurveTo(x + w, y + h, x + w - radius, y + h);
|
||
ctx.lineTo(x + radius, y + h);
|
||
ctx.quadraticCurveTo(x, y + h, x, y + h - radius);
|
||
ctx.lineTo(x, y + radius);
|
||
ctx.quadraticCurveTo(x, y, x + radius, y);
|
||
ctx.closePath();
|
||
}
|
||
|
||
function canvasToPngBlob(canvas) {
|
||
return new Promise(resolve => {
|
||
canvas.toBlob(blob => resolve(blob), 'image/png');
|
||
});
|
||
}
|
||
|
||
async function buildHeroCardImageFile() {
|
||
const city = cityLabel(state.currentCity) || 'Selected location';
|
||
const c = state.currentWeather?.current || {};
|
||
const d = state.currentWeather?.daily || {};
|
||
const w = wmo(c.weather_code);
|
||
const currentAqi = state.currentAir?.current?.us_aqi ?? '--';
|
||
const risk = computeRiskLabel();
|
||
|
||
const canvas = document.createElement('canvas');
|
||
canvas.width = 1080;
|
||
canvas.height = 1350;
|
||
const ctx = canvas.getContext('2d');
|
||
if (!ctx) return null;
|
||
|
||
const bg = ctx.createLinearGradient(0, 0, canvas.width, canvas.height);
|
||
bg.addColorStop(0, '#1f2a38');
|
||
bg.addColorStop(1, '#334e68');
|
||
ctx.fillStyle = bg;
|
||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||
|
||
ctx.fillStyle = 'rgba(255, 255, 255, 0.12)';
|
||
drawRoundedRect(ctx, 56, 56, canvas.width - 112, canvas.height - 112, 38);
|
||
ctx.fill();
|
||
|
||
ctx.fillStyle = '#ffffff';
|
||
ctx.font = '700 68px Segoe UI';
|
||
ctx.fillText('Weather Hero Card', 96, 156);
|
||
|
||
ctx.font = '600 42px Segoe UI';
|
||
ctx.fillStyle = '#dbeafe';
|
||
ctx.fillText(city, 96, 222);
|
||
|
||
ctx.font = '600 120px Segoe UI Emoji, Segoe UI';
|
||
ctx.fillStyle = '#ffffff';
|
||
ctx.fillText(w.icon || '⛅', 96, 390);
|
||
|
||
ctx.font = '700 64px Segoe UI';
|
||
ctx.fillText(`${tempDisplay(c.temperature_2m)}°${tempUnit()}`, 250, 362);
|
||
|
||
ctx.font = '500 36px Segoe UI';
|
||
ctx.fillStyle = '#e2e8f0';
|
||
ctx.fillText(`${w.desc}`, 250, 412);
|
||
ctx.fillText(`Feels ${tempDisplay(c.apparent_temperature)}°${tempUnit()}`, 250, 458);
|
||
|
||
ctx.fillStyle = 'rgba(255, 255, 255, 0.1)';
|
||
drawRoundedRect(ctx, 96, 520, canvas.width - 192, 460, 28);
|
||
ctx.fill();
|
||
|
||
ctx.fillStyle = '#ffffff';
|
||
ctx.font = '600 36px Segoe UI';
|
||
ctx.fillText(`High / Low: ${tempDisplay(d.temperature_2m_max?.[0])}°${tempUnit()} / ${tempDisplay(d.temperature_2m_min?.[0])}°${tempUnit()}`, 132, 610);
|
||
ctx.fillText(`Wind: ${speedDisplay(c.wind_speed_10m)} ${speedUnit()} ${compassDirFull(c.wind_direction_10m || 0)}`, 132, 684);
|
||
ctx.fillText(`AQI: ${currentAqi}`, 132, 758);
|
||
ctx.fillText(`Risk level: ${risk}`, 132, 832);
|
||
ctx.fillText(`Shared: ${new Date().toLocaleString()}`, 132, 906);
|
||
|
||
ctx.fillStyle = '#cbd5e1';
|
||
ctx.font = '500 30px Segoe UI';
|
||
ctx.fillText('Open full details and alerts in app link below.', 96, 1080);
|
||
ctx.fillText(window.location.href, 96, 1130);
|
||
|
||
const blob = await canvasToPngBlob(canvas);
|
||
if (!blob) return null;
|
||
return new File([blob], `hero-weather-${Date.now()}.png`, { type: 'image/png' });
|
||
}
|
||
|
||
function showHeroShareStatus(msg) {
|
||
if (!shareHeroStatusEl) return;
|
||
shareHeroStatusEl.textContent = msg;
|
||
show(shareHeroStatusEl);
|
||
setTimeout(() => {
|
||
shareHeroStatusEl.textContent = '';
|
||
hide(shareHeroStatusEl);
|
||
}, 2200);
|
||
}
|
||
|
||
function showShareWeatherStatus(msg) {
|
||
if (!shareWeatherStatusEl) return;
|
||
shareWeatherStatusEl.textContent = msg;
|
||
show(shareWeatherStatusEl);
|
||
setTimeout(() => {
|
||
shareWeatherStatusEl.textContent = '';
|
||
hide(shareWeatherStatusEl);
|
||
}, 2200);
|
||
}
|
||
|
||
function renderAlerts(data) {
|
||
// Populate hurricane panel asynchronously
|
||
fetch('/api/hurricanes').then(r => r.json()).then(d => renderHurricanePanel(d.storms || [])).catch(() => {});
|
||
|
||
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);
|
||
updateAlertBadge(0);
|
||
return;
|
||
}
|
||
|
||
updateAlertBadge(features.length);
|
||
if (alertsEmptyEl) hide(alertsEmptyEl);
|
||
|
||
const grouped = new Map();
|
||
features.forEach(f => {
|
||
const p = f.properties || {};
|
||
const sev = normalizeSeverity(p.severity);
|
||
if (!grouped.has(sev)) grouped.set(sev, []);
|
||
grouped.get(sev).push(f);
|
||
});
|
||
|
||
Array.from(grouped.keys())
|
||
.sort((a, b) => severityRank(a) - severityRank(b))
|
||
.forEach(severity => {
|
||
const groupWrap = document.createElement('section');
|
||
groupWrap.className = `alerts-group alerts-group-${severity.toLowerCase()}`;
|
||
|
||
const head = document.createElement('div');
|
||
head.className = 'alerts-group-head';
|
||
const title = document.createElement('h5');
|
||
title.className = 'alerts-group-title';
|
||
title.textContent = `${severity} Alerts (${grouped.get(severity).length})`;
|
||
const hint = document.createElement('span');
|
||
hint.className = 'alerts-group-hint';
|
||
hint.textContent = severityRank(severity) <= 1 ? 'Take action now' : 'Use caution';
|
||
head.appendChild(title);
|
||
head.appendChild(hint);
|
||
groupWrap.appendChild(head);
|
||
|
||
grouped.get(severity).slice(0, 6).forEach(f => {
|
||
const p = f.properties || {};
|
||
const card = document.createElement('article');
|
||
card.className = 'alert-item';
|
||
|
||
const top = document.createElement('div');
|
||
top.className = 'alert-top';
|
||
const event = document.createElement('strong');
|
||
event.textContent = p.event || 'Alert';
|
||
const sev = document.createElement('span');
|
||
sev.className = `alert-severity severity-${normalizeSeverity(p.severity).toLowerCase()}`;
|
||
sev.textContent = normalizeSeverity(p.severity);
|
||
top.appendChild(event);
|
||
top.appendChild(sev);
|
||
|
||
const timing = document.createElement('div');
|
||
timing.className = 'alert-timing';
|
||
timing.textContent = `Window: ${buildAlertTiming(p)}`;
|
||
|
||
const msg = document.createElement('div');
|
||
msg.className = 'alert-message';
|
||
msg.textContent = p.headline || p.description || 'See official sources for details.';
|
||
|
||
const action = document.createElement('div');
|
||
action.className = 'alert-action';
|
||
action.textContent = `What this means for me: ${plainAlertAction(p.event, p.severity)}`;
|
||
|
||
card.appendChild(top);
|
||
card.appendChild(timing);
|
||
card.appendChild(msg);
|
||
card.appendChild(action);
|
||
groupWrap.appendChild(card);
|
||
});
|
||
|
||
list.appendChild(groupWrap);
|
||
});
|
||
show(wrap);
|
||
|
||
const newlySeen = [];
|
||
features.forEach(f => {
|
||
const id = f.id || f.properties?.id;
|
||
if (!id || state.alertIdsSeen.has(id)) return;
|
||
state.alertIdsSeen.add(id);
|
||
newlySeen.push(id);
|
||
const p = f.properties || {};
|
||
const title = `Weather alert: ${p.event || 'Severe weather'}`;
|
||
const body = p.headline || p.description || 'An alert was issued for your selected location.';
|
||
|
||
if (typeof Notification !== 'undefined' && Notification.permission === 'granted') {
|
||
if ('serviceWorker' in navigator && navigator.serviceWorker.controller) {
|
||
navigator.serviceWorker.ready.then(reg =>
|
||
reg.showNotification(title, { body, icon: '/icons/icon-192.png', badge: '/icons/icon-192.png', tag: id, data: { url: '/?tab=alerts' } })
|
||
).catch(() => new Notification(title, { body, icon: '/icons/icon-192.png' }));
|
||
} else {
|
||
new Notification(title, { body, icon: '/icons/icon-192.png', badge: '/icons/icon-192.png' });
|
||
}
|
||
}
|
||
if (state.ntfyTopic) {
|
||
sendNtfyNotification(title, body, { priority: 'high', tags: ['warning,weather'] });
|
||
}
|
||
});
|
||
|
||
if (newlySeen.length) {
|
||
// Persist seen IDs; keep only the most recent 200 to bound localStorage size.
|
||
const stored = Array.from(state.alertIdsSeen).slice(-200);
|
||
localStorage.setItem('weather.alertIdsSeen', JSON.stringify(stored));
|
||
}
|
||
}
|
||
|
||
async function loadAll(city, source) {
|
||
state.currentCity = city;
|
||
if (source) state.currentSource = source;
|
||
clearErr();
|
||
hide(suggestEl);
|
||
hide(weatherDiv);
|
||
show(loadingEl);
|
||
|
||
try {
|
||
let weather;
|
||
try {
|
||
weather = await fetchWeather(city.latitude, city.longitude, city.timezone);
|
||
} catch (weatherErr) {
|
||
console.error('Primary weather fetch failed:', weatherErr?.message || weatherErr);
|
||
if (state.currentWeather) {
|
||
weather = state.currentWeather;
|
||
showErr('Live weather update failed. Showing last available data.');
|
||
} else {
|
||
throw weatherErr;
|
||
}
|
||
}
|
||
|
||
const [airResult, alertsResult] = await Promise.allSettled([
|
||
fetchAirQuality(city.latitude, city.longitude, city.timezone),
|
||
fetchAlerts(city.latitude, city.longitude),
|
||
]);
|
||
|
||
const air = airResult.status === 'fulfilled' ? airResult.value : { current: {} };
|
||
if (airResult.status !== 'fulfilled') {
|
||
console.warn('Air quality fetch failed:', airResult.reason?.message || airResult.reason);
|
||
}
|
||
|
||
const alerts = alertsResult.status === 'fulfilled' ? alertsResult.value : { features: [] };
|
||
if (alertsResult.status !== 'fulfilled') {
|
||
console.warn('Alerts fetch failed:', alertsResult.reason?.message || alertsResult.reason);
|
||
}
|
||
|
||
state.currentWeather = weather;
|
||
state.currentAir = air;
|
||
state.currentAlerts = alerts;
|
||
state.lastRefreshAt = Date.now();
|
||
|
||
// Keep weather load resilient: map/radar/render hiccups should not fail the whole page.
|
||
try {
|
||
renderWeather(weather, city);
|
||
} catch (renderErr) {
|
||
console.error('Weather render error:', renderErr);
|
||
show(weatherDiv);
|
||
}
|
||
|
||
try {
|
||
renderAirQuality(air);
|
||
} catch (airRenderErr) {
|
||
console.warn('Air quality render error:', airRenderErr);
|
||
}
|
||
|
||
try {
|
||
renderAlerts(alerts);
|
||
} catch (alertsRenderErr) {
|
||
console.warn('Alerts render error:', alertsRenderErr);
|
||
}
|
||
|
||
// Fetch discussion non-blocking — US only, silently skip if unavailable
|
||
fetchDiscussion(city.latitude, city.longitude);
|
||
|
||
// Fetch personal station non-blocking
|
||
fetchAmbientStation();
|
||
|
||
try {
|
||
if (state.showLightning || state.unifiedMapLayers.lightning) renderLightningStrikes(city);
|
||
if (state.showWind || state.unifiedMapLayers.wind) renderWindArrows(city);
|
||
renderAqiMarker(city);
|
||
renderAlertMarker(city);
|
||
renderTempMarker(city);
|
||
renderSunMoonMarker(city);
|
||
} catch (mapOverlayErr) {
|
||
console.warn('Map overlay render error:', mapOverlayErr);
|
||
}
|
||
} catch (e) {
|
||
showErr('Failed to load weather data. Please try again.');
|
||
console.error(e);
|
||
} finally {
|
||
hide(loadingEl);
|
||
}
|
||
}
|
||
|
||
function getPosition() {
|
||
return new Promise((resolve, reject) => {
|
||
if (!navigator.geolocation) {
|
||
reject(new Error('Geolocation not supported'));
|
||
return;
|
||
}
|
||
navigator.geolocation.getCurrentPosition(resolve, reject, {
|
||
enableHighAccuracy: false,
|
||
timeout: 8000,
|
||
maximumAge: 10 * 60 * 1000,
|
||
});
|
||
});
|
||
}
|
||
|
||
async function autoLocateAndLoad() {
|
||
try {
|
||
const pos = await getPosition();
|
||
const { latitude: lat, longitude: lon } = pos.coords;
|
||
const city = await buildDetectedCity(lat, lon);
|
||
searchInput.value = city.name;
|
||
await loadAll(city, 'auto-detect');
|
||
return true;
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// ── Search autocomplete ───────────────────────────────────────────────────────
|
||
let debounceTimer = null;
|
||
|
||
searchInput.addEventListener('input', () => {
|
||
clearTimeout(debounceTimer);
|
||
const q = searchInput.value.trim();
|
||
if (q.length < 2) { hide(suggestEl); return; }
|
||
|
||
debounceTimer = setTimeout(async () => {
|
||
try {
|
||
const data = await geocode(q);
|
||
const results = data.results || [];
|
||
suggestEl.innerHTML = '';
|
||
if (!results.length) { hide(suggestEl); return; }
|
||
|
||
results.forEach(r => {
|
||
const li = document.createElement('li');
|
||
const nameText = document.createTextNode(r.name);
|
||
const countrySpan = document.createElement('span');
|
||
countrySpan.className = 'sug-country';
|
||
countrySpan.textContent = `${r.admin1 ? r.admin1 + ', ' : ''}${r.country || ''}`;
|
||
li.appendChild(nameText);
|
||
li.appendChild(countrySpan);
|
||
li.addEventListener('click', () => {
|
||
searchInput.value = r.name;
|
||
hide(suggestEl);
|
||
loadAll(r, 'search-result');
|
||
});
|
||
suggestEl.appendChild(li);
|
||
});
|
||
show(suggestEl);
|
||
} catch (err) {
|
||
console.warn('Autocomplete geocode failed:', err?.message || err);
|
||
hide(suggestEl);
|
||
}
|
||
}, 300);
|
||
});
|
||
|
||
document.addEventListener('click', e => {
|
||
if (!e.target.closest('.search-wrap')) hide(suggestEl);
|
||
});
|
||
|
||
function doSearch() {
|
||
const q = searchInput.value.trim();
|
||
if (!q) { showErr('Please enter a city name.'); return; }
|
||
clearErr();
|
||
hide(suggestEl);
|
||
|
||
geocode(q).then(data => {
|
||
const results = data.results || [];
|
||
if (!results.length) { showErr(`No results found for "${q}".`); return; }
|
||
loadAll(results[0], 'search-result');
|
||
}).catch(() => showErr('Search failed. Please try again.'));
|
||
}
|
||
|
||
searchBtn.addEventListener('click', doSearch);
|
||
searchInput.addEventListener('keydown', e => { if (e.key === 'Enter') doSearch(); });
|
||
|
||
// ── Geolocation ───────────────────────────────────────────────────────────────
|
||
locateBtn.addEventListener('click', () => {
|
||
if (!navigator.geolocation) { showErr('Geolocation not supported.'); return; }
|
||
clearErr();
|
||
hide(weatherDiv);
|
||
show(loadingEl);
|
||
|
||
navigator.geolocation.getCurrentPosition(async pos => {
|
||
const { latitude: lat, longitude: lon } = pos.coords;
|
||
try {
|
||
const city = await buildDetectedCity(lat, lon);
|
||
await loadAll(city, 'gps-button');
|
||
} catch {
|
||
showErr('Could not fetch weather for your location.');
|
||
} finally {
|
||
hide(loadingEl);
|
||
}
|
||
}, () => {
|
||
hide(loadingEl);
|
||
showErr('Location access denied.');
|
||
});
|
||
});
|
||
|
||
unitsBtn.addEventListener('click', () => {
|
||
state.units = state.units === 'us' ? 'metric' : 'us';
|
||
localStorage.setItem(STORAGE_KEYS.units, state.units);
|
||
updateUnitsButton();
|
||
if (state.currentWeather && state.currentCity) {
|
||
renderWeather(state.currentWeather, state.currentCity);
|
||
renderAirQuality(state.currentAir);
|
||
// Update maps overlays if they're visible
|
||
if (state.showWind && mapsOverview.map) {
|
||
clearWindArrows();
|
||
renderWindArrows(state.currentCity);
|
||
}
|
||
}
|
||
});
|
||
|
||
if (unitsBtnSettings) {
|
||
unitsBtnSettings.addEventListener('click', () => unitsBtn.click());
|
||
}
|
||
|
||
shareBtn.addEventListener('click', async () => {
|
||
if (!state.currentCity) return;
|
||
try {
|
||
await navigator.clipboard.writeText(window.location.href);
|
||
shareBtn.textContent = 'Copied link';
|
||
setTimeout(() => { shareBtn.textContent = 'Share location'; }, 1500);
|
||
} catch {
|
||
showErr('Could not copy URL to clipboard.');
|
||
}
|
||
});
|
||
|
||
if (shareWeatherCardBtn) {
|
||
shareWeatherCardBtn.addEventListener('click', async () => {
|
||
if (!state.currentCity || !state.currentWeather) {
|
||
showErr('Load a location first to share a weather card.');
|
||
return;
|
||
}
|
||
|
||
const text = buildWeatherCardText();
|
||
const payload = {
|
||
title: `Weather card: ${cityLabel(state.currentCity) || 'Location'}`,
|
||
text,
|
||
url: window.location.href,
|
||
};
|
||
|
||
try {
|
||
if (navigator.share) {
|
||
await navigator.share(payload);
|
||
showShareWeatherStatus('Shared weather card');
|
||
return;
|
||
}
|
||
|
||
await navigator.clipboard.writeText(`${text}\n\n${window.location.href}`);
|
||
showShareWeatherStatus('Weather card copied');
|
||
} catch (err) {
|
||
if (err && err.name === 'AbortError') return;
|
||
showErr('Could not share weather card.');
|
||
}
|
||
});
|
||
}
|
||
|
||
if (shareHeroBtn) {
|
||
shareHeroBtn.addEventListener('click', async () => {
|
||
if (!state.currentCity || !state.currentWeather) {
|
||
showErr('Load a location first to share hero conditions.');
|
||
return;
|
||
}
|
||
|
||
const text = buildHeroCardText();
|
||
const title = `Now: ${cityLabel(state.currentCity) || 'Location'}`;
|
||
|
||
try {
|
||
const imageFile = await buildHeroCardImageFile();
|
||
|
||
if (navigator.share && imageFile) {
|
||
const filePayload = { title, text, files: [imageFile] };
|
||
if (!navigator.canShare || navigator.canShare({ files: [imageFile] })) {
|
||
await navigator.share(filePayload);
|
||
showHeroShareStatus('Shared hero image');
|
||
return;
|
||
}
|
||
}
|
||
|
||
if (navigator.share) {
|
||
await navigator.share({ title, text, url: window.location.href });
|
||
showHeroShareStatus('Shared hero card');
|
||
return;
|
||
}
|
||
|
||
await navigator.clipboard.writeText(`${text}\n\n${window.location.href}`);
|
||
showHeroShareStatus('Hero card copied');
|
||
} catch (err) {
|
||
if (err && err.name === 'AbortError') return;
|
||
showErr('Could not share hero card.');
|
||
}
|
||
});
|
||
}
|
||
|
||
saveCurrentBtn.addEventListener('click', () => {
|
||
if (!state.currentCity) return;
|
||
const key = cityKey(state.currentCity);
|
||
if (state.savedCities.some(c => cityKey(c) === 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));
|
||
});
|
||
|
||
if (lightningToggle) {
|
||
lightningToggle.addEventListener('change', () => {
|
||
state.showLightning = lightningToggle.checked;
|
||
localStorage.setItem(STORAGE_KEYS.showLightning, String(state.showLightning));
|
||
if (state.currentCity && mapsOverview.map) {
|
||
clearLightningMarkers();
|
||
if (state.showLightning) renderLightningStrikes(state.currentCity);
|
||
}
|
||
});
|
||
}
|
||
|
||
if (windToggle) {
|
||
windToggle.addEventListener('change', () => {
|
||
state.showWind = windToggle.checked;
|
||
localStorage.setItem(STORAGE_KEYS.showWind, String(state.showWind));
|
||
if (state.currentCity && mapsOverview.map) {
|
||
clearWindArrows();
|
||
if (state.showWind && state.currentWeather) renderWindArrows(state.currentCity);
|
||
}
|
||
});
|
||
}
|
||
|
||
// Maps tab quick toggles
|
||
const mapsLightningToggle = document.getElementById('maps-lightning-toggle');
|
||
const mapsWindToggle = document.getElementById('maps-wind-toggle');
|
||
|
||
if (mapsLightningToggle) {
|
||
mapsLightningToggle.addEventListener('click', () => {
|
||
state.showLightning = !state.showLightning;
|
||
localStorage.setItem(STORAGE_KEYS.showLightning, String(state.showLightning));
|
||
mapsLightningToggle.classList.toggle('active', state.showLightning);
|
||
lightningToggle.checked = state.showLightning;
|
||
if (state.currentCity && mapsOverview.map) {
|
||
clearLightningMarkers();
|
||
if (state.showLightning) renderLightningStrikes(state.currentCity);
|
||
}
|
||
});
|
||
// Set initial state
|
||
mapsLightningToggle.classList.toggle('active', state.showLightning);
|
||
}
|
||
|
||
if (mapsWindToggle) {
|
||
mapsWindToggle.addEventListener('click', () => {
|
||
state.showWind = !state.showWind;
|
||
localStorage.setItem(STORAGE_KEYS.showWind, String(state.showWind));
|
||
mapsWindToggle.classList.toggle('active', state.showWind);
|
||
windToggle.checked = state.showWind;
|
||
if (state.currentCity && mapsOverview.map) {
|
||
clearWindArrows();
|
||
if (state.showWind && state.currentWeather) renderWindArrows(state.currentCity);
|
||
}
|
||
});
|
||
// Set initial state
|
||
mapsWindToggle.classList.toggle('active', state.showWind);
|
||
}
|
||
|
||
const mapLayerStorageKey = {
|
||
radar: STORAGE_KEYS.showRadarLayer,
|
||
satellite: STORAGE_KEYS.showSatelliteLayer,
|
||
lightning: STORAGE_KEYS.showLightningLayer,
|
||
wind: STORAGE_KEYS.showWindLayer,
|
||
aqi: STORAGE_KEYS.showAqiLayer,
|
||
alerts: STORAGE_KEYS.showAlertsLayer,
|
||
temp: STORAGE_KEYS.showTempMarker,
|
||
'sun-moon': STORAGE_KEYS.showSunMoonMarkers,
|
||
hurricane: STORAGE_KEYS.showHurricaneLayer,
|
||
wildfire: STORAGE_KEYS.showWildfireLayer,
|
||
spc: STORAGE_KEYS.showSpcLayer,
|
||
};
|
||
|
||
function setRadarLayerVisible(visible) {
|
||
mapsOverview?.setRadarVisible?.(visible);
|
||
}
|
||
|
||
function setCloudLayerVisible(visible) {
|
||
mapsOverview?.setSatVisible?.(visible);
|
||
}
|
||
|
||
document.querySelectorAll('#tab-unified-map input[type="checkbox"][data-layer]').forEach(input => {
|
||
const layerName = input.dataset.layer;
|
||
input.checked = layerName === 'sun-moon'
|
||
? state.unifiedMapLayers.sunMoon
|
||
: !!state.unifiedMapLayers[layerName];
|
||
|
||
input.addEventListener('change', () => {
|
||
const enabled = input.checked;
|
||
if (layerName === 'sun-moon') {
|
||
state.unifiedMapLayers.sunMoon = enabled;
|
||
} else {
|
||
state.unifiedMapLayers[layerName] = enabled;
|
||
}
|
||
|
||
const storageKey = mapLayerStorageKey[layerName];
|
||
if (storageKey) localStorage.setItem(storageKey, String(enabled));
|
||
|
||
if (layerName === 'radar') {
|
||
setRadarLayerVisible(enabled);
|
||
if (!enabled && unifiedMap?.stopPlay) unifiedMap.stopPlay();
|
||
}
|
||
|
||
if (layerName === 'satellite') {
|
||
if (enabled) {
|
||
if (unifiedMapMode !== 'satellite') unifiedMapModeButton.click();
|
||
setCloudLayerVisible(true);
|
||
} else {
|
||
setCloudLayerVisible(false);
|
||
if (unifiedMapMode !== 'radar') unifiedMapModeButton.click();
|
||
}
|
||
}
|
||
|
||
if (state.currentCity) refreshMapsOverview();
|
||
});
|
||
});
|
||
|
||
const baseMapSelect = document.getElementById('base-map-select');
|
||
if (baseMapSelect) {
|
||
baseMapSelect.value = state.unifiedMapBaseLayer;
|
||
baseMapSelect.addEventListener('change', () => {
|
||
state.unifiedMapBaseLayer = baseMapSelect.value;
|
||
localStorage.setItem(STORAGE_KEYS.unifiedMapBaseLayer, state.unifiedMapBaseLayer);
|
||
if (state.currentCity) {
|
||
const city = state.currentCity;
|
||
const zoom = mapsOverview?.map?.getZoom?.() || 6;
|
||
if (mapsOverview?.map?.remove) mapsOverview.map.remove();
|
||
const replacement = createRadar(
|
||
document.getElementById('unified-map'),
|
||
document.getElementById('timeline-slider'),
|
||
document.getElementById('timeline-play'),
|
||
document.getElementById('timeline-timestamp'),
|
||
unifiedMapModeButton
|
||
);
|
||
Object.assign(mapsOverview, replacement);
|
||
mapsOverview.panTo(city.latitude, city.longitude, zoom);
|
||
refreshMapsOverview();
|
||
}
|
||
});
|
||
}
|
||
|
||
const layerMenuToggleBtn = document.getElementById('layer-menu-toggle');
|
||
const layerMenuPanel = document.getElementById('layer-menu-panel');
|
||
if (layerMenuToggleBtn && layerMenuPanel) {
|
||
layerMenuToggleBtn.addEventListener('click', () => {
|
||
const willShow = layerMenuPanel.classList.contains('hidden');
|
||
layerMenuPanel.classList.toggle('hidden', !willShow);
|
||
layerMenuToggleBtn.textContent = willShow ? 'Hide Layer Menu' : 'Show Layer Menu';
|
||
layerMenuToggleBtn.setAttribute('aria-expanded', willShow ? 'true' : 'false');
|
||
});
|
||
}
|
||
|
||
if (dailyToggleBtn) {
|
||
dailyToggleBtn.addEventListener('click', () => {
|
||
state.showExtendedForecast = !state.showExtendedForecast;
|
||
if (state.currentWeather) {
|
||
renderDailyForecast(state.currentWeather.daily);
|
||
}
|
||
});
|
||
}
|
||
|
||
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');
|
||
}
|
||
});
|
||
}
|
||
|
||
// ── ntfy push notifications ───────────────────────────────────────────────────
|
||
// ── NWS Forecast Discussion ────────────────────────────────────────────────────
|
||
async function fetchDiscussion(lat, lon) {
|
||
const drawer = document.getElementById('discussion-drawer');
|
||
const textEl = document.getElementById('discussion-text');
|
||
const metaEl = document.getElementById('discussion-meta');
|
||
const officeEl = document.getElementById('discussion-office');
|
||
if (!drawer || !textEl) return;
|
||
|
||
try {
|
||
const res = await fetch(`/api/nws/discussion?lat=${lat}&lon=${lon}`);
|
||
if (!res.ok) {
|
||
drawer.hidden = true;
|
||
return;
|
||
}
|
||
const data = await res.json();
|
||
if (!data.text) { drawer.hidden = true; return; }
|
||
|
||
drawer.hidden = false;
|
||
if (officeEl) officeEl.textContent = `· NWS ${data.issuingOffice || data.office}`;
|
||
if (metaEl) {
|
||
const issued = data.issuanceTime ? new Date(data.issuanceTime).toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }) : '';
|
||
metaEl.textContent = issued ? `Issued ${issued}` : '';
|
||
}
|
||
// Strip the WMO header block (lines before the first blank line + "AREA FORECAST DISCUSSION")
|
||
const cleaned = data.text
|
||
.replace(/^[\s\S]*?(?=\.[\w\s]+\.\.\.)/m, '') // strip WMO preamble up to first section
|
||
.trim();
|
||
textEl.textContent = cleaned || data.text.trim();
|
||
} catch {
|
||
if (drawer) drawer.hidden = true;
|
||
}
|
||
}
|
||
|
||
async function sendNtfyNotification(title, body, { priority = 'default', tags = [] } = {}) {
|
||
const topic = state.ntfyTopic;
|
||
if (!topic) return false;
|
||
try {
|
||
const r = await fetch(topic, {
|
||
method: 'POST',
|
||
body,
|
||
headers: {
|
||
Title: title,
|
||
Priority: priority,
|
||
Tags: Array.isArray(tags) ? tags.join(',') : tags,
|
||
},
|
||
});
|
||
return r.ok;
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// ── ntfy + PWA install wiring ─────────────────────────────────────────────────
|
||
const ntfyUrlInput = document.getElementById('ntfy-url-input');
|
||
const ntfyTestBtn = document.getElementById('ntfy-test-btn');
|
||
const ntfyStatusEl = document.getElementById('ntfy-status');
|
||
|
||
if (ntfyUrlInput) {
|
||
ntfyUrlInput.value = state.ntfyTopic;
|
||
ntfyUrlInput.addEventListener('change', () => {
|
||
state.ntfyTopic = ntfyUrlInput.value.trim();
|
||
localStorage.setItem(STORAGE_KEYS.ntfyTopic, state.ntfyTopic);
|
||
});
|
||
}
|
||
|
||
if (ntfyTestBtn) {
|
||
ntfyTestBtn.addEventListener('click', async () => {
|
||
const topic = (ntfyUrlInput?.value?.trim()) || state.ntfyTopic;
|
||
if (!topic) {
|
||
ntfyStatusEl.textContent = 'Enter a topic URL first.';
|
||
show(ntfyStatusEl);
|
||
return;
|
||
}
|
||
state.ntfyTopic = topic;
|
||
localStorage.setItem(STORAGE_KEYS.ntfyTopic, topic);
|
||
ntfyTestBtn.disabled = true;
|
||
ntfyTestBtn.textContent = 'Sending…';
|
||
const ok = await sendNtfyNotification(
|
||
'NeCloud Weather Test',
|
||
'Notifications are working! Weather alerts will be sent here.',
|
||
{ tags: ['white_check_mark'] }
|
||
);
|
||
ntfyTestBtn.disabled = false;
|
||
ntfyTestBtn.textContent = 'Test';
|
||
ntfyStatusEl.textContent = ok ? '✓ Test sent!' : '✗ Failed — check the topic URL.';
|
||
show(ntfyStatusEl);
|
||
});
|
||
}
|
||
|
||
let deferredInstallPrompt = null;
|
||
window.addEventListener('beforeinstallprompt', e => {
|
||
e.preventDefault();
|
||
deferredInstallPrompt = e;
|
||
const row = document.getElementById('pwa-install-row');
|
||
if (row) row.style.display = '';
|
||
});
|
||
|
||
window.addEventListener('appinstalled', () => {
|
||
deferredInstallPrompt = null;
|
||
const row = document.getElementById('pwa-install-row');
|
||
if (row) row.style.display = 'none';
|
||
});
|
||
|
||
const pwaInstallBtn = document.getElementById('pwa-install-btn');
|
||
if (pwaInstallBtn) {
|
||
pwaInstallBtn.addEventListener('click', async () => {
|
||
if (!deferredInstallPrompt) return;
|
||
deferredInstallPrompt.prompt();
|
||
const { outcome } = await deferredInstallPrompt.userChoice;
|
||
if (outcome === 'accepted') deferredInstallPrompt = null;
|
||
});
|
||
}
|
||
|
||
// ── Offline / online banner ────────────────────────────────────────────────────
|
||
const offlineBanner = document.getElementById('offline-banner');
|
||
function syncOfflineState() {
|
||
if (offlineBanner) offlineBanner.classList.toggle('hidden', navigator.onLine);
|
||
}
|
||
window.addEventListener('online', syncOfflineState);
|
||
window.addEventListener('offline', syncOfflineState);
|
||
syncOfflineState();
|
||
|
||
// ── Pull-to-refresh ────────────────────────────────────────────────────────────
|
||
(function initPTR() {
|
||
const ptrEl = document.getElementById('ptr-indicator');
|
||
if (!ptrEl) return;
|
||
let startY = 0;
|
||
let pulling = false;
|
||
document.addEventListener('touchstart', e => { startY = e.touches[0].clientY; pulling = window.scrollY === 0; }, { passive: true });
|
||
document.addEventListener('touchend', e => {
|
||
if (!pulling) return;
|
||
const dy = e.changedTouches[0].clientY - startY;
|
||
if (dy > 80 && state.currentCity) {
|
||
ptrEl.classList.remove('hidden');
|
||
ptrEl.classList.add('visible');
|
||
loadAll(state.currentCity, state.currentSource || 'pull-to-refresh').finally(() => {
|
||
ptrEl.classList.remove('visible');
|
||
setTimeout(() => ptrEl.classList.add('hidden'), 300);
|
||
});
|
||
}
|
||
pulling = false;
|
||
}, { passive: true });
|
||
})();
|
||
|
||
// ── Swipe gestures for tab switching ──────────────────────────────────────────
|
||
(function initSwipe() {
|
||
const TABS = ['conditions', 'unified-map', 'alerts', 'learn', 'settings'];
|
||
let touchX = 0;
|
||
let touchY = 0;
|
||
let blocked = false;
|
||
|
||
function isSwipeBlocked(target) {
|
||
// Don't intercept touches on maps (panning) or horizontal scroll containers.
|
||
return !!(
|
||
target.closest('.maplibregl-map') ||
|
||
target.closest('#hourly-scroll') ||
|
||
target.closest('.hourly-track') ||
|
||
target.closest('.precip-chart-wrap') ||
|
||
target.closest('.radar-timeline')
|
||
);
|
||
}
|
||
|
||
document.addEventListener('touchstart', e => {
|
||
touchX = e.touches[0].clientX;
|
||
touchY = e.touches[0].clientY;
|
||
blocked = isSwipeBlocked(e.target);
|
||
}, { passive: true });
|
||
|
||
document.addEventListener('touchend', e => {
|
||
if (blocked) return;
|
||
const dx = e.changedTouches[0].clientX - touchX;
|
||
const dy = e.changedTouches[0].clientY - touchY;
|
||
// Require the gesture to be more horizontal than vertical, and meet the distance threshold.
|
||
if (Math.abs(dx) < 60 || Math.abs(dx) < Math.abs(dy) * 1.5) return;
|
||
const cur = TABS.findIndex(t => document.querySelector(`.tab[data-tab="${t}"]`)?.classList.contains('active'));
|
||
if (cur < 0) return;
|
||
const next = dx < 0 ? Math.min(cur + 1, TABS.length - 1) : Math.max(cur - 1, 0);
|
||
if (next !== cur) activateTab(TABS[next]);
|
||
}, { passive: true });
|
||
})();
|
||
|
||
// ── Theme toggle buttons ───────────────────────────────────────────────────────
|
||
document.querySelectorAll('.theme-btn').forEach(btn => {
|
||
btn.addEventListener('click', () => applyTheme(btn.dataset.theme));
|
||
});
|
||
|
||
// ── Hourly toggles ────────────────────────────────────────────────────────────
|
||
const hourlyFeelsToggle = document.getElementById('hourly-feels-toggle');
|
||
const hourlyExtendBtn = document.getElementById('hourly-extend-btn');
|
||
if (hourlyFeelsToggle) {
|
||
hourlyFeelsToggle.addEventListener('click', () => {
|
||
state.hourlyFeelsLike = !state.hourlyFeelsLike;
|
||
hourlyFeelsToggle.textContent = state.hourlyFeelsLike ? 'Show temp' : 'Feels-like';
|
||
if (state.currentWeather && state.currentCity) renderWeather(state.currentWeather, state.currentCity);
|
||
});
|
||
}
|
||
if (hourlyExtendBtn) {
|
||
hourlyExtendBtn.addEventListener('click', () => {
|
||
state.hourlyExtended = !state.hourlyExtended;
|
||
hourlyExtendBtn.textContent = state.hourlyExtended ? '24h' : '48h';
|
||
if (state.currentWeather && state.currentCity) renderWeather(state.currentWeather, state.currentCity);
|
||
});
|
||
}
|
||
|
||
// ── VAPID push subscribe ───────────────────────────────────────────────────────
|
||
const pushSubscribeBtn = document.getElementById('push-subscribe-btn');
|
||
if (pushSubscribeBtn) pushSubscribeBtn.addEventListener('click', subscribePush);
|
||
|
||
// ── Alert threshold inputs ─────────────────────────────────────────────────────
|
||
function persistThresholds() {
|
||
localStorage.setItem(STORAGE_KEYS.alertThresholds, JSON.stringify(state.alertThresholds));
|
||
}
|
||
const threshWind = document.getElementById('threshold-wind');
|
||
const threshTempLow = document.getElementById('threshold-temp-low');
|
||
const threshAqi = document.getElementById('threshold-aqi');
|
||
if (threshWind) {
|
||
threshWind.value = state.alertThresholds.wind || '';
|
||
threshWind.addEventListener('change', () => {
|
||
const v = parseFloat(threshWind.value);
|
||
if (!isNaN(v)) state.alertThresholds.wind = v; else delete state.alertThresholds.wind;
|
||
persistThresholds();
|
||
});
|
||
}
|
||
if (threshTempLow) {
|
||
threshTempLow.value = state.alertThresholds.tempLow ?? '';
|
||
threshTempLow.addEventListener('change', () => {
|
||
const v = parseFloat(threshTempLow.value);
|
||
if (!isNaN(v)) state.alertThresholds.tempLow = v; else delete state.alertThresholds.tempLow;
|
||
persistThresholds();
|
||
});
|
||
}
|
||
if (threshAqi) {
|
||
threshAqi.value = state.alertThresholds.aqi || '';
|
||
threshAqi.addEventListener('change', () => {
|
||
const v = parseFloat(threshAqi.value);
|
||
if (!isNaN(v)) state.alertThresholds.aqi = v; else delete state.alertThresholds.aqi;
|
||
persistThresholds();
|
||
});
|
||
}
|
||
|
||
// ── Digest hour ────────────────────────────────────────────────────────────────
|
||
const digestHourInput = document.getElementById('digest-hour');
|
||
if (digestHourInput) {
|
||
digestHourInput.value = state.digestHour;
|
||
digestHourInput.addEventListener('change', () => {
|
||
const v = parseInt(digestHourInput.value, 10);
|
||
if (!isNaN(v) && v >= 0 && v <= 23) {
|
||
state.digestHour = v;
|
||
localStorage.setItem(STORAGE_KEYS.digestHour, v);
|
||
}
|
||
});
|
||
}
|
||
|
||
// ── PWS URL ────────────────────────────────────────────────────────────────────
|
||
// ── Ambient Weather key settings ──────────────────────────────────────────────
|
||
const ambientApiKeyInput = document.getElementById('ambient-api-key-input');
|
||
const ambientAppKeyInput = document.getElementById('ambient-app-key-input');
|
||
const ambientSaveBtn = document.getElementById('ambient-save-btn');
|
||
const ambientStatusEl = document.getElementById('ambient-status');
|
||
if (ambientApiKeyInput) ambientApiKeyInput.value = state.ambientApiKey;
|
||
if (ambientAppKeyInput) ambientAppKeyInput.value = state.ambientAppKey;
|
||
if (ambientSaveBtn) {
|
||
ambientSaveBtn.addEventListener('click', async () => {
|
||
const apiKey = ambientApiKeyInput?.value?.trim();
|
||
const appKey = ambientAppKeyInput?.value?.trim();
|
||
if (!apiKey || !appKey) {
|
||
if (ambientStatusEl) { ambientStatusEl.textContent = 'Both keys are required.'; show(ambientStatusEl); }
|
||
return;
|
||
}
|
||
ambientSaveBtn.disabled = true;
|
||
try {
|
||
const res = await fetch(`/api/ambient?apiKey=${encodeURIComponent(apiKey)}&appKey=${encodeURIComponent(appKey)}`);
|
||
if (res.ok) {
|
||
const devices = await res.json();
|
||
state.ambientApiKey = apiKey;
|
||
state.ambientAppKey = appKey;
|
||
localStorage.setItem(STORAGE_KEYS.ambientApiKey, apiKey);
|
||
localStorage.setItem(STORAGE_KEYS.ambientAppKey, appKey);
|
||
// Also persist keys server-side so all devices share them
|
||
fetch('/api/ambient-config', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ apiKey, appKey }),
|
||
}).catch(() => {});
|
||
const stationName = devices?.[0]?.info?.name || devices?.[0]?.macAddress || 'station';
|
||
if (ambientStatusEl) { ambientStatusEl.textContent = `✓ Connected to ${stationName} — saved for all devices`; show(ambientStatusEl); }
|
||
const useRow = document.getElementById('ambient-use-row');
|
||
if (useRow) useRow.style.display = '';
|
||
fetchAmbientStation();
|
||
} else {
|
||
const { error } = await res.json().catch(() => ({}));
|
||
if (ambientStatusEl) { ambientStatusEl.textContent = `✗ ${error || 'Connection failed'}`; show(ambientStatusEl); }
|
||
}
|
||
} catch {
|
||
if (ambientStatusEl) { ambientStatusEl.textContent = '✗ Request failed.'; show(ambientStatusEl); }
|
||
}
|
||
ambientSaveBtn.disabled = false;
|
||
});
|
||
}
|
||
|
||
// On load, check if server already has keys configured (other device saved them)
|
||
fetch('/api/ambient-config').then(r => r.json()).then(({ configured }) => {
|
||
if (configured && !state.ambientApiKey) {
|
||
if (ambientStatusEl) { ambientStatusEl.textContent = '✓ Keys configured on server'; show(ambientStatusEl); }
|
||
fetchAmbientStation();
|
||
}
|
||
if (configured || state.ambientApiKey) {
|
||
const useRow = document.getElementById('ambient-use-row');
|
||
if (useRow) useRow.style.display = '';
|
||
}
|
||
}).catch(() => {});
|
||
|
||
// ── Ambient "use as primary source" toggle ────────────────────────────────────
|
||
const ambientUseToggle = document.getElementById('ambient-use-toggle');
|
||
if (ambientUseToggle) {
|
||
ambientUseToggle.checked = state.ambientUseStation;
|
||
ambientUseToggle.addEventListener('change', () => {
|
||
state.ambientUseStation = ambientUseToggle.checked;
|
||
localStorage.setItem(STORAGE_KEYS.ambientUseStation, state.ambientUseStation);
|
||
if (state.ambientUseStation) {
|
||
// Re-apply station overlay immediately
|
||
if (state.ambientDevice) applyStationOverlay(state.ambientDevice);
|
||
} else {
|
||
// Restore location name from current city
|
||
if (state.currentCity) {
|
||
const locNameEl = document.getElementById('loc-name');
|
||
const locSubEl = document.getElementById('loc-sub');
|
||
if (locNameEl) locNameEl.textContent = state.currentCity.name;
|
||
if (locSubEl) locSubEl.textContent = [state.currentCity.admin1, state.currentCity.country].filter(Boolean).join(', ');
|
||
}
|
||
// Show station badge so user knows station is still fetching but not overriding
|
||
if (state.ambientDevice) {
|
||
const badge = document.getElementById('station-badge');
|
||
const badgeName = document.getElementById('station-badge-name');
|
||
if (badge) { if (badgeName) badgeName.textContent = state.ambientDevice.info?.name || 'My Station'; show(badge); }
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
// Show toggle row if keys are already saved locally
|
||
if (state.ambientApiKey && state.ambientAppKey) {
|
||
const useRow = document.getElementById('ambient-use-row');
|
||
if (useRow) useRow.style.display = '';
|
||
}
|
||
|
||
const pwsUrlInput = document.getElementById('pws-url-input');
|
||
const pwsSaveBtn = document.getElementById('pws-save-btn');
|
||
const pwsStatusEl = document.getElementById('pws-status');
|
||
if (pwsUrlInput) pwsUrlInput.value = state.pwsUrl;
|
||
if (pwsSaveBtn) {
|
||
pwsSaveBtn.addEventListener('click', async () => {
|
||
const url = pwsUrlInput?.value?.trim();
|
||
if (!url) return;
|
||
pwsSaveBtn.disabled = true;
|
||
try {
|
||
const res = await fetch(`/api/pws?url=${encodeURIComponent(url)}`);
|
||
if (res.ok) {
|
||
state.pwsUrl = url;
|
||
localStorage.setItem(STORAGE_KEYS.pwsUrl, url);
|
||
if (pwsStatusEl) { pwsStatusEl.textContent = '✓ PWS connected!'; show(pwsStatusEl); }
|
||
} else {
|
||
if (pwsStatusEl) { pwsStatusEl.textContent = '✗ Could not reach that URL.'; show(pwsStatusEl); }
|
||
}
|
||
} catch {
|
||
if (pwsStatusEl) { pwsStatusEl.textContent = '✗ Request failed.'; show(pwsStatusEl); }
|
||
}
|
||
pwsSaveBtn.disabled = false;
|
||
});
|
||
}
|
||
|
||
// ── Hurricane/wildfire/SPC layer toggle callbacks ─────────────────────────────
|
||
// (handled inside the existing querySelectorAll loop via mapLayerStorageKey)
|
||
|
||
// ── Default city / query link load ───────────────────────────────────────────
|
||
(async () => {
|
||
applyTheme(state.theme);
|
||
updateUnitsButton();
|
||
updateNotifyButton();
|
||
syncAutoDetectToggle();
|
||
|
||
// Sync lightning and wind toggle states
|
||
if (lightningToggle) lightningToggle.checked = state.showLightning;
|
||
if (windToggle) windToggle.checked = state.showWind;
|
||
|
||
renderSavedCities();
|
||
startAutoRefresh();
|
||
|
||
const timelineControls = document.getElementById('timeline-controls');
|
||
if (timelineControls && unifiedMapEl) {
|
||
timelineControls.classList.remove('hidden');
|
||
}
|
||
|
||
const qs = new URLSearchParams(window.location.search);
|
||
|
||
// Handle share target
|
||
if (qs.has('share')) {
|
||
const sharedUrl = qs.get('url') || qs.get('text') || '';
|
||
const sharedTitle = qs.get('title') || '';
|
||
if (sharedTitle || sharedUrl) {
|
||
searchInput.value = sharedTitle || sharedUrl;
|
||
const results = await geocode(searchInput.value).catch(() => null);
|
||
const city = results?.results?.[0];
|
||
if (city) { await loadAll(city, 'share-target'); return; }
|
||
}
|
||
}
|
||
|
||
// Handle shortcut / deep-link tab routing.
|
||
const qTab = qs.get('tab');
|
||
if (qTab) activateTab(qTab);
|
||
|
||
const qLat = Number(qs.get('lat'));
|
||
const qLon = Number(qs.get('lon'));
|
||
const qName = qs.get('name');
|
||
if (!Number.isNaN(qLat) && !Number.isNaN(qLon) && qs.get('lat') && qs.get('lon')) {
|
||
const city = {
|
||
name: qName || `${qLat.toFixed(2)}, ${qLon.toFixed(2)}`,
|
||
latitude: qLat,
|
||
longitude: qLon,
|
||
timezone: 'auto',
|
||
country: '',
|
||
admin1: ''
|
||
};
|
||
searchInput.value = city.name;
|
||
await loadAll(city, 'shared-link');
|
||
return;
|
||
}
|
||
|
||
if (state.autoDetect) {
|
||
const autoLocated = await autoLocateAndLoad();
|
||
if (autoLocated) return;
|
||
}
|
||
|
||
const lastCity = getLastCity();
|
||
if (lastCity) {
|
||
searchInput.value = lastCity.name;
|
||
await loadAll(lastCity, 'last-city');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const data = await geocode('New York');
|
||
const city = (data.results || [])[0];
|
||
if (city) {
|
||
searchInput.value = city.name;
|
||
await loadAll(city, 'default-city');
|
||
}
|
||
} catch {
|
||
// silent startup fallback
|
||
}
|
||
})();
|
||
|
||
// ── Ambient Weather personal station ─────────────────────────────────────────
|
||
let stationRefreshTimer = null;
|
||
|
||
async function fetchAmbientStation() {
|
||
const loadingEl = document.getElementById('station-loading');
|
||
const contentEl = document.getElementById('station-content');
|
||
const errorEl = document.getElementById('station-error');
|
||
const peekEl = document.getElementById('station-drawer-peek');
|
||
|
||
const apiKey = state.ambientApiKey;
|
||
const appKey = state.ambientAppKey;
|
||
const params = new URLSearchParams();
|
||
if (apiKey) params.set('apiKey', apiKey);
|
||
if (appKey) params.set('appKey', appKey);
|
||
const qs = params.toString() ? `?${params}` : '';
|
||
|
||
try {
|
||
const r = await fetch(`/api/ambient${qs}`);
|
||
if (!r.ok) {
|
||
const { error } = await r.json().catch(() => ({}));
|
||
if (errorEl) { errorEl.textContent = error || 'Station unavailable'; show(errorEl); }
|
||
if (loadingEl) hide(loadingEl);
|
||
return;
|
||
}
|
||
const devices = await r.json();
|
||
if (!Array.isArray(devices) || !devices.length) {
|
||
if (errorEl) { errorEl.textContent = 'No devices found on this account.'; show(errorEl); }
|
||
if (loadingEl) hide(loadingEl);
|
||
return;
|
||
}
|
||
if (loadingEl) hide(loadingEl);
|
||
if (errorEl) hide(errorEl);
|
||
renderAmbientStation(devices[0]);
|
||
|
||
// Auto-refresh every 60 s
|
||
clearInterval(stationRefreshTimer);
|
||
stationRefreshTimer = setInterval(async () => {
|
||
try {
|
||
const p2 = new URLSearchParams();
|
||
if (state.ambientApiKey) p2.set('apiKey', state.ambientApiKey);
|
||
if (state.ambientAppKey) p2.set('appKey', state.ambientAppKey);
|
||
const q2 = p2.toString() ? `?${p2}` : '';
|
||
const r2 = await fetch(`/api/ambient${q2}`);
|
||
if (r2.ok) {
|
||
const d2 = await r2.json();
|
||
if (Array.isArray(d2) && d2.length) renderAmbientStation(d2[0]);
|
||
}
|
||
} catch {}
|
||
}, 60000);
|
||
} catch (e) {
|
||
if (errorEl) { errorEl.textContent = 'Could not reach station.'; show(errorEl); }
|
||
if (loadingEl) hide(loadingEl);
|
||
}
|
||
}
|
||
|
||
function applyStationOverlay(device) {
|
||
if (!device) return;
|
||
if (!state.ambientUseStation) return;
|
||
const data = device.lastData || {};
|
||
if (!data.dateutc || Date.now() - data.dateutc > 15 * 60 * 1000) return; // stale > 15 min
|
||
|
||
const isMetric = state.units !== 'us';
|
||
const fT = f => f == null ? null : (isMetric ? Math.round((f - 32) * 5 / 9) : Math.round(f));
|
||
const fS = mph => mph == null ? null : (isMetric ? Math.round(mph * 1.60934) : Math.round(mph));
|
||
|
||
const setEl = (id, val) => { if (val == null) return; const el = document.getElementById(id); if (el) el.textContent = val; };
|
||
|
||
if (data.tempf != null) {
|
||
setEl('cur-temp', fT(data.tempf));
|
||
const panelTemp = document.getElementById('panel-temp');
|
||
if (panelTemp) panelTemp.textContent = `${fT(data.tempf)}°${tempUnit()}`;
|
||
}
|
||
|
||
if (data.feelsLike != null) {
|
||
setEl('cur-feels', fT(data.feelsLike));
|
||
const feelsLine = document.getElementById('feels-line');
|
||
if (feelsLine) {
|
||
const label = apparentLabel(data.tempf, data.feelsLike);
|
||
feelsLine.innerHTML = `${label} <span id="cur-feels">${fT(data.feelsLike)}</span>°${tempUnit()}`;
|
||
}
|
||
}
|
||
|
||
if (data.humidity != null) {
|
||
setEl('cur-hum', data.humidity);
|
||
setEl('hum-feel', humidityFeel(data.humidity));
|
||
}
|
||
|
||
if (data.tempf != null && data.humidity != null) {
|
||
const tc = (data.tempf - 32) * 5 / 9;
|
||
const alpha = Math.log(data.humidity / 100) + (17.62 * tc) / (243.12 + tc);
|
||
const dewC = (243.12 * alpha) / (17.62 - alpha);
|
||
setEl('cur-dew', isMetric ? Math.round(dewC) : Math.round(dewC * 9 / 5 + 32));
|
||
}
|
||
|
||
if (data.windspeedmph != null) {
|
||
setEl('cur-wind', fS(data.windspeedmph));
|
||
const panelWind = document.getElementById('panel-wind-speed');
|
||
if (panelWind) panelWind.textContent = `${fS(data.windspeedmph)} ${speedUnit()}`;
|
||
}
|
||
if (data.winddir != null) {
|
||
setEl('cur-wind-dir', compassDirFull(data.winddir));
|
||
const panelDir = document.getElementById('panel-wind-dir');
|
||
if (panelDir) panelDir.textContent = compassDirFull(data.winddir);
|
||
}
|
||
if (data.windgustmph != null) setEl('cur-gust', fS(data.windgustmph));
|
||
|
||
// Pressure: Ambient gives inHg, display expects hPa
|
||
if (data.baromrelin != null) setEl('cur-pres', Math.round(data.baromrelin * 33.8639));
|
||
|
||
if (data.uv != null) setEl('cur-uv', data.uv);
|
||
|
||
// Rain summary in hero sub-line
|
||
const rainEl = document.getElementById('cur-precip');
|
||
if (rainEl && data.dailyrainin != null) {
|
||
const rainVal = isMetric ? (data.dailyrainin * 25.4).toFixed(1) : data.dailyrainin.toFixed(2);
|
||
const rainUnit2 = isMetric ? 'mm' : 'in';
|
||
rainEl.textContent = data.dailyrainin > 0
|
||
? `${rainVal} ${rainUnit2} rain today`
|
||
: 'No rain today';
|
||
}
|
||
|
||
// Wind peek summary
|
||
const peekEl = document.getElementById('wind-drawer-peek');
|
||
if (peekEl && data.windspeedmph != null) {
|
||
peekEl.textContent = `· ${fS(data.windspeedmph)} ${speedUnit()} ${compassDir(data.winddir || 0)} · ${data.humidity ?? '--'}% humidity`;
|
||
}
|
||
|
||
// Replace location name with station name
|
||
const stationName = device.info?.name || device.macAddress || 'My Station';
|
||
const locNameEl = document.getElementById('loc-name');
|
||
const locSubEl = document.getElementById('loc-sub');
|
||
if (locNameEl) locNameEl.textContent = stationName;
|
||
if (locSubEl) locSubEl.textContent = 'Ambient Weather Station';
|
||
|
||
// Hide the hero badge (redundant — location header now identifies the station)
|
||
const badge = document.getElementById('station-badge');
|
||
if (badge) hide(badge);
|
||
}
|
||
|
||
function renderAmbientStation(device) {
|
||
state.ambientDevice = device;
|
||
const data = device?.lastData || {};
|
||
const info = device?.info || {};
|
||
const contentEl = document.getElementById('station-content');
|
||
const peekEl = document.getElementById('station-drawer-peek');
|
||
|
||
const stName = info.name || device?.macAddress || 'My Station';
|
||
const nameEl = document.getElementById('station-name');
|
||
if (nameEl) nameEl.textContent = stName;
|
||
|
||
const updatedEl = document.getElementById('station-updated');
|
||
if (updatedEl && data.dateutc) {
|
||
const ago = Math.round((Date.now() - data.dateutc) / 60000);
|
||
updatedEl.textContent = ago <= 1 ? 'just now' : `${ago} min ago`;
|
||
}
|
||
|
||
const isMetric = state.units !== 'us';
|
||
|
||
function fmtTemp(f) {
|
||
if (f == null) return '--';
|
||
return isMetric ? Math.round((f - 32) * 5 / 9) : Math.round(f);
|
||
}
|
||
function fmtSpeed(mph) {
|
||
if (mph == null) return '--';
|
||
return isMetric ? Math.round(mph * 1.60934) : Math.round(mph);
|
||
}
|
||
function fmtRain(inches) {
|
||
if (inches == null) return '--';
|
||
return isMetric ? (inches * 25.4).toFixed(1) : inches.toFixed(2);
|
||
}
|
||
|
||
const tempUnit = isMetric ? 'C' : 'F';
|
||
const speedUnit = isMetric ? 'km/h' : 'mph';
|
||
const rainUnit = isMetric ? 'mm' : 'in';
|
||
|
||
const setEl = (id, val) => { const el = document.getElementById(id); if (el) el.textContent = val; };
|
||
|
||
setEl('st-temp', fmtTemp(data.tempf));
|
||
setEl('st-temp-unit', tempUnit);
|
||
setEl('st-feels', data.feelsLike != null ? `Feels ${fmtTemp(data.feelsLike)}°` : '');
|
||
setEl('st-hum', data.humidity != null ? `${data.humidity}` : '--');
|
||
|
||
if (data.tempf != null && data.humidity != null) {
|
||
const tc = (data.tempf - 32) * 5 / 9;
|
||
const alpha = Math.log(data.humidity / 100) + (17.62 * tc) / (243.12 + tc);
|
||
const dewC = (243.12 * alpha) / (17.62 - alpha);
|
||
const dewDisplay = isMetric ? Math.round(dewC) : Math.round(dewC * 9 / 5 + 32);
|
||
setEl('st-dew', `Dew ${dewDisplay}°`);
|
||
}
|
||
|
||
setEl('st-wind', fmtSpeed(data.windspeedmph));
|
||
setEl('st-wind-unit', speedUnit);
|
||
setEl('st-wind-dir', data.winddir != null ? compassDirFull(data.winddir) : '');
|
||
setEl('st-gust', fmtSpeed(data.windgustmph));
|
||
setEl('st-gust-unit', speedUnit);
|
||
setEl('st-rain-day', fmtRain(data.dailyrainin));
|
||
setEl('st-rain-unit', rainUnit);
|
||
const rrDisplay = data.hourlyrainin != null ? `${fmtRain(data.hourlyrainin)} ${rainUnit}/hr` : '';
|
||
setEl('st-rain-rate', rrDisplay);
|
||
setEl('st-pressure', data.baromrelin != null ? data.baromrelin.toFixed(2) : '--');
|
||
setEl('st-uv', data.uv != null ? data.uv : '--');
|
||
setEl('st-solar', data.solarradiation != null ? Math.round(data.solarradiation) : '--');
|
||
|
||
const indoorCard = document.getElementById('st-indoor-card');
|
||
if (data.tempinf != null) {
|
||
setEl('st-tempin', fmtTemp(data.tempinf));
|
||
setEl('st-humin', data.humidityin != null ? `${data.humidityin}% RH` : '');
|
||
if (indoorCard) indoorCard.classList.remove('hidden');
|
||
}
|
||
|
||
if (peekEl && data.tempf != null) {
|
||
peekEl.textContent = `· ${fmtTemp(data.tempf)}°${tempUnit} ${data.humidity != null ? data.humidity + '% RH' : ''}`;
|
||
}
|
||
|
||
if (contentEl) show(contentEl);
|
||
|
||
// Update the main conditions display with real station readings
|
||
if (state.currentWeather) applyStationOverlay(device);
|
||
}
|