(function () { "use strict"; const DEFAULT_PREFS = { theme: "dark", locationQuery: "New York, NY", sections: { current: true, map: true, hourly: true, daily: true, weekly: true, air: true, history: true, archives: true }, layers: { radar: true, wind: true, rain: true, aqi: true, clouds: true }, metrics: { humidity: true, wind: true, precip: true, pressure: true, uv: true, aqi: true, pollen: true } }; const WEATHER_CODES = { 0: "Clear sky", 1: "Mainly clear", 2: "Partly cloudy", 3: "Overcast", 45: "Fog", 48: "Rime fog", 51: "Light drizzle", 53: "Drizzle", 55: "Dense drizzle", 56: "Light freezing drizzle", 57: "Freezing drizzle", 61: "Light rain", 63: "Rain", 65: "Heavy rain", 66: "Light freezing rain", 67: "Freezing rain", 71: "Light snow", 73: "Snow", 75: "Heavy snow", 77: "Snow grains", 80: "Light showers", 81: "Showers", 82: "Heavy showers", 85: "Light snow showers", 86: "Snow showers", 95: "Thunderstorm", 96: "Thunderstorm with hail", 99: "Severe thunderstorm" }; const state = { prefs: loadPrefs(), dashboard: null, selectedLocation: null, map: null, baseLayer: null, layers: {}, nextRefreshAt: 0, countdownTimer: null, refreshTimer: null, searchTimer: null, lastArchives: [] }; const $ = (id) => document.getElementById(id); document.addEventListener("DOMContentLoaded", init); function init() { $("locationInput").value = state.prefs.locationQuery || DEFAULT_PREFS.locationQuery; bindEvents(); applyPreferences(); loadWeather(false); startTimers(); if (window.lucide) { window.lucide.createIcons(); } } function bindEvents() { $("searchForm").addEventListener("submit", (event) => { event.preventDefault(); state.selectedLocation = null; state.prefs.locationQuery = $("locationInput").value.trim() || DEFAULT_PREFS.locationQuery; savePrefs(); hideSearchResults(); loadWeather(false); }); $("locationInput").addEventListener("input", () => { state.selectedLocation = null; window.clearTimeout(state.searchTimer); state.searchTimer = window.setTimeout(searchLocations, 280); }); $("refreshButton").addEventListener("click", () => loadWeather(true)); $("themeButton").addEventListener("click", toggleTheme); $("exportPrefs").addEventListener("click", exportPreferences); $("importPrefsButton").addEventListener("click", () => $("importPrefsFile").click()); $("importPrefsFile").addEventListener("change", importPreferences); $("archiveForm").addEventListener("submit", (event) => { event.preventDefault(); loadArchives(); }); $("exportArchives").addEventListener("click", exportArchives); $("closeArchiveDialog").addEventListener("click", () => $("archiveDialog").close()); document.querySelectorAll("[data-pref]").forEach((input) => { input.addEventListener("change", () => { setPath(state.prefs, input.dataset.pref, input.checked); savePrefs(); applyPreferences(); if (state.dashboard) { renderDashboard(); } }); }); } function startTimers() { window.clearInterval(state.refreshTimer); window.clearInterval(state.countdownTimer); state.nextRefreshAt = Date.now() + 300000; state.refreshTimer = window.setInterval(() => loadWeather(true), 300000); state.countdownTimer = window.setInterval(updateCountdown, 1000); updateCountdown(); } function updateCountdown() { const remaining = Math.max(0, state.nextRefreshAt - Date.now()); const totalSeconds = Math.ceil(remaining / 1000); const minutes = Math.floor(totalSeconds / 60); const seconds = String(totalSeconds % 60).padStart(2, "0"); $("refreshCountdown").textContent = `${minutes}:${seconds}`; } async function searchLocations() { const q = $("locationInput").value.trim(); if (q.length < 2) { hideSearchResults(); return; } try { const response = await fetch(`api.php?action=search&q=${encodeURIComponent(q)}`); const payload = await response.json(); if (!payload.ok) { throw new Error(payload.error || "Location search failed."); } renderSearchResults(payload.locations || []); } catch (error) { setStatus(error.message, true); } } function renderSearchResults(locations) { const box = $("searchResults"); box.innerHTML = ""; if (!locations.length) { hideSearchResults(); return; } locations.forEach((location) => { const button = document.createElement("button"); button.type = "button"; button.className = "search-result"; button.textContent = location.label; button.addEventListener("click", () => { state.selectedLocation = location; state.prefs.locationQuery = location.label; $("locationInput").value = location.label; savePrefs(); hideSearchResults(); loadWeather(false); }); box.appendChild(button); }); box.hidden = false; } function hideSearchResults() { $("searchResults").hidden = true; $("searchResults").innerHTML = ""; } async function loadWeather(force) { const params = new URLSearchParams({ action: "weather", force: force ? "1" : "0" }); const selected = state.selectedLocation; if (selected) { params.set("lat", selected.latitude); params.set("lon", selected.longitude); params.set("name", selected.name || selected.label || "Selected location"); params.set("admin1", selected.admin1 || ""); params.set("country", selected.country || ""); params.set("country_code", selected.country_code || ""); params.set("timezone", selected.timezone || "auto"); } else { params.set("q", $("locationInput").value.trim() || state.prefs.locationQuery || DEFAULT_PREFS.locationQuery); } setStatus(force ? "Refreshing weather data..." : "Loading weather data..."); try { const response = await fetch(`api.php?${params.toString()}`); const payload = await response.json(); if (!payload.ok) { throw new Error(payload.error || "Weather request failed."); } state.dashboard = payload.dashboard; state.selectedLocation = payload.dashboard.location; state.prefs.locationQuery = payload.dashboard.location.label; $("locationInput").value = payload.dashboard.location.label; savePrefs(); state.nextRefreshAt = Date.now() + ((payload.dashboard.refresh_seconds || 300) * 1000); renderDashboard(); await loadArchives(); const stale = [ payload.dashboard.forecast?._cache?.stale, payload.dashboard.air_quality?._cache?.stale, payload.dashboard.rainviewer?._cache?.stale ].some(Boolean); setStatus(stale ? "Showing cached weather data while a provider is unreachable." : "Weather data updated."); } catch (error) { setStatus(error.message, true); } } function renderDashboard() { const data = state.dashboard; if (!data) { return; } $("locationLabel").textContent = data.location.label; renderCurrent(data); renderMap(data); renderHourly(data); renderDaily(data); renderWeekly(data); renderAir(data); renderHistory(data); applyPreferences(); if (window.lucide) { window.lucide.createIcons(); } } function renderCurrent(data) { const current = data.forecast.current || {}; const hourly = data.forecast.hourly || {}; const air = data.air_quality.current || {}; const code = current.weather_code; const summary = labelForCode(code, data); $("currentTemp").textContent = formatNumber(current.temperature_2m, 0, " F"); $("currentSummary").textContent = `${summary}. Feels like ${formatNumber(current.apparent_temperature, 0, " F")}. Updated ${formatDateTime(current.time)}.`; const metrics = [ ["Humidity", current.relative_humidity_2m, "%", "metrics.humidity", "Relative humidity"], ["Wind", current.wind_speed_10m, " mph", "metrics.wind", `${formatNumber(current.wind_gusts_10m, 0, " mph gusts")} from ${formatNumber(current.wind_direction_10m, 0, " deg")}`], ["Precip", current.precipitation, " in", "metrics.precip", `${formatNumber(current.rain, 2, " in rain")} now`], ["Clouds", current.cloud_cover, "%", "metrics.precip", "Current sky cover"], ["Pressure", current.pressure_msl, " hPa", "metrics.pressure", "Mean sea level"], ["AQI", air.us_aqi, "", "metrics.aqi", aqiLabel(air.us_aqi)], ["PM2.5", air.pm2_5, " ug/m3", "metrics.aqi", "Fine particles"], ["UV", air.uv_index, "", "metrics.uv", `Clear sky ${formatNumber(air.uv_index_clear_sky, 1, "")}`], ["Visibility", firstFuture(hourly.time, hourly.visibility), " m", "metrics.pressure", "Nearest hourly value"] ]; $("metricGrid").innerHTML = metrics .filter((item) => getPath(state.prefs, item[3])) .map((item) => metricCard(item[0], item[1], item[2], item[4])) .join(""); const indexes = nextIndexes(hourly.time || [], 24); const temps = indexes.map((index) => hourly.temperature_2m?.[index]).filter(isFiniteNumber); drawSpark($("todaySpark"), temps, "Temperature", "#39a8ff"); } function metricCard(label, value, suffix, hint) { return `
${escapeHtml(label)} ${escapeHtml(formatNumber(value, suffix === " in" ? 2 : 0, suffix))} ${escapeHtml(hint || "")}
`; } function renderMap(data) { const mapEl = $("weatherMap"); if (!window.L) { mapEl.textContent = "Map library unavailable."; return; } const lat = Number(data.location.latitude); const lon = Number(data.location.longitude); if (!Number.isFinite(lat) || !Number.isFinite(lon)) { return; } if (!state.map) { state.map = L.map("weatherMap", { preferCanvas: true, zoomControl: true }).setView([lat, lon], 7); state.baseLayer = L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", { maxZoom: 19, attribution: "© OpenStreetMap contributors" }).addTo(state.map); } else { state.map.setView([lat, lon], state.map.getZoom() || 7); } Object.values(state.layers).forEach((layer) => { if (layer && state.map.hasLayer(layer)) { state.map.removeLayer(layer); } }); state.layers = {}; const current = data.forecast.current || {}; const air = data.air_quality.current || {}; state.layers.marker = L.marker([lat, lon]).addTo(state.map).bindPopup(` ${escapeHtml(data.location.label)}
${escapeHtml(formatNumber(current.temperature_2m, 0, " F"))}, ${escapeHtml(labelForCode(current.weather_code, data))}
Wind ${escapeHtml(formatNumber(current.wind_speed_10m, 0, " mph"))}
AQI ${escapeHtml(formatNumber(air.us_aqi, 0, ""))} `); if (state.prefs.layers.radar) { addRadarLayer(data.rainviewer); } else { $("radarTime").textContent = "Radar hidden"; } if (state.prefs.layers.aqi && isFiniteNumber(air.us_aqi)) { state.layers.aqi = L.circle([lat, lon], { radius: 22000, color: aqiColor(air.us_aqi), fillColor: aqiColor(air.us_aqi), fillOpacity: 0.18, weight: 2 }).addTo(state.map).bindPopup(`AQI ${formatNumber(air.us_aqi, 0, "")}: ${aqiLabel(air.us_aqi)}`); } if (state.prefs.layers.rain && isFiniteNumber(current.precipitation)) { const rainRadius = 10000 + (Number(current.precipitation) * 60000); state.layers.rain = L.circle([lat, lon], { radius: Math.max(10000, rainRadius), color: "#39a8ff", fillColor: "#39a8ff", fillOpacity: 0.16, weight: 2 }).addTo(state.map).bindPopup(`Precipitation ${formatNumber(current.precipitation, 2, " in")}`); } if (state.prefs.layers.clouds && isFiniteNumber(current.cloud_cover)) { state.layers.clouds = L.circle([lat, lon], { radius: 12000 + (Number(current.cloud_cover) * 450), color: "#f7fbff", fillColor: "#9fb0c4", fillOpacity: 0.1, weight: 1 }).addTo(state.map).bindPopup(`Cloud cover ${formatNumber(current.cloud_cover, 0, "%")}`); } if (state.prefs.layers.wind && isFiniteNumber(current.wind_speed_10m)) { const icon = L.divIcon({ className: "", html: `
${escapeHtml(formatNumber(current.wind_speed_10m, 0, ""))}
`, iconSize: [44, 44], iconAnchor: [22, 22] }); state.layers.wind = L.marker([lat + 0.12, lon + 0.12], { icon }).addTo(state.map) .bindPopup(`Wind ${formatNumber(current.wind_speed_10m, 0, " mph")} from ${formatNumber(current.wind_direction_10m, 0, " deg")}`); } window.setTimeout(() => state.map.invalidateSize(), 120); } function addRadarLayer(rainviewer) { const frames = [ ...((rainviewer?.radar?.past) || []), ...((rainviewer?.radar?.nowcast) || []) ]; const frame = frames.length ? frames[frames.length - 1] : null; if (!frame || !rainviewer.host || !frame.path) { $("radarTime").textContent = "Radar unavailable"; return; } const tileUrl = `${rainviewer.host}${frame.path}/256/{z}/{x}/{y}/2/1_1.png`; state.layers.radar = L.tileLayer(tileUrl, { tileSize: 256, opacity: 0.68, maxNativeZoom: 7, maxZoom: 12, attribution: "Radar: RainViewer" }).addTo(state.map); $("radarTime").textContent = formatDateTime(new Date(frame.time * 1000).toISOString()); } function renderHourly(data) { const hourly = data.forecast.hourly || {}; const indexes = nextIndexes(hourly.time || [], 48); const columns = [ ["Time", (i) => formatHour(hourly.time?.[i])], ["Weather", (i) => labelForCode(hourly.weather_code?.[i], data)], ["Temp", (i) => formatNumber(hourly.temperature_2m?.[i], 0, " F")] ]; if (state.prefs.metrics.humidity) { columns.push(["Humidity", (i) => formatNumber(hourly.relative_humidity_2m?.[i], 0, "%")]); } if (state.prefs.metrics.precip) { columns.push(["Chance", (i) => formatNumber(hourly.precipitation_probability?.[i], 0, "%")]); columns.push(["Precip", (i) => formatNumber(hourly.precipitation?.[i], 2, " in")]); } if (state.prefs.metrics.wind) { columns.push(["Wind", (i) => formatNumber(hourly.wind_speed_10m?.[i], 0, " mph")]); columns.push(["Gust", (i) => formatNumber(hourly.wind_gusts_10m?.[i], 0, " mph")]); } if (state.prefs.metrics.pressure) { columns.push(["Pressure", (i) => formatNumber(hourly.pressure_msl?.[i], 0, " hPa")]); } if (state.prefs.metrics.uv) { columns.push(["UV", (i) => formatNumber(hourly.uv_index?.[i], 1, "")]); } $("hourlyHead").innerHTML = `${columns.map((column) => `${escapeHtml(column[0])}`).join("")}`; $("hourlyBody").innerHTML = indexes.map((index) => { return `${columns.map((column) => `${escapeHtml(column[1](index))}`).join("")}`; }).join(""); } function renderDaily(data) { const daily = data.forecast.daily || {}; const start = daily.time?.length > 1 ? 1 : 0; const cards = []; for (let i = start; i < Math.min((daily.time || []).length, start + 16); i += 1) { cards.push(`

${escapeHtml(formatDay(daily.time?.[i]))}

${escapeHtml(labelForCode(daily.weather_code?.[i], data))}

${escapeHtml(formatNumber(daily.temperature_2m_max?.[i], 0, " F"))} ${escapeHtml(formatNumber(daily.temperature_2m_min?.[i], 0, " F"))}

Rain ${escapeHtml(formatNumber(daily.precipitation_sum?.[i], 2, " in"))} · Wind ${escapeHtml(formatNumber(daily.wind_speed_10m_max?.[i], 0, " mph"))}

`); } $("dailyGrid").innerHTML = cards.join(""); } function renderWeekly(data) { const daily = data.forecast.daily || {}; const start = daily.time?.length > 1 ? 1 : 0; const indexes = []; for (let i = start; i < Math.min((daily.time || []).length, start + 16); i += 1) { indexes.push(i); } const groups = []; for (let i = 0; i < indexes.length; i += 7) { groups.push(indexes.slice(i, i + 7)); } $("weeklyGrid").innerHTML = groups.map((group, index) => { const highs = group.map((i) => daily.temperature_2m_max?.[i]).filter(isFiniteNumber); const lows = group.map((i) => daily.temperature_2m_min?.[i]).filter(isFiniteNumber); const precip = sum(group.map((i) => daily.precipitation_sum?.[i])); const wind = max(group.map((i) => daily.wind_speed_10m_max?.[i])); return `

Week ${index + 1}

${escapeHtml(formatDay(daily.time?.[group[0]]))} to ${escapeHtml(formatDay(daily.time?.[group[group.length - 1]]))}

${escapeHtml(formatNumber(avg(highs), 0, " F avg high"))} ${escapeHtml(formatNumber(avg(lows), 0, " F avg low"))}

Total precip ${escapeHtml(formatNumber(precip, 2, " in"))}

Peak wind ${escapeHtml(formatNumber(wind, 0, " mph"))}

`; }).join(""); } function renderAir(data) { const current = data.air_quality.current || {}; const hourly = data.air_quality.hourly || {}; const pollen = latestAirValue(hourly.time, hourly.grass_pollen); const cards = [ ["AQI", current.us_aqi, "", aqiLabel(current.us_aqi), "metrics.aqi"], ["PM10", current.pm10, " ug/m3", "Coarse particles", "metrics.aqi"], ["PM2.5", current.pm2_5, " ug/m3", "Fine particles", "metrics.aqi"], ["Ozone", current.ozone, " ug/m3", "O3", "metrics.aqi"], ["NO2", current.nitrogen_dioxide, " ug/m3", "Nitrogen dioxide", "metrics.aqi"], ["CO", current.carbon_monoxide, " ug/m3", "Carbon monoxide", "metrics.aqi"], ["UV", current.uv_index, "", "Current index", "metrics.uv"], ["Grass pollen", pollen, "", "Nearest forecast", "metrics.pollen"] ]; $("airGrid").innerHTML = cards .filter((item) => getPath(state.prefs, item[4])) .map((item) => `

${escapeHtml(item[0])}

${escapeHtml(formatNumber(item[1], item[2] === "" ? 0 : 1, item[2]))}

${escapeHtml(item[3] || "")}

`) .join(""); const indexes = nextIndexes(hourly.time || [], 48); const aqiValues = indexes.map((index) => hourly.us_aqi?.[index]).filter(isFiniteNumber); drawSpark($("aqiSpark"), aqiValues, "AQI", "#45d483"); } function renderHistory(data) { $("historyDate").textContent = data.history.comparison_date || "Current date"; const facts = data.history.facts || []; $("historyFacts").innerHTML = facts.map((fact) => `
  • ${escapeHtml(fact)}
  • `).join(""); } async function loadArchives() { if (!state.prefs.sections.archives) { return; } const params = new URLSearchParams({ action: "archives", q: $("archiveQuery").value.trim(), kind: $("archiveKind").value, start: $("archiveStart").value, end: $("archiveEnd").value, limit: "80" }); try { const response = await fetch(`api.php?${params.toString()}`); const payload = await response.json(); if (!payload.ok) { throw new Error(payload.error || "Archive search failed."); } state.lastArchives = payload.archives.items || []; renderArchives(); } catch (error) { setStatus(error.message, true); } } function renderArchives() { const list = $("archiveList"); if (!state.lastArchives.length) { list.innerHTML = `

    No archive snapshots found.

    `; return; } list.innerHTML = state.lastArchives.map((item) => { const summary = item.summary || {}; return `

    ${escapeHtml(item.location_name)}

    ${escapeHtml(formatDateTime(item.observed_at))} · ${escapeHtml(summary.weather_label || "Weather snapshot")}

    ${escapeHtml(item.kind)} ${escapeHtml(formatNumber(summary.temperature, 0, " F"))} Wind ${escapeHtml(formatNumber(summary.wind_speed, 0, " mph"))} AQI ${escapeHtml(formatNumber(summary.us_aqi, 0, ""))}
    `; }).join(""); list.querySelectorAll("[data-archive-id]").forEach((button) => { button.addEventListener("click", () => openArchive(Number(button.dataset.archiveId))); }); if (window.lucide) { window.lucide.createIcons(); } } async function openArchive(id) { try { const response = await fetch(`api.php?action=archive&id=${encodeURIComponent(id)}`); const payload = await response.json(); if (!payload.ok) { throw new Error(payload.error || "Archive load failed."); } $("archiveDialogTitle").textContent = `${payload.archive.location_name} ${payload.archive.kind}`; $("archiveDialogBody").textContent = JSON.stringify(payload.archive, null, 2); $("archiveDialog").showModal(); } catch (error) { setStatus(error.message, true); } } function applyPreferences() { document.body.classList.toggle("light", state.prefs.theme === "light"); $("themeLabel").textContent = state.prefs.theme === "light" ? "Dark" : "Light"; document.querySelectorAll("[data-pref]").forEach((input) => { input.checked = Boolean(getPath(state.prefs, input.dataset.pref)); }); Object.entries(state.prefs.sections).forEach(([name, visible]) => { document.querySelectorAll(`[data-section="${name}"]`).forEach((section) => { section.classList.toggle("is-hidden", !visible); }); }); if (state.map) { window.setTimeout(() => state.map.invalidateSize(), 120); } } function toggleTheme() { state.prefs.theme = state.prefs.theme === "light" ? "dark" : "light"; savePrefs(); applyPreferences(); if (state.dashboard) { renderDashboard(); } } function loadPrefs() { const raw = readCookie("weather_preferences"); if (!raw) { return clone(DEFAULT_PREFS); } try { return mergeDeep(clone(DEFAULT_PREFS), JSON.parse(raw)); } catch (error) { return clone(DEFAULT_PREFS); } } function savePrefs() { writeCookie("weather_preferences", JSON.stringify(state.prefs), 365); } function exportPreferences() { downloadJson("weather-preferences.json", { exported_at: new Date().toISOString(), preferences: state.prefs, location: state.dashboard?.location || null }); } async function importPreferences(event) { const file = event.target.files?.[0]; event.target.value = ""; if (!file) { return; } try { const text = await file.text(); const parsed = JSON.parse(text); const importedPrefs = parsed.preferences || parsed; state.prefs = mergeDeep(clone(DEFAULT_PREFS), importedPrefs); $("locationInput").value = state.prefs.locationQuery || DEFAULT_PREFS.locationQuery; savePrefs(); applyPreferences(); loadWeather(false); } catch (error) { setStatus("Preference import failed.", true); } } function exportArchives() { downloadJson("weather-archives.json", { exported_at: new Date().toISOString(), location: state.dashboard?.location || null, archives: state.lastArchives }); } function setStatus(message, isError) { const line = $("statusLine"); line.textContent = message; line.style.color = isError ? "var(--red)" : "var(--muted)"; } function drawSpark(canvas, values, label, color) { const ctx = canvas.getContext("2d"); const ratio = window.devicePixelRatio || 1; const width = canvas.clientWidth || canvas.width; const height = canvas.clientHeight || canvas.height; canvas.width = Math.max(1, Math.floor(width * ratio)); canvas.height = Math.max(1, Math.floor(height * ratio)); ctx.setTransform(ratio, 0, 0, ratio, 0, 0); ctx.clearRect(0, 0, width, height); ctx.fillStyle = getComputedStyle(document.body).getPropertyValue("--panel-2"); ctx.fillRect(0, 0, width, height); ctx.strokeStyle = "rgba(255,255,255,0.12)"; ctx.lineWidth = 1; for (let i = 1; i < 4; i += 1) { const y = (height / 4) * i; ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(width, y); ctx.stroke(); } const clean = values.filter(isFiniteNumber); if (clean.length < 2) { ctx.fillStyle = getComputedStyle(document.body).getPropertyValue("--muted"); ctx.fillText("No trend data", 12, 24); return; } const minValue = Math.min(...clean); const maxValue = Math.max(...clean); const spread = Math.max(1, maxValue - minValue); const pad = 18; ctx.strokeStyle = color; ctx.lineWidth = 3; ctx.beginPath(); clean.forEach((value, index) => { const x = pad + (index / (clean.length - 1)) * (width - pad * 2); const y = height - pad - ((value - minValue) / spread) * (height - pad * 2); if (index === 0) { ctx.moveTo(x, y); } else { ctx.lineTo(x, y); } }); ctx.stroke(); ctx.fillStyle = color; ctx.font = "700 12px Inter, system-ui, sans-serif"; ctx.fillText(`${label}: ${Math.round(minValue)} to ${Math.round(maxValue)}`, 12, 20); } function nextIndexes(times, count) { const now = Date.now(); const indexes = []; const source = Array.isArray(times) ? times : []; let start = source.findIndex((time) => new Date(time).getTime() >= now - 3600000); if (start < 0) { start = 0; } for (let i = start; i < source.length && indexes.length < count; i += 1) { indexes.push(i); } return indexes; } function firstFuture(times, values) { if (!Array.isArray(times) || !Array.isArray(values)) { return null; } const indexes = nextIndexes(times, 1); return indexes.length ? values[indexes[0]] : null; } function latestAirValue(times, values) { return firstFuture(times, values); } function labelForCode(code, data) { if (code === null || code === undefined) { return "Weather"; } const key = String(code); return data?.weather_codes?.[key] || WEATHER_CODES[key] || "Weather"; } function formatNumber(value, digits, suffix) { if (!isFiniteNumber(value)) { return "--"; } return `${Number(value).toFixed(digits)}${suffix}`; } function formatDateTime(value) { if (!value) { return "--"; } const date = new Date(value); if (Number.isNaN(date.getTime())) { return String(value); } return new Intl.DateTimeFormat(undefined, { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" }).format(date); } function formatHour(value) { if (!value) { return "--"; } const date = new Date(value); if (Number.isNaN(date.getTime())) { return String(value); } return new Intl.DateTimeFormat(undefined, { weekday: "short", hour: "numeric" }).format(date); } function formatDay(value) { if (!value) { return "--"; } const date = new Date(`${value}T12:00:00`); if (Number.isNaN(date.getTime())) { return String(value); } return new Intl.DateTimeFormat(undefined, { weekday: "short", month: "short", day: "numeric" }).format(date); } function aqiLabel(value) { if (!isFiniteNumber(value)) { return "AQI unavailable"; } const aqi = Number(value); if (aqi <= 50) { return "Good"; } if (aqi <= 100) { return "Moderate"; } if (aqi <= 150) { return "Unhealthy for sensitive groups"; } if (aqi <= 200) { return "Unhealthy"; } if (aqi <= 300) { return "Very unhealthy"; } return "Hazardous"; } function aqiColor(value) { if (!isFiniteNumber(value)) { return "#9fb0c4"; } const aqi = Number(value); if (aqi <= 50) { return "#45d483"; } if (aqi <= 100) { return "#ffdf4d"; } if (aqi <= 150) { return "#ff9f43"; } if (aqi <= 200) { return "#ff595e"; } if (aqi <= 300) { return "#9c6dff"; } return "#7f1d1d"; } function sum(values) { return values.filter(isFiniteNumber).reduce((total, value) => total + Number(value), 0); } function max(values) { const clean = values.filter(isFiniteNumber).map(Number); return clean.length ? Math.max(...clean) : null; } function avg(values) { const clean = values.filter(isFiniteNumber).map(Number); return clean.length ? clean.reduce((total, value) => total + value, 0) / clean.length : null; } function isFiniteNumber(value) { return value !== null && value !== undefined && value !== "" && Number.isFinite(Number(value)); } function getPath(object, path) { return String(path).split(".").reduce((cursor, part) => { if (!cursor || typeof cursor !== "object") { return undefined; } return cursor[part]; }, object); } function setPath(object, path, value) { const parts = String(path).split("."); let cursor = object; parts.slice(0, -1).forEach((part) => { if (!cursor[part] || typeof cursor[part] !== "object") { cursor[part] = {}; } cursor = cursor[part]; }); cursor[parts[parts.length - 1]] = value; } function mergeDeep(target, source) { if (!source || typeof source !== "object") { return target; } Object.entries(source).forEach(([key, value]) => { if (value && typeof value === "object" && !Array.isArray(value)) { target[key] = mergeDeep(target[key] || {}, value); } else { target[key] = value; } }); return target; } function clone(value) { return JSON.parse(JSON.stringify(value)); } function readCookie(name) { const match = document.cookie.split("; ").find((row) => row.startsWith(`${name}=`)); if (!match) { return null; } return decodeURIComponent(match.substring(name.length + 1)); } function writeCookie(name, value, days) { const expires = new Date(Date.now() + days * 86400000).toUTCString(); document.cookie = `${name}=${encodeURIComponent(value)}; expires=${expires}; path=/; SameSite=Lax`; } function downloadJson(filename, data) { const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" }); const url = URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; link.download = filename; document.body.appendChild(link); link.click(); link.remove(); URL.revokeObjectURL(url); } function escapeHtml(value) { return String(value) .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """) .replaceAll("'", "'"); } }());