- One slate/blue palette across the app, dashboard, account/login pages (data colours stay orange = warm, blue = cool on the dashboard) - Slim sticky top bar with location chip, gear (settings) and ? (learn); 4-tab nav (Now, Station, Map, Alerts) with a thumb-reach bottom bar on phones - Hero sky gradient by condition/time with rain, storm, snow and star effects (respects reduced motion); keeps the searched city and badges when temps come from your station - One-line summary, 'Next 2 hours' bars and rain countdown from 15-minute precipitation, rain/cold-aware 'Bring' chips; yesterday + 'vs usual' context from the station - Scrubbable hourly chart (temp, rain chance, wind), 7-day range bars, AQI gauge, details tiles instead of drawers, taller radar with location marker + alert polygons - Skeleton loading instead of a full-screen spinner, sticky section headers, safe areas - Server: minutely_15 precipitation, public /api/station/summary Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
3114 lines
119 KiB
JavaScript
3114 lines
119 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;
|
||
}
|
||
|
||
let hurricaneStorms = [];
|
||
|
||
function renderHurricanePanel(storms) {
|
||
const panel = document.getElementById('hurricane-panel');
|
||
if (!panel) return;
|
||
if (!storms || !storms.length) { panel.innerHTML = ''; return; }
|
||
hurricaneStorms = storms;
|
||
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" style="cursor:pointer" title="Click to view on map">
|
||
<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>` : ''}
|
||
<span style="color:var(--accent,#4da6ff);font-size:0.75em">🗺 View on map</span>
|
||
</div>
|
||
</div>
|
||
`}).join('');
|
||
panel.querySelectorAll('.hurricane-storm-card').forEach((card, i) => {
|
||
card.addEventListener('click', () => flyToHurricane(hurricaneStorms[i]));
|
||
});
|
||
}
|
||
|
||
async function flyToHurricane(storm) {
|
||
const lat = parseNhcCoord(storm.latitude);
|
||
const lon = parseNhcCoord(storm.longitude);
|
||
if (isNaN(lat) || isNaN(lon)) return;
|
||
|
||
activateTab('unified-map');
|
||
|
||
if (!state.unifiedMapLayers.hurricane) {
|
||
state.unifiedMapLayers.hurricane = true;
|
||
const cb = document.querySelector('#tab-unified-map input[data-layer="hurricane"]');
|
||
if (cb) cb.checked = true;
|
||
}
|
||
|
||
await new Promise(r => setTimeout(r, 450));
|
||
|
||
const map = mapsOverview.map;
|
||
if (!map) return;
|
||
if (!map.loaded()) await new Promise(resolve => map.once('load', resolve));
|
||
|
||
map.flyTo({ center: [lon, lat], zoom: 4, duration: 900 });
|
||
|
||
const cat = (storm.classification || '').toUpperCase();
|
||
const label = cat === 'HU' ? 'Hurricane' : cat === 'TS' ? 'Tropical Storm' : 'Tropical Depression';
|
||
setTimeout(() => {
|
||
new maplibregl.Popup({ closeButton: true, maxWidth: '240px' })
|
||
.setLngLat([lon, lat])
|
||
.setHTML(`<b>${storm.name || 'Storm'}</b><br>${label}<br>Winds: ${storm.intensity || '--'} mph${storm.movement ? '<br>Moving ' + storm.movement : ''}`)
|
||
.addTo(map);
|
||
}, 950);
|
||
|
||
if (storm.id) {
|
||
try {
|
||
const { lin, pts, pgn } = await fetch(`/api/hurricanes/track?stormId=${encodeURIComponent(storm.id)}`).then(r => r.json());
|
||
|
||
['hurricane-track-fill', 'hurricane-track-line', 'hurricane-track-pts'].forEach(id => {
|
||
if (map.getLayer(id)) map.removeLayer(id);
|
||
});
|
||
['hurricane-track-pgn', 'hurricane-track-lin', 'hurricane-track-pts-src'].forEach(id => {
|
||
if (map.getSource(id)) map.removeSource(id);
|
||
});
|
||
|
||
if (pgn?.type === 'FeatureCollection' && pgn.features?.length) {
|
||
map.addSource('hurricane-track-pgn', { type: 'geojson', data: pgn });
|
||
map.addLayer({ id: 'hurricane-track-fill', type: 'fill', source: 'hurricane-track-pgn',
|
||
paint: { 'fill-color': '#ff5722', 'fill-opacity': 0.12 } });
|
||
}
|
||
|
||
if (lin?.type === 'FeatureCollection' && lin.features?.length) {
|
||
map.addSource('hurricane-track-lin', { type: 'geojson', data: lin });
|
||
map.addLayer({ id: 'hurricane-track-line', type: 'line', source: 'hurricane-track-lin',
|
||
paint: { 'line-color': '#ff5722', 'line-width': 2.5, 'line-dasharray': [4, 2] } });
|
||
}
|
||
|
||
if (pts?.type === 'FeatureCollection' && pts.features?.length) {
|
||
map.addSource('hurricane-track-pts-src', { type: 'geojson', data: pts });
|
||
map.addLayer({ id: 'hurricane-track-pts', type: 'circle', source: 'hurricane-track-pts-src',
|
||
paint: {
|
||
'circle-radius': 7,
|
||
'circle-color': ['match', ['get', 'TCDVLP'],
|
||
'Hurricane', '#f44336', 'HU', '#f44336',
|
||
'Tropical Storm', '#9c27b0', 'TS', '#9c27b0',
|
||
'#2196f3'],
|
||
'circle-stroke-width': 2, 'circle-stroke-color': '#fff',
|
||
} });
|
||
|
||
const allCoords = pts.features.map(f => f.geometry.coordinates);
|
||
if (allCoords.length > 1) {
|
||
const lons = allCoords.map(c => c[0]);
|
||
const lats = allCoords.map(c => c[1]);
|
||
map.fitBounds(
|
||
[[Math.min(...lons), Math.min(...lats)], [Math.max(...lons), Math.max(...lats)]],
|
||
{ padding: 60, duration: 600, maxZoom: 5 }
|
||
);
|
||
}
|
||
|
||
map.on('click', 'hurricane-track-pts', e => {
|
||
const p = e.features[0].properties;
|
||
const tau = p.TAU != null ? `+${p.TAU}h` : '';
|
||
const knots = p.MAXWIND || p.INTENSITY;
|
||
const wind = knots ? `${Math.round(knots * 1.15)} mph` : '--';
|
||
const type = p.TCDVLP || p.STORMTYPE || '';
|
||
new maplibregl.Popup().setLngLat(e.lngLat)
|
||
.setHTML(`<b>${tau}</b> ${type}<br>Winds: ${wind}${p.MSLP ? '<br>Pressure: ' + p.MSLP + ' mb' : ''}`)
|
||
.addTo(map);
|
||
});
|
||
map.on('mouseenter', 'hurricane-track-pts', () => { map.getCanvas().style.cursor = 'pointer'; });
|
||
map.on('mouseleave', 'hurricane-track-pts', () => { map.getCanvas().style.cursor = ''; });
|
||
}
|
||
} catch {}
|
||
}
|
||
|
||
// Render overlay last so storm circles sit on top of the track layers
|
||
await renderHurricaneOverlay(map);
|
||
}
|
||
|
||
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, .icon-btn[data-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, .icon-btn[data-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) {
|
||
if (type === 'satellite') {
|
||
return {
|
||
version: 8,
|
||
sources: { base: { type: 'raster', tileSize: 256, attribution: '© Esri',
|
||
tiles: ['https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}'] } },
|
||
layers: [{ id: 'base', type: 'raster', source: 'base' }],
|
||
};
|
||
}
|
||
return type === 'light'
|
||
? 'https://tiles.openfreemap.org/styles/positron'
|
||
: 'https://tiles.openfreemap.org/styles/liberty';
|
||
}
|
||
|
||
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);
|
||
|
||
if (window.AppUI) window.AppUI.onWeather(data, city);
|
||
}
|
||
|
||
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 = '';
|
||
const hiAll = daily.temperature_2m_max.slice(0, maxDays), loAll = daily.temperature_2m_min.slice(0, maxDays);
|
||
const wkMin = Math.min(...loAll), wkMax = Math.max(...hiAll), span = Math.max(wkMax - wkMin, 1);
|
||
for (let i = 0; i < maxDays; i++) {
|
||
const dw = wmo(daily.weather_code[i]);
|
||
const lo = daily.temperature_2m_min[i], hi = daily.temperature_2m_max[i];
|
||
const left = ((lo - wkMin) / span) * 100, width = Math.max(((hi - lo) / span) * 100, 6);
|
||
const pop = daily.precipitation_probability_max[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" title="${dw.desc}">${dw.icon}</div>
|
||
<div class="day-pop">${pop > 0 ? `<span class="pop-drop">💧</span>${pop}%` : ''}</div>
|
||
<div class="day-lo-t">${tempDisplay(lo)}°</div>
|
||
<div class="day-range" title="${dw.desc} · wind ${speedDisplay(daily.wind_speed_10m_max[i])} ${speedUnit()}"><span class="day-range-bar" style="left:${left.toFixed(1)}%;width:${width.toFixed(1)}%"></span></div>
|
||
<div class="day-hi-t">${tempDisplay(hi)}°</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 ?? '--' });
|
||
if (window.AppUI) window.AppUI.onAir(data);
|
||
}
|
||
|
||
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', 'station', 'unified-map', 'alerts'];
|
||
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, stationName }) => {
|
||
if (stationName) state.stationName = stationName;
|
||
if (!configured && !state.ambientApiKey && typeof window.stationTabMessage === 'function') window.stationTabMessage('unconfigured');
|
||
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 tabMsg = (kind, detail) => { if (typeof window.stationTabMessage === 'function') window.stationTabMessage(kind, detail); };
|
||
|
||
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(() => ({}));
|
||
tabMsg(r.status === 503 ? 'unconfigured' : 'error', error || 'Station unavailable');
|
||
return;
|
||
}
|
||
const devices = await r.json();
|
||
if (!Array.isArray(devices) || !devices.length) {
|
||
tabMsg('error', 'No devices found on this account.');
|
||
return;
|
||
}
|
||
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) {
|
||
tabMsg('error', 'Could not reach your station.');
|
||
}
|
||
}
|
||
|
||
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`;
|
||
}
|
||
|
||
// Keep the searched location's name; mark that temperatures now come from your own station
|
||
const stationName = (state.stationName || device.info?.name || 'My Station');
|
||
const badge = document.getElementById('station-badge');
|
||
const badgeName = document.getElementById('station-badge-name');
|
||
if (badgeName) badgeName.textContent = `Your station · ${stationName}`;
|
||
if (badge) show(badge);
|
||
if (window.AppUI) window.AppUI.onStation(device, data);
|
||
}
|
||
|
||
function renderAmbientStation(device) {
|
||
state.ambientDevice = device;
|
||
// The Station tab (station-live.js) renders the readings
|
||
if (typeof window.renderStationTab === 'function') window.renderStationTab(device);
|
||
|
||
// Update the main conditions display with real station readings
|
||
if (state.currentWeather) applyStationOverlay(device);
|
||
}
|