- initial

This commit is contained in:
Ty Clifford
2026-06-30 19:48:01 -04:00
commit c69d2b147b
9 changed files with 3032 additions and 0 deletions
+714
View File
@@ -0,0 +1,714 @@
:root {
color-scheme: dark;
--bg: #071019;
--bg-soft: #0d1827;
--panel: #101b2a;
--panel-2: #132235;
--line: rgba(255, 255, 255, 0.12);
--text: #f7fbff;
--muted: #9fb0c4;
--blue: #39a8ff;
--green: #45d483;
--pink: #ff5aa5;
--purple: #9c6dff;
--orange: #ff9f43;
--red: #ff595e;
--shadow: rgba(0, 0, 0, 0.28);
--radius: 8px;
--control-h: 42px;
--font: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
body.light {
color-scheme: light;
--bg: #f5f8fb;
--bg-soft: #e8eef6;
--panel: #ffffff;
--panel-2: #eef4fa;
--line: rgba(21, 33, 52, 0.13);
--text: #152134;
--muted: #64748b;
--shadow: rgba(25, 38, 60, 0.14);
}
* {
box-sizing: border-box;
}
html,
body {
min-height: 100%;
}
body {
margin: 0;
background:
linear-gradient(135deg, rgba(57, 168, 255, 0.16), transparent 34%),
linear-gradient(225deg, rgba(255, 90, 165, 0.13), transparent 38%),
var(--bg);
color: var(--text);
font-family: var(--font);
letter-spacing: 0;
}
button,
input,
select {
font: inherit;
}
button {
min-height: var(--control-h);
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--panel-2);
color: var(--text);
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 0 14px;
white-space: nowrap;
}
button:hover {
border-color: rgba(57, 168, 255, 0.55);
}
button svg,
.search svg {
width: 17px;
height: 17px;
flex: 0 0 auto;
}
input,
select {
min-height: var(--control-h);
border: 1px solid var(--line);
border-radius: var(--radius);
background: rgba(255, 255, 255, 0.06);
color: var(--text);
padding: 0 12px;
width: 100%;
min-width: 0;
}
select option {
color: #111827;
}
input::placeholder {
color: var(--muted);
}
.app-shell {
width: min(1720px, 100%);
min-height: 100vh;
margin: 0 auto;
padding: 18px;
}
.topbar {
display: grid;
grid-template-columns: minmax(220px, 0.7fr) minmax(320px, 1.1fr) auto;
gap: 16px;
align-items: center;
margin-bottom: 16px;
}
.brand {
min-width: 0;
display: flex;
gap: 12px;
align-items: center;
}
.brand-mark {
width: 42px;
height: 42px;
border-radius: 50%;
flex: 0 0 auto;
background:
conic-gradient(from 40deg, var(--blue), var(--green), var(--orange), var(--pink), var(--purple), var(--red), var(--blue));
box-shadow: 0 0 26px rgba(57, 168, 255, 0.3);
}
h1,
h2,
h3,
p {
margin: 0;
}
h1 {
font-size: 1.55rem;
line-height: 1.05;
}
.brand p {
color: var(--muted);
margin-top: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.search {
position: relative;
min-width: 0;
}
.search-box {
display: grid;
grid-template-columns: 22px minmax(0, 1fr) auto;
gap: 10px;
align-items: center;
min-height: 52px;
padding: 5px 6px 5px 14px;
border: 1px solid var(--line);
border-radius: var(--radius);
background: rgba(255, 255, 255, 0.07);
box-shadow: 0 12px 30px var(--shadow);
}
.search-box input {
border: 0;
background: transparent;
padding: 0;
}
.search-box input:focus {
outline: 0;
}
.search-results {
position: absolute;
z-index: 900;
top: calc(100% + 8px);
left: 0;
right: 0;
overflow: hidden;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--panel);
box-shadow: 0 18px 40px var(--shadow);
}
.search-result {
width: 100%;
justify-content: flex-start;
border: 0;
border-bottom: 1px solid var(--line);
border-radius: 0;
background: transparent;
min-height: 48px;
}
.search-result:last-child {
border-bottom: 0;
}
.top-actions {
display: flex;
gap: 10px;
justify-content: flex-end;
}
.layout {
display: grid;
grid-template-columns: 260px minmax(0, 1fr);
gap: 16px;
align-items: start;
}
.control-panel,
.data-section,
.current-card,
.metric-card,
.map-section,
.status-line {
border: 1px solid var(--line);
border-radius: var(--radius);
background: rgba(16, 27, 42, 0.86);
box-shadow: 0 14px 36px var(--shadow);
}
body.light .control-panel,
body.light .data-section,
body.light .current-card,
body.light .metric-card,
body.light .map-section,
body.light .status-line {
background: rgba(255, 255, 255, 0.88);
}
.control-panel {
position: sticky;
top: 16px;
padding: 14px;
}
.panel-heading,
.section-title,
.dialog-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.panel-heading {
padding-bottom: 12px;
border-bottom: 1px solid var(--line);
}
.panel-heading h2,
.section-title h2 {
font-size: 1rem;
}
.panel-heading span,
.section-title span {
color: var(--muted);
font-size: 0.88rem;
}
.control-group {
padding: 14px 0;
display: grid;
gap: 8px;
border-bottom: 1px solid var(--line);
}
.control-group h3 {
font-size: 0.78rem;
text-transform: uppercase;
color: var(--muted);
}
.control-group label {
display: flex;
align-items: center;
gap: 9px;
min-height: 26px;
color: var(--text);
}
.control-group input[type="checkbox"] {
width: 16px;
min-height: 16px;
accent-color: var(--pink);
}
.control-actions {
display: grid;
gap: 10px;
padding-top: 14px;
}
.content {
display: grid;
gap: 16px;
min-width: 0;
}
.status-line {
min-height: 44px;
padding: 11px 14px;
color: var(--muted);
}
.section-grid {
display: grid;
grid-template-columns: minmax(280px, 0.92fr) minmax(320px, 1.08fr);
gap: 16px;
min-width: 0;
}
.two-column {
grid-template-columns: minmax(320px, 1fr) minmax(320px, 1fr);
}
.current-card {
padding: 18px;
min-width: 0;
}
.main-current {
min-height: 250px;
display: grid;
grid-template-columns: minmax(210px, 0.9fr) minmax(240px, 1.1fr);
gap: 16px;
align-items: center;
}
.eyebrow {
color: var(--green);
font-size: 0.78rem;
font-weight: 700;
text-transform: uppercase;
}
#currentTemp {
font-size: clamp(3rem, 8vw, 6rem);
line-height: 0.95;
margin: 8px 0;
}
#currentSummary {
color: var(--muted);
font-size: 1rem;
line-height: 1.45;
}
canvas {
display: block;
width: 100%;
max-width: 100%;
}
.metric-grid {
display: grid;
grid-template-columns: repeat(3, minmax(120px, 1fr));
gap: 12px;
}
.metric-card {
min-height: 119px;
padding: 14px;
display: grid;
align-content: space-between;
}
.metric-card .label {
color: var(--muted);
font-size: 0.82rem;
}
.metric-card .value {
font-size: 1.45rem;
line-height: 1.1;
font-weight: 800;
}
.metric-card .hint {
color: var(--muted);
font-size: 0.78rem;
}
.map-section,
.data-section {
padding: 14px;
min-width: 0;
}
.weather-map {
min-height: 480px;
height: 58vh;
max-height: 720px;
width: 100%;
margin-top: 12px;
overflow: hidden;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--bg-soft);
}
.leaflet-container {
background: var(--bg-soft);
font-family: var(--font);
}
.map-badge {
display: grid;
place-items: center;
width: 38px;
height: 38px;
border-radius: 50%;
border: 2px solid #ffffff;
color: #ffffff;
font-weight: 800;
box-shadow: 0 8px 22px rgba(0, 0, 0, 0.35);
}
.wind-arrow {
display: grid;
place-items: center;
width: 44px;
height: 44px;
border-radius: 50%;
border: 2px solid #ffffff;
background: #9c6dff;
color: #ffffff;
font-weight: 900;
box-shadow: 0 8px 22px rgba(0, 0, 0, 0.35);
}
.scroll-table {
margin-top: 12px;
overflow: auto;
border: 1px solid var(--line);
border-radius: var(--radius);
}
table {
border-collapse: collapse;
width: 100%;
min-width: 760px;
}
th,
td {
padding: 10px 12px;
text-align: left;
border-bottom: 1px solid var(--line);
}
th {
color: var(--muted);
font-weight: 700;
font-size: 0.78rem;
text-transform: uppercase;
background: rgba(255, 255, 255, 0.04);
}
td {
font-size: 0.92rem;
}
tbody tr:hover {
background: rgba(57, 168, 255, 0.08);
}
.daily-grid,
.weekly-grid,
.air-grid,
.archive-list {
display: grid;
gap: 12px;
margin-top: 12px;
}
.daily-grid {
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
}
.weekly-grid {
grid-template-columns: repeat(auto-fit, minmax(230px, 1fr));
}
.air-grid {
grid-template-columns: repeat(auto-fit, minmax(135px, 1fr));
}
.mini-card,
.archive-card {
border: 1px solid var(--line);
border-radius: var(--radius);
background: rgba(255, 255, 255, 0.055);
padding: 12px;
min-width: 0;
}
.mini-card h3,
.archive-card h3 {
font-size: 0.98rem;
margin-bottom: 8px;
}
.mini-card p,
.archive-card p {
color: var(--muted);
line-height: 1.45;
font-size: 0.88rem;
}
.temp-range {
display: flex;
justify-content: space-between;
gap: 8px;
margin: 9px 0;
font-weight: 800;
}
.range-bar {
height: 8px;
border-radius: 999px;
background: linear-gradient(90deg, var(--blue), var(--green), var(--orange), var(--red));
}
.fact-list {
display: grid;
gap: 10px;
margin: 12px 0 0;
padding: 0;
list-style: none;
}
.fact-list li {
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 12px;
background: rgba(255, 255, 255, 0.055);
line-height: 1.45;
}
.archive-search {
display: grid;
grid-template-columns: minmax(180px, 1fr) 130px 150px 150px auto;
gap: 10px;
margin-top: 12px;
}
.archive-card {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 12px;
align-items: center;
}
.archive-meta {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-top: 8px;
}
.chip {
border: 1px solid var(--line);
border-radius: 999px;
color: var(--muted);
padding: 4px 8px;
font-size: 0.78rem;
}
dialog {
width: min(920px, calc(100vw - 28px));
max-height: calc(100vh - 28px);
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--panel);
color: var(--text);
box-shadow: 0 22px 70px rgba(0, 0, 0, 0.45);
}
dialog::backdrop {
background: rgba(0, 0, 0, 0.58);
}
.round-button {
width: 42px;
padding: 0;
}
pre {
max-height: 65vh;
overflow: auto;
white-space: pre-wrap;
color: var(--text);
background: rgba(0, 0, 0, 0.18);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 12px;
}
[hidden],
.is-hidden {
display: none !important;
}
@media (max-width: 1120px) {
.topbar {
grid-template-columns: 1fr;
}
.top-actions {
justify-content: flex-start;
}
.layout {
grid-template-columns: 1fr;
}
.control-panel {
position: static;
}
.control-panel {
display: grid;
grid-template-columns: repeat(3, minmax(160px, 1fr));
gap: 12px;
}
.panel-heading,
.control-actions {
grid-column: 1 / -1;
}
.control-group {
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 12px;
}
}
@media (max-width: 880px) {
.section-grid,
.two-column,
.main-current {
grid-template-columns: 1fr;
}
.metric-grid {
grid-template-columns: repeat(2, minmax(120px, 1fr));
}
.archive-search {
grid-template-columns: 1fr 1fr;
}
.archive-search button {
grid-column: 1 / -1;
}
}
@media (max-width: 640px) {
.app-shell {
padding: 12px;
}
.search-box {
grid-template-columns: 20px minmax(0, 1fr);
}
.search-box button {
grid-column: 1 / -1;
width: 100%;
}
.top-actions,
.control-actions {
display: grid;
grid-template-columns: 1fr 1fr;
}
.control-panel {
grid-template-columns: 1fr;
}
.metric-grid,
.archive-search {
grid-template-columns: 1fr;
}
.weather-map {
height: 420px;
min-height: 360px;
}
.archive-card {
grid-template-columns: 1fr;
}
}
+999
View File
@@ -0,0 +1,999 @@
(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: "&copy; 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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
}());