Files
Nate EmmertandClaude Sonnet 5.5 2efc945192
Publish Container / publish (push) Successful in 2m40s
Radar: switch to RainViewer (IEM blocks heavy tile use), cleaner Map tab
- /api/radar/times serves RainViewer frames (cached 2 min), IEM NEXRAD only as fallback;
  sources honour maxNativeZoom so tiles overzoom instead of going blank
- Map tab opens with radar + location dot only; wind/AQI/alerts/temp/sun/lightning are opt-in,
  readout chips follow their toggles
- Bolder location dot
- Satellite (GIBS) layer: new storage key, never persisted by storm taps, radar always draws above it

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 16:38:00 -04:00

3290 lines
132 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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 = [];
// ── NHC storm model ──────────────────────────────────────────────────────────
// NHC's CurrentStorms.json reports `intensity` in KNOTS and `movementSpeed` in mph.
const KT_TO_MPH = 1.15078;
const STORM_CLASS = {
TD: 'Tropical Depression', TS: 'Tropical Storm', HU: 'Hurricane', TY: 'Typhoon',
STD: 'Subtropical Depression', STS: 'Subtropical Storm', SD: 'Subtropical Depression', SS: 'Subtropical Storm',
PTC: 'Potential Tropical Cyclone', PC: 'Post-Tropical Cyclone', EX: 'Extratropical Cyclone',
};
const SAFFIR_COLORS = { td: '#5dade2', ts: '#27ae60', 1: '#f1c40f', 2: '#f39c12', 3: '#e74c3c', 4: '#c0392b', 5: '#9b59b6', ex: '#95a5a6' };
const compass16 = deg => ['N','NNE','NE','ENE','E','ESE','SE','SSE','S','SSW','SW','WSW','W','WNW','NW','NNW'][Math.round(deg / 22.5) % 16];
function saffirCategory(kt) { return kt >= 137 ? 5 : kt >= 113 ? 4 : kt >= 96 ? 3 : kt >= 83 ? 2 : kt >= 64 ? 1 : 0; }
// Colour for a point on the forecast track, from its own forecast wind (knots)
function windColor(kt) { return kt >= 64 ? SAFFIR_COLORS[Math.max(1, saffirCategory(kt))] : kt >= 34 ? SAFFIR_COLORS.ts : SAFFIR_COLORS.td; }
function escHtml(v) { return String(v ?? '').replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c])); }
function fmtLatLon(lat, lon) {
const la = isNaN(lat) ? '--' : `${Math.abs(lat).toFixed(1)}°${lat < 0 ? 'S' : 'N'}`;
const lo = isNaN(lon) ? '--' : `${Math.abs(lon).toFixed(1)}°${lon < 0 ? 'W' : 'E'}`;
return `${la}, ${lo}`;
}
function milesBetween(lat1, lon1, lat2, lon2) {
const R = 3958.8, r = Math.PI / 180, dLat = (lat2 - lat1) * r, dLon = (lon2 - lon1) * r;
const x = Math.sin(dLat / 2) ** 2 + Math.cos(lat1 * r) * Math.cos(lat2 * r) * Math.sin(dLon / 2) ** 2;
return 2 * R * Math.asin(Math.sqrt(x));
}
function bearingDeg(lat1, lon1, lat2, lon2) {
const r = Math.PI / 180, y = Math.sin((lon2 - lon1) * r) * Math.cos(lat2 * r);
const x = Math.cos(lat1 * r) * Math.sin(lat2 * r) - Math.sin(lat1 * r) * Math.cos(lat2 * r) * Math.cos((lon2 - lon1) * r);
return (Math.atan2(y, x) / r + 360) % 360;
}
function stormInfo(s) {
const cls = String(s.classification || '').toUpperCase();
const kt = parseFloat(s.intensity);
const hasKt = Number.isFinite(kt);
const mph = hasKt ? Math.round(kt * KT_TO_MPH) : null;
const isHurr = cls === 'HU' || cls === 'TY';
const cat = isHurr && hasKt ? Math.max(1, saffirCategory(kt)) : isHurr ? 1 : 0;
const base = STORM_CLASS[cls] || 'Tropical Cyclone';
const label = cat ? `Category ${cat} ${cls === 'TY' ? 'Typhoon' : 'Hurricane'}${cat >= 3 ? ' (major)' : ''}` : base;
const badge = cat ? `cat-${cat}` : (cls === 'TS' || cls === 'STS' || cls === 'SS') ? 'cat-ts' : (cls === 'TD' || cls === 'STD' || cls === 'SD' || cls === 'PTC') ? 'cat-td' : 'cat-ex';
const color = cat ? SAFFIR_COLORS[cat] : badge === 'cat-ts' ? SAFFIR_COLORS.ts : badge === 'cat-td' ? SAFFIR_COLORS.td : SAFFIR_COLORS.ex;
const lat = Number.isFinite(s.latitudeNumeric) ? s.latitudeNumeric : parseNhcCoord(s.latitude);
const lon = Number.isFinite(s.longitudeNumeric) ? s.longitudeNumeric : parseNhcCoord(s.longitude);
const title = s.name ? `${base} ${s.name}` : base;
let movement = '';
if (Number.isFinite(s.movementSpeed)) {
movement = s.movementSpeed < 1 ? 'Nearly stationary'
: `Moving ${Number.isFinite(s.movementDir) ? compass16(s.movementDir) + ' ' : ''}at ${Math.round(s.movementSpeed)} mph`;
}
return { cls, kt: hasKt ? kt : null, mph, cat, label, base, title, badge, color, lat, lon, movement };
}
function renderHurricanePanel(storms) {
const panel = document.getElementById('hurricane-panel');
if (!panel) return;
if (!storms || !storms.length) { panel.innerHTML = ''; return; }
hurricaneStorms = storms;
const here = state.currentCity;
panel.innerHTML = storms.map(s => {
const i = stormInfo(s);
let away = '';
if (here && !isNaN(i.lat) && !isNaN(i.lon)) {
const mi = milesBetween(here.latitude, here.longitude, i.lat, i.lon);
const dir = compass16(bearingDeg(here.latitude, here.longitude, i.lat, i.lon));
away = `<span>≈ ${Math.round(state.units === 'us' ? mi : mi * 1.60934).toLocaleString()} ${state.units === 'us' ? 'mi' : 'km'} ${dir} of ${escHtml(here.name || 'you')}</span>`;
}
const updated = s.lastUpdate ? new Date(s.lastUpdate).toLocaleString([], { weekday: 'short', hour: 'numeric', minute: '2-digit' }) : '';
const adv = s.publicAdvisory;
return `
<div class="hurricane-storm-card" style="cursor:pointer" title="Click to view on map" data-storm="${escHtml(s.id || '')}">
<div class="hurricane-header">
<span class="hurricane-name">${escHtml(i.title)}</span>
<span class="hurricane-cat-badge ${i.badge}" style="background:${i.color}">${escHtml(i.cat ? 'Category ' + i.cat : i.base)}</span>
</div>
<div class="hurricane-stats">
<span><b>${i.mph != null ? i.mph + ' mph' : '--'}</b> winds${i.kt != null ? ` <small>(${Math.round(i.kt)} kt)</small>` : ''}</span>
${s.pressure ? `<span>${escHtml(s.pressure)} mb</span>` : ''}
<span>${fmtLatLon(i.lat, i.lon)}</span>
${i.movement ? `<span>${escHtml(i.movement)}</span>` : ''}
${away}
</div>
<div class="hurricane-meta">
${updated ? `<span>Updated ${escHtml(updated)}</span>` : ''}
${adv?.advNum ? `<span>Advisory ${escHtml(adv.advNum)}</span>` : ''}
${adv?.url ? `<a href="${escHtml(adv.url)}" target="_blank" rel="noopener" class="hurricane-link">Official advisory ↗</a>` : ''}
<span class="hurricane-map-hint">🗺 View on map</span>
</div>
</div>`;
}).join('');
panel.querySelectorAll('.hurricane-storm-card').forEach((card, idx) => {
card.addEventListener('click', () => flyToHurricane(hurricaneStorms[idx]));
card.querySelector('.hurricane-link')?.addEventListener('click', e => e.stopPropagation());
});
}
async function flyToHurricane(storm) {
const info = stormInfo(storm);
const { lat, lon } = info;
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;
}
// Ground radar doesn't reach open ocean, so show infrared satellite (the storm's actual cloud
// structure). Goes through the layer checkbox so state and storage stay in sync.
const sat = document.querySelector('#tab-unified-map input[data-layer="satellite"]');
if (sat && !sat.checked) {
sat.checked = true;
sat.dispatchEvent(new Event('change'));
// a storm tap is a one-off look, not a change to the user's saved map layers
try { localStorage.removeItem(STORAGE_KEYS.showSatelliteLayer); } catch {}
}
await new Promise(r => setTimeout(r, 450));
const map = mapsOverview.map;
if (!map) return;
// 'load' fires only once per map, so waiting on it hangs forever whenever tiles are mid-load;
// flyTo works regardless, so just make sure the canvas has its real size first.
map.resize();
map.flyTo({ center: [lon, lat], zoom: 4, duration: 900, padding: { bottom: 200 } });
setTimeout(() => {
new maplibregl.Popup({ closeButton: true, maxWidth: '260px' })
.setLngLat([lon, lat])
.setHTML(`<b>${escHtml(info.title)}</b><br>${escHtml(info.label)}<br>Winds: ${info.mph != null ? info.mph + ' mph' : '--'}${info.movement ? '<br>' + escHtml(info.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': '#ff7043', '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 });
// Each forecast point is coloured by its own forecast strength (not the storm's current class)
map.addLayer({
id: 'hurricane-track-pts', type: 'circle', source: 'hurricane-track-pts-src',
paint: {
'circle-radius': 7,
'circle-color': ['step', ['to-number', ['get', 'MAXWIND'], 0],
SAFFIR_COLORS.td, 34, SAFFIR_COLORS.ts, 64, SAFFIR_COLORS[1], 83, SAFFIR_COLORS[2],
96, SAFFIR_COLORS[3], 113, SAFFIR_COLORS[4], 137, SAFFIR_COLORS[5]],
'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: { top: 70, left: 50, right: 50, bottom: 260 }, duration: 600, maxZoom: 5 }
);
}
// Register the handlers once per map: re-adding them on every call stacked up duplicate popups
if (!map.__trackHandlers) {
map.__trackHandlers = true;
map.on('click', 'hurricane-track-pts', e => {
const p = e.features[0].properties;
const when = p.TAU != null ? (p.TAU === 0 ? 'Now' : `+${p.TAU}h`) : '';
const kt = Number(p.MAXWIND) || 0;
const cat = saffirCategory(kt);
const kind = cat ? `Category ${cat} hurricane` : kt >= 34 ? 'Tropical storm' : 'Tropical depression';
new maplibregl.Popup().setLngLat(e.lngLat)
.setHTML(`<b>${escHtml(when)}</b> ${escHtml(kind)}<br>Winds: ${kt ? Math.round(kt * KT_TO_MPH) + ' mph (' + kt + ' kt)' : '--'}`)
.addTo(map);
});
map.on('mouseenter', 'hurricane-track-pts', () => { map.getCanvas().style.cursor = 'pointer'; });
map.on('mouseleave', 'hurricane-track-pts', () => { map.getCanvas().style.cursor = ''; });
}
}
} catch {}
}
}
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); }
}
}
// Overlays used to bail out silently whenever tiles were still loading (most of the time while radar animates).
// Wait for the map to settle instead (but never hang: give up after a few seconds and try anyway).
function mapReady(map, timeoutMs = 4000) {
if (!map || map.loaded()) return Promise.resolve();
return new Promise(resolve => {
const t = setTimeout(resolve, timeoutMs);
map.once('idle', () => { clearTimeout(t); resolve(); });
});
}
// Infrared satellite from NASA GIBS (GOES-West + GOES-East, newest image, no key). Unlike ground radar it
// reaches open ocean, so a hurricane's cloud shield is visible. East draws over West where they overlap.
const GOES_LAYERS = [['goes-west', 'GOES-West_ABI_Band13_Clean_Infrared'], ['goes-east', 'GOES-East_ABI_Band13_Clean_Infrared']];
async function renderCloudOverlay(map) {
if (!map) return;
await mapReady(map);
const on = !!state.unifiedMapLayers.satellite;
const stamp = Math.floor(Date.now() / 600000); // GIBS updates ~every 10 min; bust the tile cache then
try {
GOES_LAYERS.forEach(([id, layer]) => {
if (!on) { if (map.getLayer(id)) map.setLayoutProperty(id, 'visibility', 'none'); return; }
if (map.getLayer(id)) { map.setLayoutProperty(id, 'visibility', 'visible'); }
else {
map.addSource(id, { type: 'raster', tileSize: 256, maxzoom: 6, attribution: 'NASA GIBS',
tiles: [`https://gibs.earthdata.nasa.gov/wmts/epsg3857/best/${layer}/default/GoogleMapsCompatible_Level6/{z}/{y}/{x}.png?t=${stamp}`] });
// sit under the animated radar frames so rain still reads on top of the clouds
const beforeId = map.getStyle().layers.find(l => l.id.startsWith('rv-radar-'))?.id;
map.addLayer({ id, type: 'raster', source: id, paint: { 'raster-opacity': 0.8, 'raster-fade-duration': 0 } }, beforeId);
}
if (map.__cloudStamp !== stamp && map.getSource(id)?.setTiles) {
map.getSource(id).setTiles([`https://gibs.earthdata.nasa.gov/wmts/epsg3857/best/${layer}/default/GoogleMapsCompatible_Level6/{z}/{y}/{x}.png?t=${stamp}`]);
}
});
if (on) map.__cloudStamp = stamp;
// Radar frames must always draw above the clouds (they may have been added after this layer), or rain disappears
map.getStyle().layers.filter(l => l.id.startsWith('rv-radar-')).forEach(l => map.moveLayer(l.id));
} catch { /* style swapped mid-update; next refresh retries */ }
}
async function renderHurricaneOverlay(map) {
if (!map) return;
const tok = (map.__hurrTok = (map.__hurrTok || 0) + 1); // a newer refresh supersedes this one
await mapReady(map);
if (tok !== map.__hurrTok) 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());
if (tok !== map.__hurrTok) return;
const storms = data.storms || [];
if (!storms.length) return;
const features = storms.map(s => {
const i = stormInfo(s);
if (isNaN(i.lat) || isNaN(i.lon)) return null;
return {
type: 'Feature',
geometry: { type: 'Point', coordinates: [i.lon, i.lat] },
properties: { title: i.title, label: i.label, mph: i.mph, kt: i.kt, color: i.color, movement: i.movement },
};
}).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': ['get', 'color'],
'circle-opacity': 0.55,
'circle-stroke-width': 2.5,
'circle-stroke-color': ['get', 'color'],
},
});
// Handlers are registered once; this overlay is rebuilt on every map refresh
if (!map.__stormOverlayHandlers) {
map.__stormOverlayHandlers = true;
map.on('click', 'hurricane-overlay', e => {
const p = e.features[0].properties;
new maplibregl.Popup().setLngLat(e.lngLat)
.setHTML(`<b>${escHtml(p.title)}</b><br>${escHtml(p.label)}<br>Winds: ${p.mph != null && p.mph !== 'null' ? p.mph + ' mph' : '--'}${p.movement ? '<br>' + escHtml(p.movement) : ''}`).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) return;
await mapReady(map);
['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) return;
await mapReady(map);
['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.goesIR', // new key: the old 'satellite' toggle never drew anything, ignore its stale value
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) === 'true',
lightning: localStorage.getItem(STORAGE_KEYS.showLightningLayer) === 'true',
wind: localStorage.getItem(STORAGE_KEYS.showWindLayer) === 'true',
aqi: localStorage.getItem(STORAGE_KEYS.showAqiLayer) === 'true',
alerts: localStorage.getItem(STORAGE_KEYS.showAlertsLayer) === 'true',
temp: localStorage.getItem(STORAGE_KEYS.showTempMarker) === 'true',
sunMoon: localStorage.getItem(STORAGE_KEYS.showSunMoonMarkers) === 'true',
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,
// The embedded radar sits in a scrolling page: plain wheel / one-finger drag scroll the page,
// Ctrl+wheel / two fingers move the map.
cooperativeGestures: mapEl?.id === 'radar-embed',
});
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, ...(frame.maxNativeZoom ? { maxzoom: frame.maxNativeZoom } : {}) });
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;
// Keep the user's own pan/zoom; only (re)centre when the location changes
if (window.AppUI && window.AppUI.centerMap) window.AppUI.centerMap(state.currentCity);
else mapsOverview.panTo(state.currentCity.latitude, state.currentCity.longitude, mapsOverview.map.getZoom ? mapsOverview.map.getZoom() : 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);
renderCloudOverlay(mapsOverview.map);
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);
if (window.AppUI && window.AppUI.centerMap) window.AppUI.centerMap(city);
else mapsOverview.panTo(city.latitude, city.longitude, (mapsOverview?.map?.getZoom ? mapsOverview.map.getZoom() : 6) || 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('storm surge')) return 'Know your evacuation zone. If officials order an evacuation, leave early: surge is the deadliest part of a tropical storm.';
if (eventText.includes('hurricane') || eventText.includes('typhoon')) return eventText.includes('warning') ? 'Finish storm preparations now, follow local evacuation orders, and plan to shelter until the storm has fully passed.' : 'Review your plan, stock water, food and medicine, secure outdoor items, and know your evacuation zone.';
if (eventText.includes('tropical')) return eventText.includes('warning') ? 'Expect damaging winds and heavy rain. Secure outdoor items, charge devices, and stay off the roads once conditions deteriorate.' : 'Tropical storm conditions are possible. Prepare now: secure outdoor items and keep supplies ready.';
if (eventText.includes('extreme wind')) return 'Take shelter immediately in the strongest part of a sturdy building, away from windows.';
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 || p?.ends;
const fmt = iso => new Date(iso).toLocaleString([], { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' });
return `${start ? fmt(start) : 'now'} → ${end ? fmt(end) : 'until further notice'}`;
}
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);
}
// One active alert: what it's for, where, when, what to do, and the full text on demand
function buildAlertCard(f) {
const p = f.properties || {};
const AI = window.AlertInfo;
const sev = normalizeSeverity(p.severity);
const meaning = AI.meaning(p.event);
const why = p.description ? AI.firstSentence(p.description) : (p.headline || 'See official sources for details.');
const areaAll = AI.area(p.areaDesc);
const parts = String(p.areaDesc || '').split(';').map(x => x.trim()).filter(Boolean);
const areaShort = parts.length > 4 ? `${parts.slice(0, 4).join(', ')} +${parts.length - 4} more` : areaAll;
const left = AI.remaining(p.expires || p.ends);
const todo = p.instruction ? AI.firstSentence(p.instruction, 260) : plainAlertAction(p.event, p.severity);
const meta = [p.severity && p.severity !== 'Unknown' && `Severity: ${p.severity}`, p.urgency && p.urgency !== 'Unknown' && `Urgency: ${p.urgency}`,
p.certainty && p.certainty !== 'Unknown' && `Certainty: ${p.certainty}`, p.senderName && `Issued by ${p.senderName}`].filter(Boolean);
const card = document.createElement('article');
card.className = 'alert-item alert-rich';
card.innerHTML = `
<div class="alert-top">
<strong>${AI.esc(p.event || 'Alert')}</strong>
<span class="alert-severity severity-${sev.toLowerCase()}">${sev}</span>
</div>
<div class="alert-why">${AI.esc(why)}</div>
<div class="alert-facts">
${areaShort ? `<span>📍 ${AI.esc(areaShort)}</span>` : ''}
<span>🕑 ${AI.esc(buildAlertTiming(p))}</span>
${left ? `<span class="alert-left">⏳ ${AI.esc(left)}</span>` : ''}
</div>
${meaning ? `<div class="alert-meaning"><b>What this means:</b> ${AI.esc(meaning)}</div>` : ''}
<div class="alert-action"><b>What to do:</b> ${AI.esc(todo)}</div>
<details class="alert-more">
<summary>Full details</summary>
<div class="alert-full">
${p.headline ? `<p><b>Headline:</b> ${AI.esc(p.headline)}</p>` : ''}
${areaAll ? `<p><b>Area:</b> ${AI.esc(areaAll)}</p>` : ''}
${p.description ? `<p class="alert-pre"><b>Details:</b>\n${AI.esc(p.description.trim())}</p>` : ''}
${p.instruction ? `<p class="alert-pre"><b>Instructions:</b>\n${AI.esc(p.instruction.trim())}</p>` : ''}
${meta.length ? `<p class="alert-meta">${meta.map(AI.esc).join(' · ')}</p>` : ''}
</div>
</details>`;
return card;
}
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');
// Only alerts that are still active (a cached response can include ones that have just expired)
const features = (data?.features || []).filter(f => {
const end = Date.parse(f.properties?.expires || f.properties?.ends || '');
return !Number.isFinite(end) || end > Date.now();
});
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).forEach(f => groupWrap.appendChild(buildAlertCard(f)));
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/v2/icon-192.png', badge: '/icons/v2/icon-192.png', tag: id, data: { url: '/?tab=alerts' } })
).catch(() => new Notification(title, { body, icon: '/icons/v2/icon-192.png' }));
} else {
new Notification(title, { body, icon: '/icons/v2/icon-192.png', badge: '/icons/v2/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 ───────────────────────────────────────────────────────────────
// Why a location request failed, in plain language (the browser tells us: 1 denied, 2 unavailable, 3 timeout)
const IS_IOS = /iPad|iPhone|iPod/.test(navigator.userAgent) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
const IS_STANDALONE = window.matchMedia('(display-mode: standalone)').matches || navigator.standalone === true;
async function geoPermissionState() {
try { return (await navigator.permissions.query({ name: 'geolocation' })).state; } catch { return 'unknown'; }
}
function geoErrorHelp(err, perm) {
const code = err && err.code;
const tag = ` (error ${code ?? '?'}${perm && perm !== 'unknown' ? ', permission: ' + perm : ''})`;
if (code === 1) {
if (IS_IOS) {
const where = IS_STANDALONE
? 'Settings → Privacy & Security → Location Services → "NeCloud Weather" → While Using the App'
: 'Settings → Privacy & Security → Location Services → Safari Websites → While Using, then in Safari tap aA → Website Settings → Location → Allow';
return `Your phone says location is off for ${IS_STANDALONE ? 'this app' : 'this site'}. Turn it on in ${where}. If you use a Safari content blocker, also try aA → Turn Off Content Blockers for this site.${tag}`;
}
return `Location is blocked for this site. Click the lock icon in the address bar → Location → Allow, then try again.${tag}`;
}
if (code === 2) return `Your device couldn't find your position yet (no GPS or Wi-Fi fix). Make sure Location Services is on, turn off Low Power Mode, and try again, ideally on Wi-Fi or outdoors.${tag}`;
if (code === 3) return `Timed out waiting for your location. Try again in a moment.${tag}`;
return `Could not get your location.${tag}`;
}
function geoOnce(opts) {
return new Promise((resolve, reject) => navigator.geolocation.getCurrentPosition(resolve, reject, opts));
}
// A slow or cold GPS often times out the first time: retry once with a patient, high-accuracy request.
// A real "denied" is final, so don't retry that.
async function getPositionRobust() {
try {
return await geoOnce({ enableHighAccuracy: false, timeout: 12000, maximumAge: 5 * 60 * 1000 });
} catch (e) {
if (e && e.code === 1) throw e;
return await geoOnce({ enableHighAccuracy: true, timeout: 20000, maximumAge: 0 });
}
}
locateBtn.addEventListener('click', async () => {
if (!navigator.geolocation) { showErr('Geolocation not supported.'); return; }
clearErr();
hide(weatherDiv);
show(loadingEl);
try {
const pos = await getPositionRobust();
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.');
}
} catch (err) {
if (state.currentWeather) show(weatherDiv);
showErr(geoErrorHelp(err, await geoPermissionState()));
} finally {
hide(loadingEl);
if (window.AppUI && window.AppUI.refreshGeoStatus) window.AppUI.refreshGeoStatus();
}
});
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') renderCloudOverlay(mapsOverview.map);
syncReadoutChips();
if (state.currentCity) refreshMapsOverview();
});
});
// The top-left readout chips only show for layers the user switched on
function syncReadoutChips() {
const L = state.unifiedMapLayers;
[['overlay-temp', L.temp], ['overlay-aqi', L.aqi], ['overlay-sunmoon', L.sunMoon]].forEach(([id, on]) => {
const el = document.getElementById(id);
if (el) el.hidden = !on;
});
const box = document.getElementById('map-overlay-readouts');
if (box) box.hidden = !(L.temp || L.aqi || L.sunMoon);
}
syncReadoutChips();
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');
document.getElementById('layer-menu-close')?.addEventListener('click', () => { if (!document.getElementById('layer-menu-panel').classList.contains('hidden')) document.getElementById('layer-menu-toggle').click(); });
const layerMenuPanel = document.getElementById('layer-menu-panel');
if (layerMenuToggleBtn && layerMenuPanel) {
layerMenuToggleBtn.addEventListener('click', () => {
const willShow = layerMenuPanel.classList.contains('hidden');
layerMenuPanel.classList.toggle('hidden', !willShow);
if (!layerMenuToggleBtn.classList.contains('hud-btn')) layerMenuToggleBtn.textContent = willShow ? 'Hide Layer Menu' : 'Show Layer Menu';
layerMenuToggleBtn.classList.toggle('on', willShow);
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);
}