1000 lines
36 KiB
JavaScript
1000 lines
36 KiB
JavaScript
(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 `
|
|
<article class="metric-card">
|
|
<span class="label">${escapeHtml(label)}</span>
|
|
<strong class="value">${escapeHtml(formatNumber(value, suffix === " in" ? 2 : 0, suffix))}</strong>
|
|
<span class="hint">${escapeHtml(hint || "")}</span>
|
|
</article>
|
|
`;
|
|
}
|
|
|
|
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(`
|
|
<strong>${escapeHtml(data.location.label)}</strong><br>
|
|
${escapeHtml(formatNumber(current.temperature_2m, 0, " F"))}, ${escapeHtml(labelForCode(current.weather_code, data))}<br>
|
|
Wind ${escapeHtml(formatNumber(current.wind_speed_10m, 0, " mph"))}<br>
|
|
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: `<div class="wind-arrow" title="Wind">${escapeHtml(formatNumber(current.wind_speed_10m, 0, ""))}</div>`,
|
|
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 = `<tr>${columns.map((column) => `<th>${escapeHtml(column[0])}</th>`).join("")}</tr>`;
|
|
$("hourlyBody").innerHTML = indexes.map((index) => {
|
|
return `<tr>${columns.map((column) => `<td>${escapeHtml(column[1](index))}</td>`).join("")}</tr>`;
|
|
}).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(`
|
|
<article class="mini-card">
|
|
<h3>${escapeHtml(formatDay(daily.time?.[i]))}</h3>
|
|
<p>${escapeHtml(labelForCode(daily.weather_code?.[i], data))}</p>
|
|
<div class="temp-range">
|
|
<span>${escapeHtml(formatNumber(daily.temperature_2m_max?.[i], 0, " F"))}</span>
|
|
<span>${escapeHtml(formatNumber(daily.temperature_2m_min?.[i], 0, " F"))}</span>
|
|
</div>
|
|
<div class="range-bar"></div>
|
|
<p>Rain ${escapeHtml(formatNumber(daily.precipitation_sum?.[i], 2, " in"))} · Wind ${escapeHtml(formatNumber(daily.wind_speed_10m_max?.[i], 0, " mph"))}</p>
|
|
</article>
|
|
`);
|
|
}
|
|
|
|
$("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 `
|
|
<article class="mini-card">
|
|
<h3>Week ${index + 1}</h3>
|
|
<p>${escapeHtml(formatDay(daily.time?.[group[0]]))} to ${escapeHtml(formatDay(daily.time?.[group[group.length - 1]]))}</p>
|
|
<div class="temp-range">
|
|
<span>${escapeHtml(formatNumber(avg(highs), 0, " F avg high"))}</span>
|
|
<span>${escapeHtml(formatNumber(avg(lows), 0, " F avg low"))}</span>
|
|
</div>
|
|
<p>Total precip ${escapeHtml(formatNumber(precip, 2, " in"))}</p>
|
|
<p>Peak wind ${escapeHtml(formatNumber(wind, 0, " mph"))}</p>
|
|
</article>
|
|
`;
|
|
}).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) => `
|
|
<article class="mini-card">
|
|
<h3>${escapeHtml(item[0])}</h3>
|
|
<div class="temp-range">
|
|
<span>${escapeHtml(formatNumber(item[1], item[2] === "" ? 0 : 1, item[2]))}</span>
|
|
</div>
|
|
<p>${escapeHtml(item[3] || "")}</p>
|
|
</article>
|
|
`)
|
|
.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) => `<li>${escapeHtml(fact)}</li>`).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 = `<div class="mini-card"><p>No archive snapshots found.</p></div>`;
|
|
return;
|
|
}
|
|
|
|
list.innerHTML = state.lastArchives.map((item) => {
|
|
const summary = item.summary || {};
|
|
return `
|
|
<article class="archive-card">
|
|
<div>
|
|
<h3>${escapeHtml(item.location_name)}</h3>
|
|
<p>${escapeHtml(formatDateTime(item.observed_at))} · ${escapeHtml(summary.weather_label || "Weather snapshot")}</p>
|
|
<div class="archive-meta">
|
|
<span class="chip">${escapeHtml(item.kind)}</span>
|
|
<span class="chip">${escapeHtml(formatNumber(summary.temperature, 0, " F"))}</span>
|
|
<span class="chip">Wind ${escapeHtml(formatNumber(summary.wind_speed, 0, " mph"))}</span>
|
|
<span class="chip">AQI ${escapeHtml(formatNumber(summary.us_aqi, 0, ""))}</span>
|
|
</div>
|
|
</div>
|
|
<button type="button" data-archive-id="${Number(item.id)}">
|
|
<i data-lucide="eye"></i>
|
|
<span>View</span>
|
|
</button>
|
|
</article>
|
|
`;
|
|
}).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("'", "'");
|
|
}
|
|
}());
|