- initial
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
/data/*.sqlite
|
||||
/data/*.sqlite-*
|
||||
.DS_Store
|
||||
@@ -0,0 +1,36 @@
|
||||
# Weather
|
||||
|
||||
A PHP, SQLite, HTML5, and JavaScript weather dashboard that uses public weather APIs for forecasts, air quality, radar, historical comparison facts, local caching, and searchable archives.
|
||||
|
||||
## Features
|
||||
|
||||
- Search by city/state or ZIP code.
|
||||
- Open-Meteo forecast data with current, hourly, daily, and 16-day projections.
|
||||
- Open-Meteo air quality data with AQI, pollutants, UV, and pollen forecast values.
|
||||
- RainViewer radar tiles on an interactive Leaflet map.
|
||||
- Local SQLite API cache with a five minute weather refresh window.
|
||||
- Hourly and daily archive snapshots with search and JSON detail export.
|
||||
- Cookie-backed display preferences, map layer toggles, dark/light theme, and import/export.
|
||||
- Current-date history facts comparing the projection to the same date last year.
|
||||
|
||||
## Run
|
||||
|
||||
Use any PHP server with SQLite/PDO enabled and point the document root at `public`.
|
||||
|
||||
```sh
|
||||
php -S 127.0.0.1:8080 -t public
|
||||
```
|
||||
|
||||
This environment does not include a system `php` binary, but it does have FrankenPHP:
|
||||
|
||||
```sh
|
||||
frankenphp php-server --root public --listen 127.0.0.1:8080
|
||||
```
|
||||
|
||||
The SQLite database is created automatically at `data/weather.sqlite`.
|
||||
|
||||
## APIs
|
||||
|
||||
- Forecast, geocoding, air quality, and historical comparison data: Open-Meteo.
|
||||
- Radar tiles: RainViewer.
|
||||
- Base map: OpenStreetMap tile layer through Leaflet.
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/src/bootstrap.php';
|
||||
|
||||
try {
|
||||
$action = (string) request_value('action', 'weather');
|
||||
$service = weather_service();
|
||||
|
||||
switch ($action) {
|
||||
case 'search':
|
||||
json_response([
|
||||
'ok' => true,
|
||||
'locations' => $service->searchLocations((string) request_value('q', '')),
|
||||
]);
|
||||
break;
|
||||
|
||||
case 'weather':
|
||||
json_response([
|
||||
'ok' => true,
|
||||
'dashboard' => $service->dashboard([
|
||||
'q' => request_value('q', ''),
|
||||
'lat' => request_value('lat'),
|
||||
'lon' => request_value('lon'),
|
||||
'name' => request_value('name'),
|
||||
'admin1' => request_value('admin1'),
|
||||
'country' => request_value('country'),
|
||||
'country_code' => request_value('country_code'),
|
||||
'timezone' => request_value('timezone'),
|
||||
'force' => request_value('force', false),
|
||||
]),
|
||||
]);
|
||||
break;
|
||||
|
||||
case 'archives':
|
||||
json_response([
|
||||
'ok' => true,
|
||||
'archives' => $service->listArchives([
|
||||
'q' => request_value('q', ''),
|
||||
'kind' => request_value('kind', ''),
|
||||
'start' => request_value('start', ''),
|
||||
'end' => request_value('end', ''),
|
||||
'limit' => request_value('limit', 50),
|
||||
]),
|
||||
]);
|
||||
break;
|
||||
|
||||
case 'archive':
|
||||
json_response([
|
||||
'ok' => true,
|
||||
'archive' => $service->getArchive((int) request_value('id', 0)),
|
||||
]);
|
||||
break;
|
||||
|
||||
default:
|
||||
json_response([
|
||||
'ok' => false,
|
||||
'error' => 'Unknown API action.',
|
||||
], 404);
|
||||
}
|
||||
} catch (InvalidArgumentException $error) {
|
||||
json_response([
|
||||
'ok' => false,
|
||||
'error' => $error->getMessage(),
|
||||
], 422);
|
||||
} catch (Throwable $error) {
|
||||
json_response([
|
||||
'ok' => false,
|
||||
'error' => $error->getMessage(),
|
||||
], 500);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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: "© 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("'", "'");
|
||||
}
|
||||
}());
|
||||
@@ -0,0 +1,216 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
?><!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Weather</title>
|
||||
<link rel="preconnect" href="https://unpkg.com">
|
||||
<link rel="preconnect" href="https://tile.openstreetmap.org">
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">
|
||||
<link rel="stylesheet" href="assets/css/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-shell">
|
||||
<header class="topbar">
|
||||
<div class="brand">
|
||||
<span class="brand-mark"></span>
|
||||
<div>
|
||||
<h1>Weather</h1>
|
||||
<p id="locationLabel">Search a location</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form class="search" id="searchForm" autocomplete="off">
|
||||
<div class="search-box">
|
||||
<i data-lucide="search"></i>
|
||||
<input id="locationInput" name="q" type="search" placeholder="City, state, or ZIP" aria-label="City, state, or ZIP">
|
||||
<button type="submit">
|
||||
<i data-lucide="navigation"></i>
|
||||
<span>Go</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="search-results" id="searchResults" hidden></div>
|
||||
</form>
|
||||
|
||||
<div class="top-actions">
|
||||
<button class="icon-button" type="button" id="refreshButton" title="Refresh now">
|
||||
<i data-lucide="refresh-cw"></i>
|
||||
<span>Refresh</span>
|
||||
</button>
|
||||
<button class="icon-button" type="button" id="themeButton" title="Switch theme">
|
||||
<i data-lucide="sun-moon"></i>
|
||||
<span id="themeLabel">Light</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="layout">
|
||||
<aside class="control-panel">
|
||||
<div class="panel-heading">
|
||||
<h2>Controls</h2>
|
||||
<span id="refreshCountdown">5:00</span>
|
||||
</div>
|
||||
|
||||
<div class="control-group">
|
||||
<h3>Views</h3>
|
||||
<label><input type="checkbox" data-pref="sections.current"> Current</label>
|
||||
<label><input type="checkbox" data-pref="sections.map"> Radar</label>
|
||||
<label><input type="checkbox" data-pref="sections.hourly"> Hourly</label>
|
||||
<label><input type="checkbox" data-pref="sections.daily"> Daily</label>
|
||||
<label><input type="checkbox" data-pref="sections.weekly"> Weekly</label>
|
||||
<label><input type="checkbox" data-pref="sections.air"> Air</label>
|
||||
<label><input type="checkbox" data-pref="sections.history"> History</label>
|
||||
<label><input type="checkbox" data-pref="sections.archives"> Archives</label>
|
||||
</div>
|
||||
|
||||
<div class="control-group">
|
||||
<h3>Map</h3>
|
||||
<label><input type="checkbox" data-pref="layers.radar"> Radar</label>
|
||||
<label><input type="checkbox" data-pref="layers.wind"> Wind</label>
|
||||
<label><input type="checkbox" data-pref="layers.rain"> Rain</label>
|
||||
<label><input type="checkbox" data-pref="layers.aqi"> Air quality</label>
|
||||
<label><input type="checkbox" data-pref="layers.clouds"> Clouds</label>
|
||||
</div>
|
||||
|
||||
<div class="control-group">
|
||||
<h3>Data</h3>
|
||||
<label><input type="checkbox" data-pref="metrics.humidity"> Humidity</label>
|
||||
<label><input type="checkbox" data-pref="metrics.wind"> Wind</label>
|
||||
<label><input type="checkbox" data-pref="metrics.precip"> Precipitation</label>
|
||||
<label><input type="checkbox" data-pref="metrics.pressure"> Pressure</label>
|
||||
<label><input type="checkbox" data-pref="metrics.uv"> UV</label>
|
||||
<label><input type="checkbox" data-pref="metrics.aqi"> AQI</label>
|
||||
<label><input type="checkbox" data-pref="metrics.pollen"> Pollen</label>
|
||||
</div>
|
||||
|
||||
<div class="control-actions">
|
||||
<button type="button" id="exportPrefs">
|
||||
<i data-lucide="download"></i>
|
||||
<span>Export prefs</span>
|
||||
</button>
|
||||
<button type="button" id="importPrefsButton">
|
||||
<i data-lucide="upload"></i>
|
||||
<span>Import prefs</span>
|
||||
</button>
|
||||
<input id="importPrefsFile" type="file" accept="application/json" hidden>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section class="content">
|
||||
<section class="status-line" id="statusLine" aria-live="polite">Ready</section>
|
||||
|
||||
<section class="section-grid" id="currentSection" data-section="current">
|
||||
<div class="current-card main-current">
|
||||
<div>
|
||||
<span class="eyebrow">Current</span>
|
||||
<h2 id="currentTemp">--</h2>
|
||||
<p id="currentSummary">Waiting for weather data</p>
|
||||
</div>
|
||||
<canvas id="todaySpark" width="360" height="150" aria-label="Temperature trend"></canvas>
|
||||
</div>
|
||||
<div class="metric-grid" id="metricGrid"></div>
|
||||
</section>
|
||||
|
||||
<section class="map-section" id="mapSection" data-section="map">
|
||||
<div class="section-title">
|
||||
<h2>Radar</h2>
|
||||
<span id="radarTime">Live layer</span>
|
||||
</div>
|
||||
<div id="weatherMap" class="weather-map"></div>
|
||||
</section>
|
||||
|
||||
<section class="data-section" id="hourlySection" data-section="hourly">
|
||||
<div class="section-title">
|
||||
<h2>Hourly</h2>
|
||||
<span id="hourlyMeta">Next 48 hours</span>
|
||||
</div>
|
||||
<div class="scroll-table">
|
||||
<table>
|
||||
<thead id="hourlyHead"></thead>
|
||||
<tbody id="hourlyBody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="data-section" id="dailySection" data-section="daily">
|
||||
<div class="section-title">
|
||||
<h2>Daily</h2>
|
||||
<span>16 day projection</span>
|
||||
</div>
|
||||
<div class="daily-grid" id="dailyGrid"></div>
|
||||
</section>
|
||||
|
||||
<section class="data-section" id="weeklySection" data-section="weekly">
|
||||
<div class="section-title">
|
||||
<h2>Weekly</h2>
|
||||
<span>Grouped projection</span>
|
||||
</div>
|
||||
<div class="weekly-grid" id="weeklyGrid"></div>
|
||||
</section>
|
||||
|
||||
<section class="section-grid two-column">
|
||||
<div class="data-section" id="airSection" data-section="air">
|
||||
<div class="section-title">
|
||||
<h2>Air</h2>
|
||||
<span id="airMeta">AQI and pollutants</span>
|
||||
</div>
|
||||
<div class="air-grid" id="airGrid"></div>
|
||||
<canvas id="aqiSpark" width="420" height="160" aria-label="AQI trend"></canvas>
|
||||
</div>
|
||||
|
||||
<div class="data-section" id="historySection" data-section="history">
|
||||
<div class="section-title">
|
||||
<h2>History</h2>
|
||||
<span id="historyDate">Current date</span>
|
||||
</div>
|
||||
<ul class="fact-list" id="historyFacts"></ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="data-section" id="archivesSection" data-section="archives">
|
||||
<div class="section-title">
|
||||
<h2>Archives</h2>
|
||||
<button type="button" id="exportArchives">
|
||||
<i data-lucide="file-json"></i>
|
||||
<span>Export results</span>
|
||||
</button>
|
||||
</div>
|
||||
<form class="archive-search" id="archiveForm">
|
||||
<input id="archiveQuery" type="search" placeholder="Search archive" aria-label="Search archive">
|
||||
<select id="archiveKind" aria-label="Archive kind">
|
||||
<option value="">All</option>
|
||||
<option value="hourly">Hourly</option>
|
||||
<option value="daily">Daily</option>
|
||||
</select>
|
||||
<input id="archiveStart" type="date" aria-label="Start date">
|
||||
<input id="archiveEnd" type="date" aria-label="End date">
|
||||
<button type="submit">
|
||||
<i data-lucide="database"></i>
|
||||
<span>Search</span>
|
||||
</button>
|
||||
</form>
|
||||
<div class="archive-list" id="archiveList"></div>
|
||||
</section>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<dialog id="archiveDialog">
|
||||
<div class="dialog-head">
|
||||
<h2 id="archiveDialogTitle">Archive</h2>
|
||||
<button type="button" id="closeArchiveDialog" class="round-button" title="Close">
|
||||
<i data-lucide="x"></i>
|
||||
</button>
|
||||
</div>
|
||||
<pre id="archiveDialogBody"></pre>
|
||||
</dialog>
|
||||
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||
<script src="https://unpkg.com/lucide@latest/dist/umd/lucide.min.js"></script>
|
||||
<script src="assets/js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,931 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
final class WeatherService
|
||||
{
|
||||
private PDO $db;
|
||||
|
||||
/** @var array<string, string> */
|
||||
private array $stateNames = [
|
||||
'AL' => 'Alabama',
|
||||
'AK' => 'Alaska',
|
||||
'AZ' => 'Arizona',
|
||||
'AR' => 'Arkansas',
|
||||
'CA' => 'California',
|
||||
'CO' => 'Colorado',
|
||||
'CT' => 'Connecticut',
|
||||
'DE' => 'Delaware',
|
||||
'DC' => 'District of Columbia',
|
||||
'FL' => 'Florida',
|
||||
'GA' => 'Georgia',
|
||||
'HI' => 'Hawaii',
|
||||
'ID' => 'Idaho',
|
||||
'IL' => 'Illinois',
|
||||
'IN' => 'Indiana',
|
||||
'IA' => 'Iowa',
|
||||
'KS' => 'Kansas',
|
||||
'KY' => 'Kentucky',
|
||||
'LA' => 'Louisiana',
|
||||
'ME' => 'Maine',
|
||||
'MD' => 'Maryland',
|
||||
'MA' => 'Massachusetts',
|
||||
'MI' => 'Michigan',
|
||||
'MN' => 'Minnesota',
|
||||
'MS' => 'Mississippi',
|
||||
'MO' => 'Missouri',
|
||||
'MT' => 'Montana',
|
||||
'NE' => 'Nebraska',
|
||||
'NV' => 'Nevada',
|
||||
'NH' => 'New Hampshire',
|
||||
'NJ' => 'New Jersey',
|
||||
'NM' => 'New Mexico',
|
||||
'NY' => 'New York',
|
||||
'NC' => 'North Carolina',
|
||||
'ND' => 'North Dakota',
|
||||
'OH' => 'Ohio',
|
||||
'OK' => 'Oklahoma',
|
||||
'OR' => 'Oregon',
|
||||
'PA' => 'Pennsylvania',
|
||||
'RI' => 'Rhode Island',
|
||||
'SC' => 'South Carolina',
|
||||
'SD' => 'South Dakota',
|
||||
'TN' => 'Tennessee',
|
||||
'TX' => 'Texas',
|
||||
'UT' => 'Utah',
|
||||
'VT' => 'Vermont',
|
||||
'VA' => 'Virginia',
|
||||
'WA' => 'Washington',
|
||||
'WV' => 'West Virginia',
|
||||
'WI' => 'Wisconsin',
|
||||
'WY' => 'Wyoming',
|
||||
];
|
||||
|
||||
/** @var array<int, string> */
|
||||
private array $weatherCodes = [
|
||||
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',
|
||||
];
|
||||
|
||||
public function __construct(PDO $db)
|
||||
{
|
||||
$this->db = $db;
|
||||
$this->initializeSchema();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function searchLocations(string $query): array
|
||||
{
|
||||
$query = trim($query);
|
||||
if ($query === '') {
|
||||
throw new InvalidArgumentException('Enter a city, state, or ZIP code.');
|
||||
}
|
||||
|
||||
[$lookup, $stateFilter] = $this->splitLocationQuery($query);
|
||||
$isZip = (bool) preg_match('/^\d{5}(?:-\d{4})?$/', $lookup);
|
||||
|
||||
$params = [
|
||||
'name' => $isZip ? substr($lookup, 0, 5) : $lookup,
|
||||
'count' => 12,
|
||||
'language' => 'en',
|
||||
'format' => 'json',
|
||||
];
|
||||
|
||||
if ($isZip || $stateFilter !== null) {
|
||||
$params['countryCode'] = 'US';
|
||||
}
|
||||
|
||||
$url = 'https://geocoding-api.open-meteo.com/v1/search?' . http_build_query($params);
|
||||
$data = $this->cachedJson($url, 'geocoding', 'global', 86400, false);
|
||||
$results = $data['results'] ?? [];
|
||||
|
||||
if (!is_array($results)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$locations = [];
|
||||
foreach ($results as $result) {
|
||||
if (!is_array($result) || !isset($result['latitude'], $result['longitude'], $result['name'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$admin1 = (string) ($result['admin1'] ?? '');
|
||||
if ($stateFilter !== null && !$this->matchesState($admin1, $stateFilter)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$locations[] = $this->normalizeLocation($result);
|
||||
}
|
||||
|
||||
if ($locations === [] && $stateFilter !== null) {
|
||||
foreach ($results as $result) {
|
||||
if (is_array($result) && isset($result['latitude'], $result['longitude'], $result['name'])) {
|
||||
$locations[] = $this->normalizeLocation($result);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $locations;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $input
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function dashboard(array $input): array
|
||||
{
|
||||
$force = filter_var($input['force'] ?? false, FILTER_VALIDATE_BOOL);
|
||||
$location = $this->resolveLocation($input);
|
||||
$locationKey = $this->locationKey($location);
|
||||
|
||||
$forecast = $this->cachedJson($this->forecastUrl($location), 'forecast', $locationKey, 300, $force);
|
||||
$airQuality = $this->cachedJson($this->airQualityUrl($location), 'air-quality', $locationKey, 300, $force);
|
||||
$history = $this->historyFacts($location, $forecast);
|
||||
$rainViewer = $this->cachedJson('https://api.rainviewer.com/public/weather-maps.json', 'rainviewer', 'global', 900, $force);
|
||||
|
||||
$this->archiveSnapshot($location, $forecast, $airQuality, $history);
|
||||
$todayArchiveCount = $this->countArchiveForDay($locationKey, gmdate('Y-m-d'));
|
||||
$history['facts'][] = sprintf('Your local archive has %d saved snapshot%s for this location today.', $todayArchiveCount, $todayArchiveCount === 1 ? '' : 's');
|
||||
|
||||
return [
|
||||
'generated_at' => gmdate('c'),
|
||||
'refresh_seconds' => 300,
|
||||
'location' => $location,
|
||||
'forecast' => $forecast,
|
||||
'air_quality' => $airQuality,
|
||||
'history' => $history,
|
||||
'rainviewer' => $rainViewer,
|
||||
'weather_codes' => $this->weatherCodes,
|
||||
'archive_summary' => $this->archiveSummary($locationKey),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $filters
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function listArchives(array $filters): array
|
||||
{
|
||||
$where = [];
|
||||
$params = [];
|
||||
|
||||
$q = trim((string) ($filters['q'] ?? ''));
|
||||
if ($q !== '') {
|
||||
$where[] = '(location_name LIKE :q OR summary_json LIKE :q OR payload_json LIKE :q)';
|
||||
$params[':q'] = '%' . $q . '%';
|
||||
}
|
||||
|
||||
$kind = trim((string) ($filters['kind'] ?? ''));
|
||||
if ($kind !== '' && in_array($kind, ['hourly', 'daily'], true)) {
|
||||
$where[] = 'kind = :kind';
|
||||
$params[':kind'] = $kind;
|
||||
}
|
||||
|
||||
$start = trim((string) ($filters['start'] ?? ''));
|
||||
if ($start !== '') {
|
||||
$where[] = 'bucket_day >= :start';
|
||||
$params[':start'] = $start;
|
||||
}
|
||||
|
||||
$end = trim((string) ($filters['end'] ?? ''));
|
||||
if ($end !== '') {
|
||||
$where[] = 'bucket_day <= :end';
|
||||
$params[':end'] = $end;
|
||||
}
|
||||
|
||||
$limit = max(1, min(200, (int) ($filters['limit'] ?? 50)));
|
||||
$sql = 'SELECT id, location_key, location_name, latitude, longitude, kind, bucket_hour, bucket_day, observed_at, summary_json, created_at, updated_at FROM archive_snapshots';
|
||||
if ($where !== []) {
|
||||
$sql .= ' WHERE ' . implode(' AND ', $where);
|
||||
}
|
||||
$sql .= ' ORDER BY observed_at DESC, id DESC LIMIT :limit';
|
||||
|
||||
$stmt = $this->db->prepare($sql);
|
||||
foreach ($params as $key => $value) {
|
||||
$stmt->bindValue($key, $value, PDO::PARAM_STR);
|
||||
}
|
||||
$stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
|
||||
$stmt->execute();
|
||||
|
||||
$items = [];
|
||||
foreach ($stmt->fetchAll() as $row) {
|
||||
$row['summary'] = json_decode((string) $row['summary_json'], true) ?: [];
|
||||
unset($row['summary_json']);
|
||||
$items[] = $row;
|
||||
}
|
||||
|
||||
return [
|
||||
'items' => $items,
|
||||
'count' => count($items),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function getArchive(int $id): array
|
||||
{
|
||||
$stmt = $this->db->prepare('SELECT * FROM archive_snapshots WHERE id = :id');
|
||||
$stmt->execute([':id' => $id]);
|
||||
$row = $stmt->fetch();
|
||||
|
||||
if (!$row) {
|
||||
throw new RuntimeException('Archive snapshot not found.');
|
||||
}
|
||||
|
||||
$row['summary'] = json_decode((string) $row['summary_json'], true) ?: [];
|
||||
$row['payload'] = json_decode((string) $row['payload_json'], true) ?: [];
|
||||
unset($row['summary_json'], $row['payload_json']);
|
||||
|
||||
return $row;
|
||||
}
|
||||
|
||||
private function initializeSchema(): void
|
||||
{
|
||||
$this->db->exec(
|
||||
"CREATE TABLE IF NOT EXISTS api_cache (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
cache_key TEXT NOT NULL UNIQUE,
|
||||
endpoint TEXT NOT NULL,
|
||||
location_key TEXT NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
response_json TEXT NOT NULL,
|
||||
fetched_at TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
)"
|
||||
);
|
||||
|
||||
$this->db->exec(
|
||||
"CREATE TABLE IF NOT EXISTS archive_snapshots (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
location_key TEXT NOT NULL,
|
||||
location_name TEXT NOT NULL,
|
||||
latitude REAL NOT NULL,
|
||||
longitude REAL NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
bucket_hour TEXT NOT NULL,
|
||||
bucket_day TEXT NOT NULL,
|
||||
observed_at TEXT NOT NULL,
|
||||
summary_json TEXT NOT NULL,
|
||||
payload_json TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
UNIQUE(location_key, kind, bucket_hour)
|
||||
)"
|
||||
);
|
||||
|
||||
$this->db->exec('CREATE INDEX IF NOT EXISTS idx_cache_expiry ON api_cache (expires_at)');
|
||||
$this->db->exec('CREATE INDEX IF NOT EXISTS idx_archive_location ON archive_snapshots (location_key)');
|
||||
$this->db->exec('CREATE INDEX IF NOT EXISTS idx_archive_day ON archive_snapshots (bucket_day)');
|
||||
$this->db->exec('CREATE INDEX IF NOT EXISTS idx_archive_kind ON archive_snapshots (kind)');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $input
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function resolveLocation(array $input): array
|
||||
{
|
||||
$lat = $input['lat'] ?? null;
|
||||
$lon = $input['lon'] ?? null;
|
||||
|
||||
if (is_numeric($lat) && is_numeric($lon)) {
|
||||
return $this->normalizeLocation([
|
||||
'name' => (string) ($input['name'] ?? 'Selected location'),
|
||||
'admin1' => (string) ($input['admin1'] ?? ''),
|
||||
'country' => (string) ($input['country'] ?? ''),
|
||||
'country_code' => (string) ($input['country_code'] ?? ''),
|
||||
'latitude' => (float) $lat,
|
||||
'longitude' => (float) $lon,
|
||||
'timezone' => (string) ($input['timezone'] ?? 'auto'),
|
||||
]);
|
||||
}
|
||||
|
||||
$query = trim((string) ($input['q'] ?? ''));
|
||||
$locations = $this->searchLocations($query !== '' ? $query : 'New York, NY');
|
||||
|
||||
if ($locations === []) {
|
||||
throw new RuntimeException('No matching location was found.');
|
||||
}
|
||||
|
||||
return $locations[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $raw
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function normalizeLocation(array $raw): array
|
||||
{
|
||||
$name = (string) ($raw['name'] ?? 'Selected location');
|
||||
$admin1 = (string) ($raw['admin1'] ?? '');
|
||||
$country = (string) ($raw['country'] ?? '');
|
||||
$countryCode = strtoupper((string) ($raw['country_code'] ?? ''));
|
||||
$latitude = round((float) $raw['latitude'], 5);
|
||||
$longitude = round((float) $raw['longitude'], 5);
|
||||
$timezone = (string) ($raw['timezone'] ?? 'auto');
|
||||
|
||||
$parts = array_values(array_filter([$name, $admin1, $countryCode ?: $country]));
|
||||
|
||||
return [
|
||||
'id' => (string) ($raw['id'] ?? md5($latitude . ',' . $longitude)),
|
||||
'name' => $name,
|
||||
'admin1' => $admin1,
|
||||
'country' => $country,
|
||||
'country_code' => $countryCode,
|
||||
'latitude' => $latitude,
|
||||
'longitude' => $longitude,
|
||||
'timezone' => $timezone,
|
||||
'elevation' => $raw['elevation'] ?? null,
|
||||
'population' => $raw['population'] ?? null,
|
||||
'label' => implode(', ', $parts),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0: string, 1: string|null}
|
||||
*/
|
||||
private function splitLocationQuery(string $query): array
|
||||
{
|
||||
$query = trim($query);
|
||||
$stateFilter = null;
|
||||
$lookup = $query;
|
||||
|
||||
$parts = array_values(array_filter(array_map('trim', explode(',', $query)), static fn (string $part): bool => $part !== ''));
|
||||
if (count($parts) >= 3) {
|
||||
$countryCandidate = strtoupper((string) end($parts));
|
||||
if (in_array($countryCandidate, ['US', 'USA', 'UNITED STATES', 'UNITED STATES OF AMERICA'], true)) {
|
||||
array_pop($parts);
|
||||
}
|
||||
}
|
||||
|
||||
if (count($parts) >= 2) {
|
||||
$candidate = (string) end($parts);
|
||||
$normalizedState = $this->normalizeStateCandidate($candidate);
|
||||
if ($normalizedState !== null) {
|
||||
array_pop($parts);
|
||||
$lookup = trim((string) reset($parts));
|
||||
$stateFilter = $normalizedState;
|
||||
}
|
||||
}
|
||||
|
||||
return [$lookup, $stateFilter];
|
||||
}
|
||||
|
||||
private function normalizeStateCandidate(string $candidate): ?string
|
||||
{
|
||||
$candidate = trim($candidate);
|
||||
$upper = strtoupper($candidate);
|
||||
if (isset($this->stateNames[$upper])) {
|
||||
return $this->stateNames[$upper];
|
||||
}
|
||||
|
||||
foreach ($this->stateNames as $stateName) {
|
||||
if (strcasecmp($stateName, $candidate) === 0) {
|
||||
return $stateName;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function matchesState(string $admin1, string $stateFilter): bool
|
||||
{
|
||||
return strcasecmp($admin1, $stateFilter) === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $location
|
||||
*/
|
||||
private function locationKey(array $location): string
|
||||
{
|
||||
return md5(round((float) $location['latitude'], 3) . ',' . round((float) $location['longitude'], 3));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $location
|
||||
*/
|
||||
private function forecastUrl(array $location): string
|
||||
{
|
||||
$params = [
|
||||
'latitude' => $location['latitude'],
|
||||
'longitude' => $location['longitude'],
|
||||
'current' => implode(',', [
|
||||
'temperature_2m',
|
||||
'relative_humidity_2m',
|
||||
'apparent_temperature',
|
||||
'is_day',
|
||||
'precipitation',
|
||||
'rain',
|
||||
'showers',
|
||||
'snowfall',
|
||||
'weather_code',
|
||||
'cloud_cover',
|
||||
'pressure_msl',
|
||||
'surface_pressure',
|
||||
'wind_speed_10m',
|
||||
'wind_direction_10m',
|
||||
'wind_gusts_10m',
|
||||
]),
|
||||
'hourly' => implode(',', [
|
||||
'temperature_2m',
|
||||
'relative_humidity_2m',
|
||||
'dew_point_2m',
|
||||
'apparent_temperature',
|
||||
'precipitation_probability',
|
||||
'precipitation',
|
||||
'rain',
|
||||
'showers',
|
||||
'snowfall',
|
||||
'snow_depth',
|
||||
'weather_code',
|
||||
'pressure_msl',
|
||||
'surface_pressure',
|
||||
'cloud_cover',
|
||||
'visibility',
|
||||
'wind_speed_10m',
|
||||
'wind_direction_10m',
|
||||
'wind_gusts_10m',
|
||||
'uv_index',
|
||||
'uv_index_clear_sky',
|
||||
'is_day',
|
||||
]),
|
||||
'daily' => implode(',', [
|
||||
'weather_code',
|
||||
'temperature_2m_max',
|
||||
'temperature_2m_min',
|
||||
'apparent_temperature_max',
|
||||
'apparent_temperature_min',
|
||||
'sunrise',
|
||||
'sunset',
|
||||
'daylight_duration',
|
||||
'sunshine_duration',
|
||||
'uv_index_max',
|
||||
'uv_index_clear_sky_max',
|
||||
'precipitation_sum',
|
||||
'rain_sum',
|
||||
'showers_sum',
|
||||
'snowfall_sum',
|
||||
'precipitation_hours',
|
||||
'precipitation_probability_max',
|
||||
'wind_speed_10m_max',
|
||||
'wind_gusts_10m_max',
|
||||
'wind_direction_10m_dominant',
|
||||
]),
|
||||
'forecast_days' => 16,
|
||||
'past_days' => 1,
|
||||
'timezone' => 'auto',
|
||||
'temperature_unit' => 'fahrenheit',
|
||||
'wind_speed_unit' => 'mph',
|
||||
'precipitation_unit' => 'inch',
|
||||
];
|
||||
|
||||
return 'https://api.open-meteo.com/v1/forecast?' . http_build_query($params);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $location
|
||||
*/
|
||||
private function airQualityUrl(array $location): string
|
||||
{
|
||||
$params = [
|
||||
'latitude' => $location['latitude'],
|
||||
'longitude' => $location['longitude'],
|
||||
'current' => implode(',', [
|
||||
'us_aqi',
|
||||
'pm10',
|
||||
'pm2_5',
|
||||
'carbon_monoxide',
|
||||
'nitrogen_dioxide',
|
||||
'sulphur_dioxide',
|
||||
'ozone',
|
||||
'uv_index',
|
||||
'uv_index_clear_sky',
|
||||
]),
|
||||
'hourly' => implode(',', [
|
||||
'us_aqi',
|
||||
'us_aqi_pm2_5',
|
||||
'us_aqi_pm10',
|
||||
'us_aqi_nitrogen_dioxide',
|
||||
'us_aqi_ozone',
|
||||
'pm10',
|
||||
'pm2_5',
|
||||
'carbon_monoxide',
|
||||
'nitrogen_dioxide',
|
||||
'sulphur_dioxide',
|
||||
'ozone',
|
||||
'aerosol_optical_depth',
|
||||
'dust',
|
||||
'uv_index',
|
||||
'uv_index_clear_sky',
|
||||
'alder_pollen',
|
||||
'birch_pollen',
|
||||
'grass_pollen',
|
||||
'mugwort_pollen',
|
||||
'olive_pollen',
|
||||
'ragweed_pollen',
|
||||
]),
|
||||
'forecast_days' => 5,
|
||||
'timezone' => 'auto',
|
||||
];
|
||||
|
||||
return 'https://air-quality-api.open-meteo.com/v1/air-quality?' . http_build_query($params);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $location
|
||||
* @param array<string, mixed> $forecast
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function historyFacts(array $location, array $forecast): array
|
||||
{
|
||||
$timezoneName = is_string($location['timezone'] ?? null) && $location['timezone'] !== 'auto'
|
||||
? (string) $location['timezone']
|
||||
: 'UTC';
|
||||
|
||||
try {
|
||||
$timezone = new DateTimeZone($timezoneName);
|
||||
} catch (Throwable) {
|
||||
$timezone = new DateTimeZone('UTC');
|
||||
}
|
||||
|
||||
$today = new DateTimeImmutable('today', $timezone);
|
||||
$lastYear = $today->modify('-1 year');
|
||||
$date = $lastYear->format('Y-m-d');
|
||||
|
||||
$params = [
|
||||
'latitude' => $location['latitude'],
|
||||
'longitude' => $location['longitude'],
|
||||
'start_date' => $date,
|
||||
'end_date' => $date,
|
||||
'daily' => implode(',', [
|
||||
'weather_code',
|
||||
'temperature_2m_max',
|
||||
'temperature_2m_min',
|
||||
'temperature_2m_mean',
|
||||
'precipitation_sum',
|
||||
'rain_sum',
|
||||
'snowfall_sum',
|
||||
'wind_speed_10m_max',
|
||||
]),
|
||||
'timezone' => 'auto',
|
||||
'temperature_unit' => 'fahrenheit',
|
||||
'wind_speed_unit' => 'mph',
|
||||
'precipitation_unit' => 'inch',
|
||||
];
|
||||
|
||||
$url = 'https://archive-api.open-meteo.com/v1/archive?' . http_build_query($params);
|
||||
$historical = $this->cachedJson($url, 'history', $this->locationKey($location), 43200, false);
|
||||
|
||||
$facts = [];
|
||||
$todayDate = $today->format('F j');
|
||||
$place = (string) $location['label'];
|
||||
$todayHigh = $this->arrayValue($forecast, ['daily', 'temperature_2m_max', 1]);
|
||||
$lastHigh = $this->arrayValue($historical, ['daily', 'temperature_2m_max', 0]);
|
||||
$lastLow = $this->arrayValue($historical, ['daily', 'temperature_2m_min', 0]);
|
||||
$lastRain = $this->arrayValue($historical, ['daily', 'precipitation_sum', 0]);
|
||||
$lastWind = $this->arrayValue($historical, ['daily', 'wind_speed_10m_max', 0]);
|
||||
$lastCode = $this->arrayValue($historical, ['daily', 'weather_code', 0]);
|
||||
|
||||
if (is_numeric($todayHigh) && is_numeric($lastHigh)) {
|
||||
$delta = round((float) $todayHigh - (float) $lastHigh, 1);
|
||||
if ($delta > 0) {
|
||||
$facts[] = sprintf(
|
||||
'This %s is projected %.1f F warmer than the same date last year near %s.',
|
||||
$todayDate,
|
||||
abs($delta),
|
||||
$place
|
||||
);
|
||||
} elseif ($delta < 0) {
|
||||
$facts[] = sprintf(
|
||||
'This %s is projected %.1f F cooler than the same date last year near %s.',
|
||||
$todayDate,
|
||||
abs($delta),
|
||||
$place
|
||||
);
|
||||
} else {
|
||||
$facts[] = sprintf(
|
||||
'This %s is projected to match last year near %s at %.1f F.',
|
||||
$todayDate,
|
||||
$place,
|
||||
(float) $todayHigh
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (is_numeric($lastHigh) && is_numeric($lastLow)) {
|
||||
$facts[] = sprintf(
|
||||
'On %s, %s, the historical high was %.1f F and the low was %.1f F.',
|
||||
$lastYear->format('F j'),
|
||||
$lastYear->format('Y'),
|
||||
(float) $lastHigh,
|
||||
(float) $lastLow
|
||||
);
|
||||
}
|
||||
|
||||
if (is_numeric($lastRain)) {
|
||||
$facts[] = sprintf(
|
||||
'That date recorded %.2f inches of precipitation in the Open-Meteo archive.',
|
||||
(float) $lastRain
|
||||
);
|
||||
}
|
||||
|
||||
if (is_numeric($lastWind)) {
|
||||
$facts[] = sprintf(
|
||||
'The strongest archived wind for that date was %.1f mph.',
|
||||
(float) $lastWind
|
||||
);
|
||||
}
|
||||
|
||||
return [
|
||||
'comparison_date' => $date,
|
||||
'facts' => $facts,
|
||||
'historical_daily' => $historical['daily'] ?? [],
|
||||
'historical_weather' => is_numeric($lastCode) ? ($this->weatherCodes[(int) $lastCode] ?? 'Unknown') : null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $location
|
||||
* @param array<string, mixed> $forecast
|
||||
* @param array<string, mixed> $airQuality
|
||||
* @param array<string, mixed> $history
|
||||
*/
|
||||
private function archiveSnapshot(array $location, array $forecast, array $airQuality, array $history): void
|
||||
{
|
||||
$now = gmdate('c');
|
||||
$bucketHour = gmdate('Y-m-d H:00:00');
|
||||
$bucketDay = gmdate('Y-m-d');
|
||||
$observedAt = (string) ($forecast['current']['time'] ?? $now);
|
||||
$locationKey = $this->locationKey($location);
|
||||
|
||||
$summary = [
|
||||
'temperature' => $this->arrayValue($forecast, ['current', 'temperature_2m']),
|
||||
'apparent_temperature' => $this->arrayValue($forecast, ['current', 'apparent_temperature']),
|
||||
'humidity' => $this->arrayValue($forecast, ['current', 'relative_humidity_2m']),
|
||||
'weather_code' => $this->arrayValue($forecast, ['current', 'weather_code']),
|
||||
'weather_label' => $this->codeLabel($this->arrayValue($forecast, ['current', 'weather_code'])),
|
||||
'precipitation' => $this->arrayValue($forecast, ['current', 'precipitation']),
|
||||
'rain' => $this->arrayValue($forecast, ['current', 'rain']),
|
||||
'snowfall' => $this->arrayValue($forecast, ['current', 'snowfall']),
|
||||
'cloud_cover' => $this->arrayValue($forecast, ['current', 'cloud_cover']),
|
||||
'pressure' => $this->arrayValue($forecast, ['current', 'pressure_msl']),
|
||||
'wind_speed' => $this->arrayValue($forecast, ['current', 'wind_speed_10m']),
|
||||
'wind_direction' => $this->arrayValue($forecast, ['current', 'wind_direction_10m']),
|
||||
'wind_gusts' => $this->arrayValue($forecast, ['current', 'wind_gusts_10m']),
|
||||
'us_aqi' => $this->arrayValue($airQuality, ['current', 'us_aqi']),
|
||||
'pm2_5' => $this->arrayValue($airQuality, ['current', 'pm2_5']),
|
||||
'pm10' => $this->arrayValue($airQuality, ['current', 'pm10']),
|
||||
];
|
||||
|
||||
$payload = [
|
||||
'location' => $location,
|
||||
'forecast' => $forecast,
|
||||
'air_quality' => $airQuality,
|
||||
'history' => $history,
|
||||
];
|
||||
|
||||
foreach (['hourly' => $bucketHour, 'daily' => $bucketDay . ' 00:00:00'] as $kind => $kindBucketHour) {
|
||||
$stmt = $this->db->prepare(
|
||||
"INSERT INTO archive_snapshots (
|
||||
location_key, location_name, latitude, longitude, kind, bucket_hour, bucket_day,
|
||||
observed_at, summary_json, payload_json, created_at, updated_at
|
||||
) VALUES (
|
||||
:location_key, :location_name, :latitude, :longitude, :kind, :bucket_hour, :bucket_day,
|
||||
:observed_at, :summary_json, :payload_json, :created_at, :updated_at
|
||||
)
|
||||
ON CONFLICT(location_key, kind, bucket_hour) DO UPDATE SET
|
||||
observed_at = excluded.observed_at,
|
||||
summary_json = excluded.summary_json,
|
||||
payload_json = excluded.payload_json,
|
||||
updated_at = excluded.updated_at"
|
||||
);
|
||||
|
||||
$stmt->execute([
|
||||
':location_key' => $locationKey,
|
||||
':location_name' => (string) $location['label'],
|
||||
':latitude' => (float) $location['latitude'],
|
||||
':longitude' => (float) $location['longitude'],
|
||||
':kind' => $kind,
|
||||
':bucket_hour' => $kindBucketHour,
|
||||
':bucket_day' => $bucketDay,
|
||||
':observed_at' => $observedAt,
|
||||
':summary_json' => json_encode($summary, JSON_UNESCAPED_SLASHES),
|
||||
':payload_json' => json_encode($payload, JSON_UNESCAPED_SLASHES),
|
||||
':created_at' => $now,
|
||||
':updated_at' => $now,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function archiveSummary(string $locationKey): array
|
||||
{
|
||||
$stmt = $this->db->prepare(
|
||||
"SELECT
|
||||
COUNT(*) AS snapshots,
|
||||
SUM(CASE WHEN kind = 'hourly' THEN 1 ELSE 0 END) AS hourly_snapshots,
|
||||
SUM(CASE WHEN kind = 'daily' THEN 1 ELSE 0 END) AS daily_snapshots,
|
||||
MIN(bucket_day) AS first_day,
|
||||
MAX(bucket_day) AS last_day
|
||||
FROM archive_snapshots
|
||||
WHERE location_key = :location_key"
|
||||
);
|
||||
$stmt->execute([':location_key' => $locationKey]);
|
||||
$row = $stmt->fetch() ?: [];
|
||||
|
||||
return [
|
||||
'snapshots' => (int) ($row['snapshots'] ?? 0),
|
||||
'hourly_snapshots' => (int) ($row['hourly_snapshots'] ?? 0),
|
||||
'daily_snapshots' => (int) ($row['daily_snapshots'] ?? 0),
|
||||
'first_day' => $row['first_day'] ?? null,
|
||||
'last_day' => $row['last_day'] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
private function countArchiveForDay(string $locationKey, string $day): int
|
||||
{
|
||||
$stmt = $this->db->prepare('SELECT COUNT(*) FROM archive_snapshots WHERE location_key = :location_key AND bucket_day = :bucket_day');
|
||||
$stmt->execute([':location_key' => $locationKey, ':bucket_day' => $day]);
|
||||
|
||||
return (int) $stmt->fetchColumn();
|
||||
}
|
||||
|
||||
private function codeLabel(mixed $code): ?string
|
||||
{
|
||||
if (!is_numeric($code)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->weatherCodes[(int) $code] ?? 'Unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
* @param array<int, string|int> $path
|
||||
*/
|
||||
private function arrayValue(array $data, array $path): mixed
|
||||
{
|
||||
$cursor = $data;
|
||||
foreach ($path as $part) {
|
||||
if (!is_array($cursor) || !array_key_exists($part, $cursor)) {
|
||||
return null;
|
||||
}
|
||||
$cursor = $cursor[$part];
|
||||
}
|
||||
|
||||
return $cursor;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function cachedJson(string $url, string $endpoint, string $locationKey, int $ttlSeconds, bool $force): array
|
||||
{
|
||||
$cacheKey = hash('sha256', $endpoint . '|' . $url);
|
||||
$now = gmdate('c');
|
||||
|
||||
$stmt = $this->db->prepare('SELECT * FROM api_cache WHERE cache_key = :cache_key');
|
||||
$stmt->execute([':cache_key' => $cacheKey]);
|
||||
$row = $stmt->fetch();
|
||||
|
||||
if (!$force && $row && strcmp((string) $row['expires_at'], $now) > 0) {
|
||||
$data = json_decode((string) $row['response_json'], true);
|
||||
if (is_array($data)) {
|
||||
$data['_cache'] = [
|
||||
'endpoint' => $endpoint,
|
||||
'fetched_at' => $row['fetched_at'],
|
||||
'expires_at' => $row['expires_at'],
|
||||
'stale' => false,
|
||||
];
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
$data = $this->fetchJson($url);
|
||||
$fetchedAt = gmdate('c');
|
||||
$expiresAt = gmdate('c', time() + $ttlSeconds);
|
||||
|
||||
$upsert = $this->db->prepare(
|
||||
"INSERT INTO api_cache (cache_key, endpoint, location_key, url, response_json, fetched_at, expires_at, created_at)
|
||||
VALUES (:cache_key, :endpoint, :location_key, :url, :response_json, :fetched_at, :expires_at, :created_at)
|
||||
ON CONFLICT(cache_key) DO UPDATE SET
|
||||
endpoint = excluded.endpoint,
|
||||
location_key = excluded.location_key,
|
||||
url = excluded.url,
|
||||
response_json = excluded.response_json,
|
||||
fetched_at = excluded.fetched_at,
|
||||
expires_at = excluded.expires_at"
|
||||
);
|
||||
|
||||
$upsert->execute([
|
||||
':cache_key' => $cacheKey,
|
||||
':endpoint' => $endpoint,
|
||||
':location_key' => $locationKey,
|
||||
':url' => $url,
|
||||
':response_json' => json_encode($data, JSON_UNESCAPED_SLASHES),
|
||||
':fetched_at' => $fetchedAt,
|
||||
':expires_at' => $expiresAt,
|
||||
':created_at' => $fetchedAt,
|
||||
]);
|
||||
|
||||
$data['_cache'] = [
|
||||
'endpoint' => $endpoint,
|
||||
'fetched_at' => $fetchedAt,
|
||||
'expires_at' => $expiresAt,
|
||||
'stale' => false,
|
||||
];
|
||||
|
||||
return $data;
|
||||
} catch (Throwable $error) {
|
||||
if ($row) {
|
||||
$data = json_decode((string) $row['response_json'], true);
|
||||
if (is_array($data)) {
|
||||
$data['_cache'] = [
|
||||
'endpoint' => $endpoint,
|
||||
'fetched_at' => $row['fetched_at'],
|
||||
'expires_at' => $row['expires_at'],
|
||||
'stale' => true,
|
||||
'error' => $error->getMessage(),
|
||||
];
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
|
||||
throw $error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function fetchJson(string $url): array
|
||||
{
|
||||
$context = stream_context_create([
|
||||
'http' => [
|
||||
'method' => 'GET',
|
||||
'timeout' => 15,
|
||||
'header' => implode("\r\n", [
|
||||
'Accept: application/json',
|
||||
'User-Agent: CodexWeather/1.0 (+local weather dashboard)',
|
||||
]),
|
||||
],
|
||||
]);
|
||||
|
||||
$body = @file_get_contents($url, false, $context);
|
||||
|
||||
if ($body === false) {
|
||||
$message = 'Could not reach weather data provider.';
|
||||
$headers = function_exists('http_get_last_response_headers') ? http_get_last_response_headers() : [];
|
||||
if (is_array($headers) && $headers !== []) {
|
||||
$message .= ' ' . $headers[0];
|
||||
}
|
||||
throw new RuntimeException($message);
|
||||
}
|
||||
|
||||
$data = json_decode($body, true);
|
||||
if (!is_array($data)) {
|
||||
throw new RuntimeException('Weather data provider returned invalid JSON.');
|
||||
}
|
||||
|
||||
if (($data['error'] ?? false) === true) {
|
||||
throw new RuntimeException((string) ($data['reason'] ?? 'Weather data provider returned an error.'));
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
define('APP_ROOT', dirname(__DIR__));
|
||||
define('DATA_DIR', APP_ROOT . '/data');
|
||||
define('DB_PATH', DATA_DIR . '/weather.sqlite');
|
||||
|
||||
error_reporting(E_ALL & ~E_DEPRECATED);
|
||||
ini_set('display_errors', '0');
|
||||
ini_set('log_errors', '1');
|
||||
|
||||
if (!is_dir(DATA_DIR)) {
|
||||
mkdir(DATA_DIR, 0775, true);
|
||||
}
|
||||
|
||||
require_once __DIR__ . '/WeatherService.php';
|
||||
|
||||
function app_db(): PDO
|
||||
{
|
||||
static $pdo = null;
|
||||
|
||||
if ($pdo instanceof PDO) {
|
||||
return $pdo;
|
||||
}
|
||||
|
||||
$pdo = new PDO('sqlite:' . DB_PATH);
|
||||
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
||||
$pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
|
||||
$pdo->exec('PRAGMA journal_mode = WAL');
|
||||
$pdo->exec('PRAGMA foreign_keys = ON');
|
||||
|
||||
return $pdo;
|
||||
}
|
||||
|
||||
function weather_service(): WeatherService
|
||||
{
|
||||
static $service = null;
|
||||
|
||||
if ($service instanceof WeatherService) {
|
||||
return $service;
|
||||
}
|
||||
|
||||
$service = new WeatherService(app_db());
|
||||
|
||||
return $service;
|
||||
}
|
||||
|
||||
function json_response(array $payload, int $status = 200): void
|
||||
{
|
||||
http_response_code($status);
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
header('Cache-Control: no-store');
|
||||
echo json_encode($payload, JSON_UNESCAPED_SLASHES);
|
||||
}
|
||||
|
||||
function request_value(string $key, mixed $default = null): mixed
|
||||
{
|
||||
return $_POST[$key] ?? $_GET[$key] ?? $default;
|
||||
}
|
||||
Reference in New Issue
Block a user