'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: '🌡️' }; }
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',
showLightning: 'weather.showLightning',
showWind: 'weather.showWind',
};
const state = {
units: localStorage.getItem(STORAGE_KEYS.units) || 'us',
autoDetect: localStorage.getItem(STORAGE_KEYS.autoDetect) !== 'false',
showLightning: localStorage.getItem(STORAGE_KEYS.showLightning) !== 'false',
showWind: localStorage.getItem(STORAGE_KEYS.showWind) !== 'false',
currentCity: null,
currentWeather: null,
currentAir: null,
currentSource: 'startup',
savedCities: JSON.parse(localStorage.getItem(STORAGE_KEYS.savedCities) || '[]'),
alertIdsSeen: new Set(),
autoRefreshTimer: null,
lastRefreshAt: 0,
lightningMarkers: [],
windArrows: [],
};
// ── 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 saveCurrentBtn = document.getElementById('save-current-btn');
const savedLocationsEl = document.getElementById('saved-locations');
const notifyBtn = document.getElementById('notify-btn');
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 lightningStatus = document.getElementById('lightning-status');
const windStatus = document.getElementById('wind-status');
// ── 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 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 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;
}
state.savedCities.forEach(city => {
const wrap = document.createElement('div');
wrap.className = 'saved-item';
const chip = document.createElement('button');
chip.className = 'saved-chip';
chip.type = 'button';
chip.textContent = city.name;
chip.addEventListener('click', () => loadAll(city, 'saved-location'));
const remove = document.createElement('button');
remove.className = 'saved-remove';
remove.type = 'button';
remove.textContent = 'x';
remove.addEventListener('click', (e) => {
e.stopPropagation();
state.savedCities = state.savedCities.filter(c => cityKey(c) !== cityKey(city));
persistSavedCities();
renderSavedCities();
});
wrap.appendChild(chip);
wrap.appendChild(remove);
savedLocationsEl.appendChild(wrap);
});
}
// ── Tab switching ─────────────────────────────────────────────────────────────
document.querySelectorAll('.tab').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.tab-panel').forEach(p => hide(p));
btn.classList.add('active');
show(document.getElementById('tab-' + btn.dataset.tab));
if (btn.dataset.tab === 'radar-full') {
setTimeout(() => { radarFull.map && radarFull.map.invalidateSize(); }, 50);
}
if (btn.dataset.tab === 'maps') {
setTimeout(() => { mapsOverview.map && mapsOverview.map.invalidateSize(); }, 50);
}
});
});
document.getElementById('open-radar-tab').addEventListener('click', () => {
document.querySelector('.tab[data-tab="radar-full"]').click();
});
// ── APIs ─────────────────────────────────────────────────────────────────────
async function geocode(q) {
const r = await fetch(`/api/geocode?q=${encodeURIComponent(q)}`);
if (!r.ok) throw new Error('Geocoding failed');
return r.json();
}
async function fetchWeather(lat, lon, tz) {
const r = await fetch(`/api/weather?lat=${lat}&lon=${lon}&tz=${encodeURIComponent(tz||'auto')}&units=us`);
if (!r.ok) throw new Error('Weather fetch failed');
return r.json();
}
async function fetchAirQuality(lat, lon, tz) {
const r = await fetch(`/api/air-quality?lat=${lat}&lon=${lon}&tz=${encodeURIComponent(tz||'auto')}`);
if (!r.ok) throw new Error('Air quality fetch failed');
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();
}
// ── Radar engine ──────────────────────────────────────────────────────────────
function createRadar(mapEl, timelineEl, playBtn, tsEl, layerBtn) {
const map = L.map(mapEl, {
center: [39.5, -98.35],
zoom: 4,
zoomControl: true,
attributionControl: false,
});
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
maxZoom: 19,
}).addTo(map);
let radarFrames = [];
let satelliteFrames = [];
let mode = 'radar';
let currentIdx = 0;
let layers = { radar: {}, satellite: {} };
let playing = false;
let animTimer = null;
let locationMarker = null;
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) {
if (mode === '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 showFrame(idx) {
const frames = activeFrames();
currentIdx = idx;
const frame = frames[idx];
if (!frame) return;
if (!layers[mode][frame.time]) {
layers[mode][frame.time] = L.tileLayer(frameTileUrl(frame), { opacity: 0.75, maxZoom: 15 });
}
Object.values(layers.radar).forEach(l => { if (map.hasLayer(l)) map.removeLayer(l); });
Object.values(layers.satellite).forEach(l => { if (map.hasLayer(l)) map.removeLayer(l); });
layers[mode][frame.time].addTo(map);
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, 500);
}
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';
buildTimeline();
const idx = Math.min(currentIdx, activeFrames().length - 1);
showFrame(Math.max(idx, 0));
});
}
async function loadFrames() {
try {
const r = await fetch('/api/radar/times');
const data = await r.json();
radarFrames = [];
satelliteFrames = [];
(data.radar?.past || []).forEach(f => radarFrames.push({ time: f.time, path: f.path, isForecast: false }));
(data.radar?.nowcast || []).forEach(f => radarFrames.push({ time: f.time, path: f.path, isForecast: true }));
(data.satellite?.infrared || []).forEach(f => satelliteFrames.push({ time: f.time, path: f.path, isForecast: false }));
if (!radarFrames.length && satelliteFrames.length) mode = 'satellite';
if (!radarFrames.length && !satelliteFrames.length) return;
if (layerBtn) {
layerBtn.disabled = !(radarFrames.length && satelliteFrames.length);
layerBtn.textContent = mode === 'radar' ? 'R' : 'S';
}
buildTimeline();
const frames = activeFrames();
showFrame(frames.length - 1);
} catch (e) {
console.error('Radar load failed', e);
}
}
function panTo(lat, lon, zoom) {
map.setView([lat, lon], zoom || map.getZoom());
if (locationMarker) map.removeLayer(locationMarker);
locationMarker = L.circleMarker([lat, lon], {
radius: 6,
fillColor: '#e07848',
fillOpacity: 1,
color: '#fff',
weight: 2,
}).addTo(map);
}
loadFrames();
return { map, panTo, startPlay, stopPlay };
}
const radarEmbed = createRadar(
document.getElementById('radar-embed'),
document.getElementById('embed-timeline'),
document.getElementById('embed-play'),
document.getElementById('embed-timestamp'),
document.getElementById('embed-layer')
);
const radarFull = createRadar(
document.getElementById('radar-full'),
document.getElementById('full-timeline'),
document.getElementById('full-play'),
document.getElementById('full-timestamp'),
document.getElementById('full-layer')
);
function createOverviewMap(mapEl) {
const map = L.map(mapEl, {
center: [39.5, -98.35],
zoom: 4,
zoomControl: true,
attributionControl: false,
});
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 18,
}).addTo(map);
let marker = null;
function panTo(lat, lon, zoom) {
map.setView([lat, lon], zoom || map.getZoom());
if (marker) map.removeLayer(marker);
marker = L.circleMarker([lat, lon], {
radius: 7,
fillColor: '#e07848',
fillOpacity: 1,
color: '#fff',
weight: 2,
}).addTo(map);
}
return { map, panTo };
}
function clearLightningMarkers() {
if (!mapsOverview.map) return;
state.lightningMarkers.forEach(marker => mapsOverview.map.removeLayer(marker));
state.lightningMarkers = [];
}
function clearWindArrows() {
if (!mapsOverview.map) return;
state.windArrows.forEach(arrow => mapsOverview.map.removeLayer(arrow));
state.windArrows = [];
}
function renderLightningStrikes(city) {
if (!mapsOverview.map || !state.showLightning) return;
clearLightningMarkers();
// Simulate lightning strikes in the vicinity of the city (for now, use random placement)
// In a production app, you'd fetch from a lightning API like Blitzortung or similar
// For demo: show 3-5 random strikes within 0.5 degrees
const strokeCount = Math.random() < 0.7 ? 0 : (Math.floor(Math.random() * 4) + 1);
for (let i = 0; i < strokeCount; i++) {
const latOffset = (Math.random() - 0.5) * 0.5;
const lonOffset = (Math.random() - 0.5) * 0.5;
const lat = parseFloat(city.latitude) + latOffset;
const lon = parseFloat(city.longitude) + lonOffset;
const marker = L.circleMarker([lat, lon], {
radius: 4,
fillColor: '#ffeb3b',
fillOpacity: 0.8,
color: '#ff6f00',
weight: 1.5,
className: 'lightning-marker',
}).bindPopup('Lightning strike');
marker.addTo(mapsOverview.map);
state.lightningMarkers.push(marker);
}
}
function renderWindArrows(city) {
if (!mapsOverview.map || !state.showWind || !state.currentWeather) return;
clearWindArrows();
// Get current wind data
const current = state.currentWeather.current;
if (!current || !current.wind_speed_10m) return;
const windSpeed = current.wind_speed_10m;
const windDir = current.wind_direction_10m || 0;
// Draw a wind barb pointing in the direction of wind movement
const lat = parseFloat(city.latitude);
const lon = parseFloat(city.longitude);
const radius = 0.3;
// Calculate arrow end point based on wind direction
const radians = (windDir * Math.PI) / 180;
const endLat = lat + radius * Math.cos(radians);
const endLon = lon + radius * Math.sin(radians);
// Draw arrow line
const arrow = L.polyline(
[[lat, lon], [endLat, endLon]],
{
color: '#4a90e2',
weight: 2,
opacity: 0.7,
}
).bindPopup(`Wind: ${speedDisplay(windSpeed)} ${speedUnit()}`);
arrow.addTo(mapsOverview.map);
state.windArrows.push(arrow);
// Draw arrow head as a small circle at the end
const arrowHead = L.circleMarker([endLat, endLon], {
radius: 3,
fillColor: '#4a90e2',
fillOpacity: 0.7,
color: '#fff',
weight: 1,
});
arrowHead.addTo(mapsOverview.map);
state.windArrows.push(arrowHead);
}
const mapsOverview = createOverviewMap(document.getElementById('map-overview'));
// ── Rendering ────────────────────────────────────────────────────────────────
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').innerHTML =
`${DAYS[now.getDay()]}, ${MONTHS[now.getMonth()]} ${now.getDate()}
` +
now.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
const w = wmo(c.weather_code);
document.getElementById('cur-icon').textContent = w.icon;
document.getElementById('cur-desc').textContent = w.desc;
document.getElementById('cur-temp').textContent = tempDisplay(c.temperature_2m);
document.querySelector('.deg').textContent = `°${tempUnit()}`;
document.querySelector('.feels').innerHTML = `${apparentLabel(c.temperature_2m, c.apparent_temperature)} ${tempDisplay(c.apparent_temperature)}°${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('cur-wind').textContent = `${speedDisplay(c.wind_speed_10m)} ${compassDir(c.wind_direction_10m)}`;
document.getElementById('cur-pres').textContent = Math.round(c.surface_pressure);
document.getElementById('cur-vis').textContent = visDisplay(c.visibility);
document.getElementById('cur-uv').textContent = Math.round(d.uv_index_max[0] ?? 0);
document.getElementById('cur-cloud').textContent = c.cloud_cover;
document.querySelector('.stat:nth-child(2) .unit').textContent = speedUnit();
document.querySelector('.stat:nth-child(4) .unit').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 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 = '';
for (let i = startIdx; i < Math.min(startIdx + 24, h.time.length); 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 cell = document.createElement('div');
cell.className = 'hour-cell' + (i === startIdx ? ' now' : '');
cell.innerHTML = `