- Keep per-day extremes in data/records.json (merge-only) and derive all-time, notable-day, monthly and yearly records from it; rebuild from raw files at startup - Use station maxdailygust for true gust extremes - Add /api/station/backup.jsonl, records-backup.json and records/rebuild - Dashboard: safe-area insets, 44px touch targets, scrollable nav/range bars, chart touch-action, standalone PWA manifest + apple meta tags Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
1579 lines
77 KiB
HTML
1579 lines
77 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||
<meta name="theme-color" content="#f5f2ec" media="(prefers-color-scheme: light)">
|
||
<meta name="theme-color" content="#191510" media="(prefers-color-scheme: dark)">
|
||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||
<meta name="mobile-web-app-capable" content="yes">
|
||
<meta name="apple-mobile-web-app-title" content="Station">
|
||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||
<link rel="manifest" href="/dashboard.webmanifest">
|
||
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png">
|
||
<title>Station Dashboard</title>
|
||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
|
||
<style>
|
||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||
:root {
|
||
--ink: #1a1a1a; --ink-mid: #3d3730; --ink-light: #5f5649;
|
||
--paper: #f5f2ec; --paper-dark: #ece7db; --paper-darker: #ddd6c7;
|
||
--border: #b9ae9b; --accent: #c25a2a; --cool: #2a6b8a;
|
||
--mono: 'Segoe UI Rounded', 'Trebuchet MS', 'Avenir Next', sans-serif;
|
||
}
|
||
@media (prefers-color-scheme: dark) {
|
||
:root {
|
||
--ink: #ece7dc; --ink-mid: #d2c8b1; --ink-light: #beb399;
|
||
--paper: #191510; --paper-dark: #201c16; --paper-darker: #2b2620;
|
||
--border: #3a342a; --accent: #e07848; --cool: #5ab0d8;
|
||
}
|
||
}
|
||
html, body { min-height: 100%; background: var(--paper); color: var(--ink); font-family: var(--mono); font-size: 15px; }
|
||
a { color: var(--cool); text-decoration: none; }
|
||
|
||
/* ── Header ── */
|
||
.header { display: flex; align-items: center; justify-content: space-between; padding: 1rem 1.5rem; border-bottom: 1px solid var(--border); background: color-mix(in srgb, var(--paper-dark) 82%, transparent); backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px); position: sticky; top: 0; z-index: 10; transition: box-shadow .25s; }
|
||
.header.scrolled { box-shadow: 0 4px 24px rgba(0,0,0,0.18); }
|
||
.header h1 { font-size: 1.15rem; }
|
||
.header .meta { font-size: 0.8rem; color: var(--ink-light); }
|
||
.header-right { display: flex; align-items: center; gap: 1rem; font-size: 0.85rem; }
|
||
.station-name { color: var(--cool); font-weight: 600; }
|
||
.logout {
|
||
color: var(--ink-light);
|
||
text-decoration: none;
|
||
font-size: 0.78rem;
|
||
font-weight: 600;
|
||
padding: 0.32rem 0.75rem;
|
||
border: 1px solid var(--border);
|
||
border-radius: 8px;
|
||
background: color-mix(in srgb, white 5%, transparent);
|
||
backdrop-filter: blur(10px);
|
||
-webkit-backdrop-filter: blur(10px);
|
||
transition: background .18s cubic-bezier(.2,0,0,1),
|
||
border-color .18s cubic-bezier(.2,0,0,1),
|
||
color .18s cubic-bezier(.2,0,0,1),
|
||
box-shadow .18s cubic-bezier(.2,0,0,1),
|
||
transform .1s cubic-bezier(.2,0,0,1);
|
||
}
|
||
.logout:hover {
|
||
background: color-mix(in srgb, var(--accent) 18%, transparent);
|
||
border-color: var(--accent);
|
||
color: var(--accent);
|
||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 20%, transparent);
|
||
}
|
||
.logout:active { transform: scale(0.95); box-shadow: none; }
|
||
|
||
/* ── Wrapper ── */
|
||
.page { max-width: 1200px; margin: 0 auto; padding: 1.5rem 1rem 4rem; }
|
||
|
||
/* ── Stat tiles ── */
|
||
.stats { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 0.75rem; margin-bottom: 1.75rem; }
|
||
.stat {
|
||
background: var(--paper-dark); border: 1px solid var(--border); border-radius: 12px; padding: 0.85rem 1rem;
|
||
transition: transform .18s cubic-bezier(.2,0,0,1), box-shadow .18s cubic-bezier(.2,0,0,1);
|
||
}
|
||
.stat:hover { transform: translateY(-2px); box-shadow: 0 6px 20px rgba(0,0,0,0.14); }
|
||
.stat-label { font-size: 0.72rem; color: var(--ink-light); text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 0.2rem; }
|
||
.stat-val { font-size: 1.5rem; font-weight: 700; line-height: 1.1; }
|
||
.stat-unit { font-size: 0.75rem; color: var(--ink-light); margin-top: 0.1rem; }
|
||
|
||
/* ── Range selector ── */
|
||
.range-bar { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 1.5rem; flex-wrap: wrap; }
|
||
.range-bar span { font-size: 0.82rem; color: var(--ink-light); margin-right: 0.25rem; }
|
||
.range-btn {
|
||
padding: 0.3rem 0.75rem;
|
||
border: 1px solid var(--border);
|
||
border-radius: 20px;
|
||
background: color-mix(in srgb, white 5%, transparent);
|
||
backdrop-filter: blur(10px);
|
||
-webkit-backdrop-filter: blur(10px);
|
||
color: var(--ink);
|
||
font-family: var(--mono);
|
||
font-size: 0.82rem;
|
||
cursor: pointer;
|
||
transition: background .18s cubic-bezier(.2,0,0,1),
|
||
border-color .18s cubic-bezier(.2,0,0,1),
|
||
color .18s cubic-bezier(.2,0,0,1),
|
||
box-shadow .18s cubic-bezier(.2,0,0,1),
|
||
transform .1s cubic-bezier(.2,0,0,1);
|
||
}
|
||
.range-btn.active { background: var(--accent); border-color: var(--accent); color: #fff; }
|
||
.range-btn:hover {
|
||
background: var(--accent);
|
||
border-color: var(--accent);
|
||
color: #fff;
|
||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 20%, transparent);
|
||
}
|
||
.range-btn:active { transform: scale(0.95); box-shadow: none; }
|
||
.refresh-btn {
|
||
margin-left: auto;
|
||
padding: 0.3rem 0.75rem;
|
||
border: 1px solid var(--border);
|
||
border-radius: 20px;
|
||
background: color-mix(in srgb, white 5%, transparent);
|
||
backdrop-filter: blur(10px);
|
||
-webkit-backdrop-filter: blur(10px);
|
||
color: var(--ink-light);
|
||
font-family: var(--mono);
|
||
font-size: 0.82rem;
|
||
cursor: pointer;
|
||
transition: border-color .18s cubic-bezier(.2,0,0,1),
|
||
color .18s cubic-bezier(.2,0,0,1),
|
||
box-shadow .18s cubic-bezier(.2,0,0,1),
|
||
transform .1s cubic-bezier(.2,0,0,1);
|
||
}
|
||
.refresh-btn:hover {
|
||
border-color: var(--cool);
|
||
color: var(--cool);
|
||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--cool) 20%, transparent);
|
||
}
|
||
.refresh-btn:active { transform: scale(0.95); box-shadow: none; }
|
||
|
||
/* ── Chart grid ── */
|
||
.charts { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
|
||
@media (max-width: 720px) { .charts { grid-template-columns: 1fr; } }
|
||
.chart-card { background: var(--paper-dark); border: 1px solid var(--border); border-radius: 12px; padding: 1rem 1.2rem 1.2rem; }
|
||
.chart-card.wide { grid-column: 1 / -1; }
|
||
.chart-title { font-size: 0.82rem; text-transform: uppercase; letter-spacing: 0.06em; color: var(--ink-light); margin-bottom: 0.75rem; }
|
||
.chart-wrap { position: relative; height: 200px; }
|
||
.chart-card.wide .chart-wrap { height: 180px; }
|
||
|
||
/* ── Status ── */
|
||
.status { text-align: center; padding: 3rem 1rem; color: var(--ink-light); font-size: 0.9rem; }
|
||
.status.error { color: var(--accent); }
|
||
.updated { font-size: 0.75rem; color: var(--ink-light); margin-top: 0.5rem; }
|
||
|
||
/* ── Wind rose ── */
|
||
#windRoseCanvas { display: block; margin: 0 auto; }
|
||
|
||
/* ── No-data ── */
|
||
.no-data { display: flex; align-items: center; justify-content: center; height: 180px; font-size: 0.85rem; color: var(--ink-light); }
|
||
|
||
/* ── Settings panel (collapsible sections) ── */
|
||
.settings-panel { margin-top: 2.5rem; border: 1px solid var(--border); border-radius: 12px; overflow: hidden; }
|
||
.settings-panel summary { padding: 0.85rem 1.2rem; cursor: pointer; font-size: 0.82rem; text-transform: uppercase; letter-spacing: 0.06em; color: var(--ink-light); background: var(--paper-dark); list-style: none; display: flex; align-items: center; gap: 0.5rem; user-select: none; }
|
||
.settings-panel summary::before { content: '›'; display: inline-block; transition: transform 0.2s; }
|
||
.settings-panel[open] summary::before { transform: rotate(90deg); }
|
||
.settings-panel summary::-webkit-details-marker { display: none; }
|
||
.settings-body { padding: 1.2rem; background: var(--paper-dark); border-top: 1px solid var(--border); }
|
||
/* Records */
|
||
.records-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 0.6rem; }
|
||
.record-item { background: var(--paper); border: 1px solid var(--border); border-radius: 8px; padding: 0.6rem 0.85rem; }
|
||
.record-label { font-size: 0.72rem; color: var(--ink-light); text-transform: uppercase; letter-spacing: 0.05em; }
|
||
.record-val { font-size: 1.15rem; font-weight: 700; margin: 0.1rem 0; }
|
||
.record-date { font-size: 0.72rem; color: var(--ink-light); }
|
||
/* Anomaly tile */
|
||
.stat.anomaly-above { border-color: var(--accent); }
|
||
.stat.anomaly-below { border-color: var(--cool); }
|
||
/* NWS alert cards */
|
||
.alert-card { border-radius: 10px; padding: 0.6rem 1rem; margin-bottom: 0.5rem; font-size: 0.85rem; border: 1px solid; }
|
||
.alert-card.sev-extreme { background: rgba(120,0,0,0.12); border-color: #a00; color: var(--ink); }
|
||
.alert-card.sev-severe { background: rgba(194,90,42,0.12); border-color: var(--accent); }
|
||
.alert-card.sev-moderate{ background: rgba(200,160,0,0.12); border-color: #c8a000; }
|
||
.alert-card.sev-minor { background: rgba(42,107,138,0.12); border-color: var(--cool); }
|
||
.alert-card.sev-unknown { background: var(--paper-dark); border-color: var(--border); }
|
||
.alert-title { font-weight: 700; margin-bottom: 0.15rem; }
|
||
.alert-meta { font-size: 0.75rem; color: var(--ink-light); }
|
||
.alert-card summary { cursor: pointer; list-style: none; }
|
||
.alert-card summary::-webkit-details-marker { display: none; }
|
||
.alert-summary { margin-top: 0.2rem; line-height: 1.35; }
|
||
.alert-hint { font-size: 0.72rem; color: var(--ink-light); margin-top: 0.2rem; }
|
||
.alert-card details[open] .alert-hint { display: none; }
|
||
.alert-full { margin-top: 0.5rem; white-space: pre-wrap; line-height: 1.45; font-size: 0.82rem; }
|
||
.alert-full h4 { margin: 0.6rem 0 0.15rem; font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.05em; color: var(--ink-light); }
|
||
.ah-row { cursor: pointer; }
|
||
.ah-row .ah-body { display: -webkit-box; -webkit-line-clamp: 1; -webkit-box-orient: vertical; overflow: hidden; }
|
||
.ah-row.open .ah-body { display: block; white-space: pre-wrap; }
|
||
/* Trend arrows */
|
||
.stat-trend { font-size: 0.85rem; margin-left: 0.25rem; }
|
||
.trend-up { color: #e07848; }
|
||
.trend-down { color: #5ab0d8; }
|
||
.trend-flat { color: var(--ink-light); opacity: 0.5; }
|
||
/* Offline banner */
|
||
.offline-banner { background: rgba(194,90,42,0.12); border: 1px solid var(--accent); border-radius: 10px; padding: 0.6rem 1rem; margin-bottom: 1rem; font-size: 0.85rem; color: var(--accent); display: none; }
|
||
/* Monthly summary */
|
||
.monthly-table-wrap { overflow-x: auto; margin-top: 0.75rem; }
|
||
.monthly-table { width: 100%; border-collapse: collapse; font-size: 0.85rem; }
|
||
.monthly-table th { text-align: left; padding: 0.4rem 0.75rem; font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.06em; color: var(--ink-light); border-bottom: 1px solid var(--border); }
|
||
.monthly-table td { padding: 0.45rem 0.75rem; border-bottom: 1px solid var(--border); font-variant-numeric: tabular-nums; }
|
||
.monthly-table tr:last-child td { border-bottom: none; }
|
||
.monthly-table tr:hover td { background: var(--paper-darker); }
|
||
/* Heatmap */
|
||
.heatmap-wrap { overflow-x: auto; padding-bottom: 0.5rem; }
|
||
.heatmap { display: inline-grid; grid-auto-flow: column; grid-template-rows: repeat(7, 14px); gap: 3px; }
|
||
.heatmap-cell { width: 14px; height: 14px; border-radius: 3px; background: var(--border); position: relative; }
|
||
.heatmap-cell[title]:hover::after { content: attr(title); position: absolute; bottom: 18px; left: 50%; transform: translateX(-50%); background: rgba(0,0,0,0.8); color: #fff; padding: 3px 7px; border-radius: 5px; font-size: 11px; white-space: nowrap; pointer-events: none; z-index: 10; }
|
||
.heatmap-legend { display: flex; align-items: center; gap: 0.4rem; margin-top: 0.6rem; font-size: 0.72rem; color: var(--ink-light); }
|
||
.heatmap-legend-swatch { width: 12px; height: 12px; border-radius: 2px; }
|
||
/* Alert history table */
|
||
.alert-history-bar { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.85rem; flex-wrap: wrap; }
|
||
.alert-history-table-wrap { overflow-x: auto; }
|
||
.alert-history-table { width: 100%; border-collapse: collapse; font-size: 0.83rem; }
|
||
.alert-history-table th { text-align: left; padding: 0.4rem 0.75rem; font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.06em; color: var(--ink-light); border-bottom: 1px solid var(--border); white-space: nowrap; }
|
||
.alert-history-table td { padding: 0.45rem 0.75rem; border-bottom: 1px solid var(--border); vertical-align: top; }
|
||
.alert-history-table tr:last-child td { border-bottom: none; }
|
||
.alert-history-table tr:hover td { background: var(--paper-darker); }
|
||
.ah-badge { display: inline-block; font-size: 0.68rem; font-weight: 700; padding: 0.15rem 0.45rem; border-radius: 4px; text-transform: uppercase; letter-spacing: 0.04em; white-space: nowrap; }
|
||
.ah-badge.nws { background: rgba(194,90,42,0.15); color: var(--accent); border: 1px solid color-mix(in srgb, var(--accent) 40%, transparent); }
|
||
.ah-badge.threshold { background: rgba(42,107,138,0.15); color: var(--cool); border: 1px solid color-mix(in srgb, var(--cool) 40%, transparent); }
|
||
.ah-body { color: var(--ink-light); font-size: 0.78rem; margin-top: 0.1rem; line-height: 1.4; }
|
||
.ah-empty { padding: 1.5rem; text-align: center; color: var(--ink-light); font-size: 0.85rem; }
|
||
|
||
.hidden { display: none !important; }
|
||
|
||
/* ── Section cards ── */
|
||
.section-card {
|
||
border-radius: 16px; padding: 1.25rem 1.5rem; margin-bottom: 1rem;
|
||
border: 1px solid transparent;
|
||
background:
|
||
linear-gradient(var(--paper-dark), var(--paper-dark)) padding-box,
|
||
linear-gradient(140deg, color-mix(in srgb, var(--accent) 40%, var(--border)), var(--border) 50%, color-mix(in srgb, var(--cool) 38%, var(--border))) border-box;
|
||
}
|
||
.section-card-hdr { font-size: 0.68rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.09em; color: var(--ink-light); margin-bottom: 1.1rem; }
|
||
|
||
/* ── Hero ── */
|
||
.hero { display: flex; gap: 2rem; align-items: flex-start; flex-wrap: wrap; }
|
||
.hero-main { flex: 0 0 auto; min-width: 160px; }
|
||
.hero-temp-row { display: flex; align-items: baseline; gap: 0.25rem; }
|
||
.hero-temp { font-size: 5.5rem; font-weight: 800; line-height: 1; letter-spacing: -0.03em; transition: color 0.4s; }
|
||
.hero-temp-unit { font-size: 2rem; font-weight: 300; color: var(--ink-light); align-self: flex-start; margin-top: 0.6rem; }
|
||
.hero-sub-line { font-size: 0.92rem; color: var(--ink-light); margin-top: 0.3rem; }
|
||
.hero-grid { flex: 1; display: grid; grid-template-columns: repeat(3, 1fr); gap: 0.9rem 1.5rem; min-width: 260px; align-content: start; }
|
||
.hero-stat-label { font-size: 0.67rem; text-transform: uppercase; letter-spacing: 0.06em; color: var(--ink-light); margin-bottom: 0.1rem; }
|
||
.hero-stat-val { font-size: 1.45rem; font-weight: 700; line-height: 1.15; }
|
||
.hero-stat-unit { font-size: 0.72rem; color: var(--ink-light); }
|
||
@media (max-width: 640px) {
|
||
.hero { flex-direction: column; gap: 1.25rem; }
|
||
.hero-temp { font-size: 4.5rem; }
|
||
.hero-grid { grid-template-columns: repeat(3, 1fr); min-width: 0; width: 100%; }
|
||
.hero-stat-val { font-size: 1.15rem; }
|
||
}
|
||
|
||
/* ── Today bar ── */
|
||
.today-bar { display: flex; align-items: center; gap: 0.75rem; background: var(--paper-dark); border: 1px solid var(--border); border-radius: 12px; padding: 0.65rem 1.1rem; margin-bottom: 1rem; font-size: 0.88rem; flex-wrap: wrap; }
|
||
.today-label { font-size: 0.67rem; text-transform: uppercase; letter-spacing: 0.08em; color: var(--ink-light); font-weight: 700; margin-right: 0.1rem; flex-shrink: 0; }
|
||
.today-item { display: flex; align-items: baseline; gap: 0.25rem; }
|
||
.today-icon { font-size: 0.8rem; }
|
||
.today-val { font-weight: 700; }
|
||
.today-val.hi { color: var(--accent); }
|
||
.today-val.lo { color: var(--cool); }
|
||
.today-sep { width: 1px; height: 1.1em; background: var(--border); flex-shrink: 0; }
|
||
.today-lbl { font-size: 0.75rem; color: var(--ink-light); }
|
||
|
||
/* ── Ecobee section ── */
|
||
|
||
/* ── Tile color tints ── */
|
||
.stat-hot { border-color: rgba(220,80,30,0.55) !important; background: rgba(220,80,30,0.07) !important; }
|
||
.stat-warm { border-color: rgba(200,155,0,0.5) !important; background: rgba(200,155,0,0.06) !important; }
|
||
.stat-cool { border-color: rgba(42,140,200,0.5) !important; background: rgba(42,140,200,0.07) !important; }
|
||
.stat-cold { border-color: rgba(60,100,200,0.55) !important; background: rgba(60,100,200,0.09) !important; }
|
||
.stat-raining { border-color: rgba(42,107,138,0.6) !important; background: rgba(42,107,138,0.09) !important; }
|
||
.stat-uv-mod { border-color: rgba(200,155,0,0.5) !important; background: rgba(200,155,0,0.07) !important; }
|
||
.stat-uv-high { border-color: rgba(210,80,20,0.55) !important; background: rgba(210,80,20,0.08) !important; }
|
||
.stat-uv-xhigh { border-color: rgba(150,0,70,0.5) !important; background: rgba(150,0,70,0.08) !important; }
|
||
|
||
/* ── Sparklines ── */
|
||
canvas.sparkline { display: block; width: 100%; height: 22px; margin-top: 0.4rem; opacity: 0.65; }
|
||
|
||
/* ── Secondary stats strip ── */
|
||
.stats.secondary { grid-template-columns: repeat(auto-fill, minmax(130px, 1fr)); margin-bottom: 1rem; }
|
||
|
||
/* ── Updated chart look ── */
|
||
.chart-wrap { position: relative; height: 240px; }
|
||
.chart-card.wide .chart-wrap { height: 160px; }
|
||
.chart-card {
|
||
transition: transform .18s cubic-bezier(.2,0,0,1), box-shadow .18s cubic-bezier(.2,0,0,1);
|
||
}
|
||
.chart-card:hover { transform: translateY(-1px); box-shadow: 0 4px 20px rgba(0,0,0,0.12); }
|
||
|
||
/* ── Today bar hover ── */
|
||
.today-bar { transition: transform .18s cubic-bezier(.2,0,0,1), box-shadow .18s cubic-bezier(.2,0,0,1); }
|
||
.today-bar:hover { transform: translateY(-1px); box-shadow: 0 4px 16px rgba(0,0,0,0.1); }
|
||
|
||
/* ── Live dot ── */
|
||
.live-dot {
|
||
display: inline-block; width: 6px; height: 6px; border-radius: 50%;
|
||
background: #3cba6e; margin-right: 5px;
|
||
vertical-align: middle; position: relative; bottom: 1px;
|
||
animation: live-pulse 2.4s ease-in-out infinite;
|
||
}
|
||
@keyframes live-pulse {
|
||
0%,100% { opacity:1; transform:scale(1); }
|
||
50% { opacity:.35; transform:scale(.65); }
|
||
}
|
||
|
||
/* ── Skeleton shimmer ── */
|
||
@keyframes shimmer {
|
||
from { background-position: 200% 0; }
|
||
to { background-position: -200% 0; }
|
||
}
|
||
.stat.loading .stat-val {
|
||
background: linear-gradient(90deg, var(--border) 25%, var(--paper-darker) 50%, var(--border) 75%);
|
||
background-size: 200% 100%;
|
||
animation: shimmer 1.5s infinite linear;
|
||
color: transparent !important;
|
||
border-radius: 4px; min-width: 48px; display: inline-block;
|
||
}
|
||
.stat.loading .stat-unit { opacity: 0; }
|
||
|
||
/* ── Value flash on data update ── */
|
||
@keyframes val-flash {
|
||
0% { opacity: 0.15; transform: translateY(4px); }
|
||
100% { opacity: 1; transform: translateY(0); }
|
||
}
|
||
.val-flash { animation: val-flash .4s cubic-bezier(.2,0,0,1); }
|
||
|
||
/* ── Record best highlight ── */
|
||
.record-item.record-best {
|
||
border-color: rgba(255,195,0,0.65);
|
||
box-shadow: 0 0 0 2px rgba(255,195,0,0.12);
|
||
}
|
||
.record-best .record-label::after { content: ' ★'; color: #ffc300; }
|
||
|
||
/* ── iPhone / touch ── */
|
||
html { -webkit-text-size-adjust: 100%; text-size-adjust: 100%; -webkit-tap-highlight-color: transparent; }
|
||
body { overscroll-behavior-y: none; min-height: 100vh; min-height: 100dvh; }
|
||
.header { padding-top: calc(1rem + env(safe-area-inset-top)); padding-left: calc(1.5rem + env(safe-area-inset-left)); padding-right: calc(1.5rem + env(safe-area-inset-right)); }
|
||
.page { padding-left: calc(1rem + env(safe-area-inset-left)); padding-right: calc(1rem + env(safe-area-inset-right)); padding-bottom: calc(4rem + env(safe-area-inset-bottom)); }
|
||
/* Let the page scroll when a finger starts on a chart; taps/long-press still show tooltips */
|
||
.chart-wrap canvas { touch-action: pan-y; -webkit-touch-callout: none; user-select: none; -webkit-user-select: none; }
|
||
button, .range-btn, .refresh-btn, .logout, summary { touch-action: manipulation; }
|
||
input, select, textarea { font-size: 16px; } /* stops iOS zooming in on focus */
|
||
@media (hover: none) {
|
||
.chart-card:hover, .today-bar:hover { transform: none; box-shadow: none; }
|
||
}
|
||
@media (pointer: coarse) {
|
||
.range-btn, .refresh-btn, .logout { min-height: 44px; display: inline-flex; align-items: center; justify-content: center; padding-left: 0.9rem; padding-right: 0.9rem; }
|
||
.settings-panel summary { min-height: 48px; }
|
||
}
|
||
@media (max-width: 640px) {
|
||
.header { flex-wrap: wrap; gap: 0.6rem; padding-left: calc(1rem + env(safe-area-inset-left)); padding-right: calc(1rem + env(safe-area-inset-right)); }
|
||
.header-right { width: 100%; flex-wrap: nowrap; overflow-x: auto; gap: 0.5rem; -webkit-overflow-scrolling: touch; scrollbar-width: none; padding-bottom: 2px; }
|
||
.header-right::-webkit-scrollbar { display: none; }
|
||
.header-right > * { flex-shrink: 0; }
|
||
.range-bar { flex-wrap: nowrap; overflow-x: auto; -webkit-overflow-scrolling: touch; scrollbar-width: none; margin-left: -1rem; margin-right: -1rem; padding: 0 1rem 0.25rem; }
|
||
.range-bar::-webkit-scrollbar { display: none; }
|
||
.range-bar > * { flex-shrink: 0; }
|
||
.today-bar { flex-wrap: wrap; row-gap: 0.4rem; }
|
||
.chart-card { padding: 0.8rem 0.8rem 1rem; }
|
||
.chart-wrap { height: 220px; }
|
||
.settings-body { padding: 0.9rem; }
|
||
.records-grid { grid-template-columns: repeat(2, 1fr); }
|
||
.record-val { font-size: 1.05rem; }
|
||
}
|
||
/* Records tables */
|
||
.rec-h { font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.06em; color: var(--ink-light); margin: 1.1rem 0 0.5rem; }
|
||
.rec-scroll { overflow-x: auto; -webkit-overflow-scrolling: touch; }
|
||
.rec-table { width: 100%; border-collapse: collapse; font-size: 0.82rem; white-space: nowrap; }
|
||
.rec-table th, .rec-table td { text-align: right; padding: 0.4rem 0.6rem; border-bottom: 1px solid var(--border); }
|
||
.rec-table th:first-child, .rec-table td:first-child { text-align: left; }
|
||
.rec-table th { font-size: 0.68rem; text-transform: uppercase; letter-spacing: 0.05em; color: var(--ink-light); font-weight: 600; }
|
||
.rec-actions { display: flex; flex-wrap: wrap; gap: 0.5rem; margin-top: 1.1rem; }
|
||
.rec-actions a { display: inline-flex; align-items: center; min-height: 40px; padding: 0 0.9rem; border: 1px solid var(--border); border-radius: 8px; font-size: 0.8rem; font-weight: 600; background: var(--paper); }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<div class="header">
|
||
<div>
|
||
<h1>Station Dashboard</h1>
|
||
<div class="meta" id="headerMeta">Loading…</div>
|
||
</div>
|
||
<div class="header-right">
|
||
<span class="station-name" id="stationName"></span>
|
||
<a href="/conditions" class="logout">Conditions</a>
|
||
<a href="/" class="logout">← Weather</a>
|
||
<a href="/dashboard/settings" class="logout">Settings</a>
|
||
<a href="/dashboard/logout" class="logout">Sign out</a>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="page">
|
||
<div class="offline-banner" id="offlineBanner"></div>
|
||
<div id="alertsSection" style="margin-bottom:0"></div>
|
||
|
||
<!-- Hero: Current Conditions -->
|
||
<div class="section-card">
|
||
<div class="section-card-hdr">Current Conditions</div>
|
||
<div class="hero">
|
||
<div class="hero-main">
|
||
<div class="hero-temp-row">
|
||
<div class="hero-temp" id="sTempF">—</div>
|
||
<div class="hero-temp-unit">°F</div>
|
||
</div>
|
||
<div class="hero-sub-line">Feels like <span id="sFeels">—</span>° F</div>
|
||
<div class="hero-sub-line" id="heroWindLine" style="margin-top:0.15rem"></div>
|
||
</div>
|
||
<div class="hero-grid">
|
||
<div>
|
||
<div class="hero-stat-label">Humidity</div>
|
||
<div class="hero-stat-val" id="sHum">—<span class="stat-trend" id="trHum"></span></div>
|
||
<div class="hero-stat-unit">%</div>
|
||
<canvas class="sparkline" id="spHum"></canvas>
|
||
</div>
|
||
<div>
|
||
<div class="hero-stat-label">Dew Point</div>
|
||
<div class="hero-stat-val" id="sDew">—<span class="stat-trend" id="trDew"></span></div>
|
||
<div class="hero-stat-unit">°F</div>
|
||
</div>
|
||
<div>
|
||
<div class="hero-stat-label">Pressure</div>
|
||
<div class="hero-stat-val" id="sPres">—<span class="stat-trend" id="trPres"></span></div>
|
||
<div class="hero-stat-unit">inHg <span id="sTrend" style="font-size:0.9rem"></span></div>
|
||
<canvas class="sparkline" id="spPres"></canvas>
|
||
</div>
|
||
<div>
|
||
<div class="hero-stat-label">UV Index</div>
|
||
<div class="hero-stat-val" id="sUV">—<span class="stat-trend" id="trUV"></span></div>
|
||
<div class="hero-stat-unit"></div>
|
||
</div>
|
||
<div>
|
||
<div class="hero-stat-label">Solar Rad.</div>
|
||
<div class="hero-stat-val" id="sSolar">—<span class="stat-trend" id="trSolar"></span></div>
|
||
<div class="hero-stat-unit">W/m²</div>
|
||
<canvas class="sparkline" id="spSolar"></canvas>
|
||
</div>
|
||
<div>
|
||
<div class="hero-stat-label">Battery</div>
|
||
<div class="hero-stat-val" id="sBatt">—</div>
|
||
<div class="hero-stat-unit"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Today at a glance -->
|
||
<div class="today-bar" id="todayBar" style="display:none">
|
||
<span class="today-label">Today</span>
|
||
<div class="today-item"><span class="today-icon">↑</span><span class="today-val hi" id="todayHigh">—</span><span class="today-lbl">°F high</span></div>
|
||
<div class="today-item"><span class="today-icon">↓</span><span class="today-val lo" id="todayLow">—</span><span class="today-lbl">°F low</span></div>
|
||
<div class="today-sep"></div>
|
||
<div class="today-item"><span class="today-icon">💧</span><span class="today-val" id="todayRain">—</span><span class="today-lbl">" rain</span></div>
|
||
<div class="today-item"><span class="today-icon">💨</span><span class="today-val" id="todayGust">—</span><span class="today-lbl"> mph peak gust</span></div>
|
||
</div>
|
||
|
||
<!-- Wind section -->
|
||
<div class="section-card" style="margin-bottom:1rem">
|
||
<div class="section-card-hdr">Wind</div>
|
||
<div class="stats secondary" style="margin:0">
|
||
<div class="stat" id="statWindTile">
|
||
<div class="stat-label">Speed</div>
|
||
<div class="stat-val" id="sWind">—<span class="stat-trend" id="trWind"></span></div>
|
||
<div class="stat-unit">mph</div>
|
||
<div class="stat-unit" id="sWindDir" style="color:var(--ink-light)"></div>
|
||
<canvas class="sparkline" id="spWind"></canvas>
|
||
</div>
|
||
<div class="stat"><div class="stat-label">Gust</div><div class="stat-val" id="sGust">—</div><div class="stat-unit">mph</div></div>
|
||
<div class="stat"><div class="stat-label">Peak Gust Today</div><div class="stat-val" id="sMaxGust">—</div><div class="stat-unit">mph</div></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Precipitation section -->
|
||
<div class="section-card" style="margin-bottom:1rem">
|
||
<div class="section-card-hdr">Precipitation</div>
|
||
<div class="stats secondary" style="margin:0">
|
||
<div class="stat" id="statRainTile"><div class="stat-label">Today</div><div class="stat-val" id="sRain">—</div><div class="stat-unit">in</div></div>
|
||
<div class="stat" id="statRainRateTile"><div class="stat-label">Rate</div><div class="stat-val" id="sRainRate">—</div><div class="stat-unit">in/hr</div></div>
|
||
<div class="stat"><div class="stat-label">7 Days</div><div class="stat-val" id="sRain7d">—</div><div class="stat-unit">in</div></div>
|
||
<div class="stat"><div class="stat-label">30 Days</div><div class="stat-val" id="sRain30d">—</div><div class="stat-unit">in</div></div>
|
||
<div class="stat"><div class="stat-label">Year to Date</div><div class="stat-val" id="sRainYTD">—</div><div class="stat-unit">in</div></div>
|
||
<div class="stat" id="sEventRainTile" style="display:none"><div class="stat-label">Event</div><div class="stat-val" id="sEventRain">—</div><div class="stat-unit">in</div></div>
|
||
<div class="stat" id="sTotalRainTile" style="display:none"><div class="stat-label">All Time</div><div class="stat-val" id="sTotalRain">—</div><div class="stat-unit">in</div></div>
|
||
<div class="stat" id="sStreakTile" style="display:none">
|
||
<div class="stat-label">Streak</div>
|
||
<div class="stat-val" id="sStreak">—</div>
|
||
<div class="stat-unit" id="sStreakNote"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Climate analytics -->
|
||
<div class="section-card" style="margin-bottom:1rem">
|
||
<div class="section-card-hdr">Climate</div>
|
||
<div class="stats secondary" style="margin:0">
|
||
<div class="stat"><div class="stat-label">Heating DD</div><div class="stat-val" id="sHDD">—</div><div class="stat-unit">this month</div></div>
|
||
<div class="stat"><div class="stat-label">Cooling DD</div><div class="stat-val" id="sCDD">—</div><div class="stat-unit">this month</div></div>
|
||
<div class="stat" id="sAnomalyTile" style="display:none">
|
||
<div class="stat-label">Temp vs Normal</div>
|
||
<div class="stat-val" id="sAnomaly">—</div>
|
||
<div class="stat-unit" id="sAnomalyNote"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Indoor (shown only when PWS provides indoor sensor data) -->
|
||
<div class="section-card hidden" id="indoorSection" style="margin-bottom:1rem">
|
||
<div class="section-card-hdr" style="display:flex;align-items:center;justify-content:space-between">
|
||
Indoor
|
||
<a href="https://www.ecobee.com/consumerportal/index.html#/devices/thermostats/440785466129" target="_blank" rel="noopener" style="font-size:0.75rem;font-weight:400;text-transform:none;letter-spacing:0;color:var(--cool)">Ecobee →</a>
|
||
</div>
|
||
<div class="stats secondary" style="margin:0">
|
||
<div class="stat">
|
||
<div class="stat-label">Temperature</div>
|
||
<div class="stat-val" id="sTempIn">—</div>
|
||
<div class="stat-unit">°F</div>
|
||
</div>
|
||
<div class="stat">
|
||
<div class="stat-label">Humidity</div>
|
||
<div class="stat-val" id="sHumIn">—</div>
|
||
<div class="stat-unit">%</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Range selector -->
|
||
<div class="range-bar">
|
||
<span>History:</span>
|
||
<button class="range-btn" data-range="1h">1h</button>
|
||
<button class="range-btn" data-range="6h">6h</button>
|
||
<button class="range-btn active" data-range="24h">24h</button>
|
||
<button class="range-btn" data-range="7d">7d</button>
|
||
<button class="range-btn" data-range="30d">30d</button>
|
||
<button class="range-btn" data-range="90d">90d</button>
|
||
<button class="range-btn" data-range="1y">1y</button>
|
||
<button class="range-btn" data-range="2y">2y</button>
|
||
<button class="range-btn" data-range="3y">3y</button>
|
||
<button class="refresh-btn" id="refreshBtn">↻ Refresh</button>
|
||
<button class="range-btn" id="yoyBtn" style="display:none" title="Overlay same period from 1 year ago">YoY</button>
|
||
</div>
|
||
<div class="updated" id="updatedLine"></div>
|
||
|
||
<!-- Charts -->
|
||
<div id="chartsArea">
|
||
<div class="status" id="loadingMsg">Loading history…</div>
|
||
</div>
|
||
|
||
<!-- Records -->
|
||
<details class="settings-panel" id="recordsPanel" style="margin-top:1.5rem">
|
||
<summary>All-Time Station Records</summary>
|
||
<div class="settings-body" id="recordsBody">
|
||
<div class="status">Loading records…</div>
|
||
</div>
|
||
</details>
|
||
|
||
<!-- Monthly Summary -->
|
||
<details class="settings-panel" id="monthlyPanel" style="margin-top:1rem">
|
||
<summary>Monthly Summary</summary>
|
||
<div class="settings-body" id="monthlyBody">
|
||
<div class="status">Loading…</div>
|
||
</div>
|
||
</details>
|
||
|
||
<!-- Temperature Heatmap -->
|
||
<details class="settings-panel" id="heatmapPanel" style="margin-top:1rem">
|
||
<summary>Temperature Calendar</summary>
|
||
<div class="settings-body" id="heatmapBody">
|
||
<div class="status">Loading…</div>
|
||
</div>
|
||
</details>
|
||
|
||
<!-- Alert History -->
|
||
<details class="settings-panel" id="alertHistoryPanel" style="margin-top:1rem">
|
||
<summary>Alert History</summary>
|
||
<div class="settings-body" id="alertHistoryBody">
|
||
<div class="status">Loading…</div>
|
||
</div>
|
||
</details>
|
||
|
||
</div>
|
||
|
||
<script>
|
||
const RANGE_LABELS = {
|
||
'1h': 'HH:mm', '6h': 'HH:mm', '24h': 'HH:mm',
|
||
'7d': 'MMM dd', '30d': 'MMM dd'
|
||
};
|
||
|
||
let currentRange = '24h';
|
||
let charts = {};
|
||
|
||
// ── Formatting helpers ──────────────────────────────────────────────────────
|
||
function fmtTime(ts, range) {
|
||
const d = new Date(ts);
|
||
if (['7d','30d','90d','1y','2y','3y'].includes(range)) {
|
||
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
||
}
|
||
return d.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false });
|
||
}
|
||
|
||
function cardinal(deg) {
|
||
const dirs = ['N','NNE','NE','ENE','E','ESE','SE','SSE','S','SSW','SW','WSW','W','WNW','NW','NNW'];
|
||
return dirs[Math.round(deg / 22.5) % 16];
|
||
}
|
||
|
||
// ── Chart factory ───────────────────────────────────────────────────────────
|
||
const accent = getComputedStyle(document.documentElement).getPropertyValue('--accent').trim() || '#c25a2a';
|
||
const cool = getComputedStyle(document.documentElement).getPropertyValue('--cool').trim() || '#2a6b8a';
|
||
|
||
function chartColors() {
|
||
const style = getComputedStyle(document.documentElement);
|
||
return {
|
||
accent: style.getPropertyValue('--accent').trim(),
|
||
cool: style.getPropertyValue('--cool').trim(),
|
||
ink: style.getPropertyValue('--ink').trim(),
|
||
inkLight: style.getPropertyValue('--ink-light').trim(),
|
||
border: style.getPropertyValue('--border').trim(),
|
||
};
|
||
}
|
||
|
||
function makeChart(canvasId, type, datasets, labels, yLabel, opts = {}) {
|
||
const c = chartColors();
|
||
const ctx = document.getElementById(canvasId);
|
||
if (!ctx) return null;
|
||
if (charts[canvasId]) { charts[canvasId].destroy(); }
|
||
|
||
const defaults = {
|
||
responsive: true,
|
||
maintainAspectRatio: false,
|
||
interaction: { mode: 'index', intersect: false },
|
||
plugins: {
|
||
legend: { display: datasets.length > 1, labels: { color: c.inkLight, font: { size: 11 }, boxWidth: 10 } },
|
||
tooltip: {
|
||
backgroundColor: 'rgba(0,0,0,0.75)',
|
||
titleFont: { size: 11 }, bodyFont: { size: 12 },
|
||
callbacks: {
|
||
title: items => items[0]?.label || '',
|
||
}
|
||
}
|
||
},
|
||
scales: {
|
||
x: {
|
||
ticks: { color: c.inkLight, font: { size: 10 }, maxTicksLimit: 8, maxRotation: 0 },
|
||
grid: { color: c.border + '44' }
|
||
},
|
||
y: {
|
||
ticks: { color: c.inkLight, font: { size: 11 } },
|
||
grid: { color: c.border + '44' },
|
||
title: yLabel ? { display: true, text: yLabel, color: c.inkLight, font: { size: 10 } } : undefined,
|
||
}
|
||
}
|
||
};
|
||
|
||
const mergedOpts = deepMerge(defaults, opts);
|
||
charts[canvasId] = new Chart(ctx, { type, data: { labels, datasets }, options: mergedOpts });
|
||
return charts[canvasId];
|
||
}
|
||
|
||
function deepMerge(a, b) {
|
||
const out = { ...a };
|
||
for (const k of Object.keys(b || {})) {
|
||
if (b[k] && typeof b[k] === 'object' && !Array.isArray(b[k]) && a[k] && typeof a[k] === 'object') {
|
||
out[k] = deepMerge(a[k], b[k]);
|
||
} else {
|
||
out[k] = b[k];
|
||
}
|
||
}
|
||
return out;
|
||
}
|
||
|
||
function lineDataset(label, data, color, dashed = false) {
|
||
return {
|
||
label, data, borderColor: color,
|
||
backgroundColor: dashed ? color + '00' : color + '18',
|
||
borderWidth: 1.75, pointRadius: 0, pointHoverRadius: 4,
|
||
borderDash: dashed ? [4, 3] : [], tension: 0.35,
|
||
fill: dashed ? false : 'origin',
|
||
};
|
||
}
|
||
|
||
function barDataset(label, data, color) {
|
||
return { label, data, backgroundColor: color + 'bb', borderColor: color, borderWidth: 1, borderRadius: 2 };
|
||
}
|
||
|
||
// ── Wind Rose ───────────────────────────────────────────────────────────────
|
||
function drawWindRose(canvas, readings) {
|
||
const buckets = new Array(16).fill(0);
|
||
for (const r of readings) {
|
||
if (r.winddir == null) continue;
|
||
buckets[Math.round(r.winddir / 22.5) % 16]++;
|
||
}
|
||
const max = Math.max(...buckets, 1);
|
||
const ctx = canvas.getContext('2d');
|
||
const dpr = window.devicePixelRatio || 1;
|
||
const W = canvas.width / dpr, H = canvas.height / dpr;
|
||
const cx = W / 2, cy = H / 2;
|
||
const R = Math.min(cx, cy) - 24;
|
||
const style = getComputedStyle(document.documentElement);
|
||
const colBorder = style.getPropertyValue('--border').trim();
|
||
const colAccent = style.getPropertyValue('--accent').trim();
|
||
const colInk = style.getPropertyValue('--ink').trim();
|
||
const colInkLight = style.getPropertyValue('--ink-light').trim();
|
||
const sliceAngle = (Math.PI * 2) / 16;
|
||
|
||
function drawFrame(progress) {
|
||
ctx.clearRect(0, 0, W, H);
|
||
// grid circles
|
||
for (let i = 1; i <= 4; i++) {
|
||
ctx.beginPath(); ctx.arc(cx, cy, R * i / 4, 0, Math.PI * 2);
|
||
ctx.strokeStyle = colBorder + '66'; ctx.lineWidth = 0.5; ctx.stroke();
|
||
}
|
||
// cardinal lines + labels
|
||
['N','E','S','W'].forEach((dir, i) => {
|
||
const a = i * Math.PI / 2 - Math.PI / 2;
|
||
ctx.beginPath(); ctx.moveTo(cx, cy); ctx.lineTo(cx + Math.cos(a) * R, cy + Math.sin(a) * R);
|
||
ctx.strokeStyle = colBorder + '88'; ctx.lineWidth = 0.5; ctx.stroke();
|
||
ctx.fillStyle = colInkLight; ctx.font = '10px sans-serif';
|
||
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
|
||
ctx.fillText(dir, cx + Math.cos(a) * (R + 12), cy + Math.sin(a) * (R + 12));
|
||
});
|
||
// petals
|
||
for (let i = 0; i < 16; i++) {
|
||
const a = i * sliceAngle - Math.PI / 2 - sliceAngle / 2;
|
||
const len = (buckets[i] / max) * R * progress;
|
||
if (len < 1) continue;
|
||
ctx.beginPath(); ctx.moveTo(cx, cy); ctx.arc(cx, cy, len, a, a + sliceAngle); ctx.closePath();
|
||
ctx.fillStyle = colAccent + 'cc'; ctx.fill();
|
||
ctx.strokeStyle = colAccent; ctx.lineWidth = 0.5; ctx.stroke();
|
||
}
|
||
// center dot
|
||
ctx.beginPath(); ctx.arc(cx, cy, 3, 0, Math.PI * 2);
|
||
ctx.fillStyle = colInk; ctx.fill();
|
||
}
|
||
|
||
// Animate petals growing in over 650ms
|
||
const dur = 650, t0 = performance.now();
|
||
(function animate(now) {
|
||
const p = Math.min((now - t0) / dur, 1);
|
||
const ease = 1 - Math.pow(1 - p, 3);
|
||
drawFrame(ease);
|
||
if (p < 1) requestAnimationFrame(animate);
|
||
})(t0);
|
||
}
|
||
|
||
// ── Render charts ────────────────────────────────────────────────────────────
|
||
function renderCharts(readings, range, yoy = null) {
|
||
const area = document.getElementById('chartsArea');
|
||
if (!readings.length) {
|
||
area.innerHTML = '<div class="status">No recorded data for this range yet. Data is collected every minute while the station is active.</div>';
|
||
return;
|
||
}
|
||
|
||
const labels = readings.map(r => fmtTime(r.dateutc, range));
|
||
const c = chartColors();
|
||
|
||
// downsample for 30d to keep chart manageable
|
||
let pts = readings;
|
||
if (readings.length > 720) {
|
||
const step = Math.ceil(readings.length / 720);
|
||
pts = readings.filter((_, i) => i % step === 0);
|
||
}
|
||
const lbls = pts.map(r => fmtTime(r.dateutc, range));
|
||
|
||
function field(arr, key) { return arr.map(r => r[key] != null ? r[key] : null); }
|
||
|
||
area.innerHTML = `
|
||
<div class="charts">
|
||
<!-- Temperature -->
|
||
<div class="chart-card">
|
||
<div class="chart-title">Temperature & Feels Like</div>
|
||
<div class="chart-wrap"><canvas id="cTemp"></canvas></div>
|
||
</div>
|
||
|
||
<!-- Humidity & Dew -->
|
||
<div class="chart-card">
|
||
<div class="chart-title">Humidity & Dew Point</div>
|
||
<div class="chart-wrap"><canvas id="cHum"></canvas></div>
|
||
</div>
|
||
|
||
<!-- Wind speed -->
|
||
<div class="chart-card">
|
||
<div class="chart-title">Wind Speed & Gusts</div>
|
||
<div class="chart-wrap"><canvas id="cWind"></canvas></div>
|
||
</div>
|
||
|
||
<!-- Wind rose -->
|
||
<div class="chart-card">
|
||
<div class="chart-title">Wind Direction</div>
|
||
<div class="chart-wrap" id="roseWrap" style="display:flex;align-items:center;justify-content:center;">
|
||
<canvas id="windRoseCanvas" width="200" height="200"></canvas>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Pressure -->
|
||
<div class="chart-card wide">
|
||
<div class="chart-title">Barometric Pressure (inHg)</div>
|
||
<div class="chart-wrap"><canvas id="cPres"></canvas></div>
|
||
</div>
|
||
|
||
<!-- Rain -->
|
||
<div class="chart-card">
|
||
<div class="chart-title">Rain Rate (in/hr)</div>
|
||
<div class="chart-wrap"><canvas id="cRainRate"></canvas></div>
|
||
</div>
|
||
|
||
<!-- Rain accum -->
|
||
<div class="chart-card">
|
||
<div class="chart-title">Daily Rain Accumulation (in)</div>
|
||
<div class="chart-wrap"><canvas id="cRainAccum"></canvas></div>
|
||
</div>
|
||
|
||
<!-- UV + Solar -->
|
||
<div class="chart-card">
|
||
<div class="chart-title">UV Index</div>
|
||
<div class="chart-wrap"><canvas id="cUV"></canvas></div>
|
||
</div>
|
||
|
||
<div class="chart-card">
|
||
<div class="chart-title">Solar Radiation (W/m²)</div>
|
||
<div class="chart-wrap"><canvas id="cSolar"></canvas></div>
|
||
</div>
|
||
|
||
</div>
|
||
`;
|
||
|
||
const tempDatasets = [
|
||
lineDataset('Temp °F', field(pts, 'tempf'), c.accent),
|
||
lineDataset('Feels Like', field(pts, 'feelsLike'), c.cool, true),
|
||
];
|
||
if (yoy?.prior?.length) {
|
||
const yoyPts = yoy.prior.length > 720 ? yoy.prior.filter((_, i) => i % Math.ceil(yoy.prior.length / 720) === 0) : yoy.prior;
|
||
tempDatasets.push({ ...lineDataset('Last Year', yoyPts.map(r => r.tempf), c.accent + '55', true), borderWidth: 1 });
|
||
}
|
||
makeChart('cTemp', 'line', tempDatasets, lbls, '°F');
|
||
|
||
makeChart('cHum', 'line',
|
||
[lineDataset('Humidity %', field(pts, 'humidity'), c.cool),
|
||
lineDataset('Dew Point °F', field(pts, 'dewPoint'), c.accent, true)],
|
||
lbls, null);
|
||
|
||
makeChart('cWind', 'line',
|
||
[lineDataset('Wind mph', field(pts, 'windspeedmph'), c.cool),
|
||
lineDataset('Gust mph', field(pts, 'windgustmph'), c.accent, true)],
|
||
lbls, 'mph');
|
||
|
||
const hasPres = pts.some(r => r.baromrelin != null);
|
||
if (hasPres) {
|
||
makeChart('cPres', 'line',
|
||
[lineDataset('Rel. Pressure', field(pts, 'baromrelin'), c.accent),
|
||
lineDataset('Abs. Pressure', field(pts, 'baromabsin'), c.cool, true)],
|
||
lbls, 'inHg');
|
||
}
|
||
|
||
makeChart('cRainRate', 'bar',
|
||
[barDataset('Rain Rate', field(pts, 'rainratein'), c.cool)],
|
||
lbls, 'in/hr');
|
||
|
||
makeChart('cRainAccum', 'line',
|
||
[lineDataset('Daily Rain', field(pts, 'dailyrainin'), c.cool)],
|
||
lbls, 'in', { scales: { y: { min: 0 } } });
|
||
|
||
makeChart('cUV', 'line',
|
||
[lineDataset('UV Index', field(pts, 'uv'), c.accent)],
|
||
lbls, null, { scales: { y: { min: 0 } } });
|
||
|
||
makeChart('cSolar', 'line',
|
||
[lineDataset('Solar W/m²', field(pts, 'solarradiation'), c.accent)],
|
||
lbls, 'W/m²', { scales: { y: { min: 0 } } });
|
||
|
||
// Today bar + sparklines use full readings (not downsampled pts)
|
||
updateTodayBar(readings);
|
||
updateSparklines(readings);
|
||
|
||
// Wind rose
|
||
const roseCanvas = document.getElementById('windRoseCanvas');
|
||
if (roseCanvas) {
|
||
const dpr = window.devicePixelRatio || 1;
|
||
roseCanvas.width = 200 * dpr;
|
||
roseCanvas.height = 200 * dpr;
|
||
roseCanvas.style.width = '200px';
|
||
roseCanvas.style.height = '200px';
|
||
roseCanvas.getContext('2d').scale(dpr, dpr);
|
||
drawWindRose(roseCanvas, readings);
|
||
}
|
||
}
|
||
|
||
// ── Update current stats ─────────────────────────────────────────────────────
|
||
async function updateLatest() {
|
||
try {
|
||
const r = await fetch('/api/station/latest');
|
||
if (!r.ok) return;
|
||
const d = await r.json();
|
||
if (!d) return;
|
||
|
||
const set = (id, val, digits = 1) => {
|
||
const el = document.getElementById(id);
|
||
if (!el) return;
|
||
const text = val != null ? (typeof val === 'number' ? val.toFixed(digits) : String(val)) : '—';
|
||
// Preserve child elements (trend spans) by only updating the leading text node
|
||
const tn = el.firstChild;
|
||
if (tn && tn.nodeType === 3) tn.textContent = text;
|
||
else el.textContent = text;
|
||
};
|
||
|
||
// Count-up animation for hero temperature
|
||
const animateHeroTemp = (val, digits = 1) => {
|
||
const el = document.getElementById('sTempF');
|
||
if (!el || val == null || typeof val !== 'number') { set('sTempF', val, digits); return; }
|
||
const tn = el.firstChild && el.firstChild.nodeType === 3 ? el.firstChild : null;
|
||
const from = tn ? (parseFloat(tn.textContent) || val) : val;
|
||
const diff = val - from;
|
||
if (Math.abs(diff) < 0.05) { if (tn) tn.textContent = val.toFixed(digits); return; }
|
||
const dur = 700, t0 = performance.now();
|
||
(function step(now) {
|
||
const p = Math.min((now - t0) / dur, 1);
|
||
const ease = 1 - Math.pow(1 - p, 3);
|
||
const text = (from + diff * ease).toFixed(digits);
|
||
if (tn) tn.textContent = text; else el.textContent = text;
|
||
if (p < 1) requestAnimationFrame(step);
|
||
})(t0);
|
||
};
|
||
|
||
animateHeroTemp(d.tempf);
|
||
set('sFeels', d.feelsLike ?? d.tempf);
|
||
set('sHum', d.humidity, 0);
|
||
set('sDew', d.dewPoint);
|
||
set('sWind', d.windspeedmph);
|
||
set('sGust', d.windgustmph);
|
||
set('sPres', d.baromrelin, 2);
|
||
set('sRain', d.dailyrainin, 2);
|
||
set('sRainRate', d.rainratein, 2);
|
||
set('sUV', d.uv, 0);
|
||
set('sSolar', d.solarradiation, 0);
|
||
set('sBatt', d.battout != null ? (d.battout === 1 ? 'OK' : 'Low') : '—', 0);
|
||
|
||
if (d.maxdailygust != null) set('sMaxGust', d.maxdailygust);
|
||
|
||
// Indoor sensor (Ambient stations with indoor probe)
|
||
if (d.tempinf != null) {
|
||
set('sTempIn', d.tempinf);
|
||
set('sHumIn', d.humidityin, 0);
|
||
const sec = document.getElementById('indoorSection');
|
||
if (sec) sec.classList.remove('hidden');
|
||
}
|
||
|
||
// Ambient rain accumulators
|
||
if (d.eventrainin != null && d.eventrainin > 0) {
|
||
set('sEventRain', d.eventrainin, 2);
|
||
const evTile = document.getElementById('sEventRainTile');
|
||
if (evTile) evTile.style.display = '';
|
||
}
|
||
if (d.totalrainin != null) {
|
||
set('sTotalRain', d.totalrainin, 2);
|
||
const totTile = document.getElementById('sTotalRainTile');
|
||
if (totTile) totTile.style.display = '';
|
||
}
|
||
|
||
// Hero wind line
|
||
const heroWind = document.getElementById('heroWindLine');
|
||
if (heroWind && d.windspeedmph != null) {
|
||
const dir = d.winddir != null ? cardinal(d.winddir) : '';
|
||
heroWind.textContent = d.windspeedmph > 0
|
||
? `${d.windspeedmph.toFixed(1)} mph ${dir} wind`
|
||
: 'Calm winds';
|
||
}
|
||
// Wind direction on secondary tile
|
||
const windDirEl = document.getElementById('sWindDir');
|
||
if (windDirEl) windDirEl.textContent = d.winddir != null ? cardinal(d.winddir) : '—';
|
||
|
||
colorTiles(d);
|
||
|
||
// Remove skeleton shimmer now that data has arrived
|
||
document.querySelectorAll('.stat.loading').forEach(s => s.classList.remove('loading'));
|
||
|
||
// Flash stat values on update
|
||
requestAnimationFrame(() => {
|
||
document.querySelectorAll('.stat-val, .hero-temp, .hero-stat-val').forEach(el => {
|
||
el.classList.remove('val-flash');
|
||
requestAnimationFrame(() => el.classList.add('val-flash'));
|
||
});
|
||
});
|
||
|
||
const name = document.getElementById('stationName');
|
||
if (name && d.PASSKEY) name.textContent = '';
|
||
|
||
const meta = document.getElementById('headerMeta');
|
||
if (meta && d.dateutc) {
|
||
meta.innerHTML = '<span class="live-dot"></span>Updated ' + new Date(d.dateutc).toLocaleTimeString();
|
||
}
|
||
updateOfflineBanner(d.dateutc || null);
|
||
} catch {}
|
||
}
|
||
|
||
// ── Load history ─────────────────────────────────────────────────────────────
|
||
async function loadHistory(range) {
|
||
const area = document.getElementById('chartsArea');
|
||
area.innerHTML = '<div class="status" id="loadingMsg">Loading…</div>';
|
||
try {
|
||
const r = await fetch(`/api/station/history?range=${range}`);
|
||
if (r.status === 401) { location.href = '/dashboard/login'; return; }
|
||
if (!r.ok) throw new Error('Failed');
|
||
const data = await r.json();
|
||
renderCharts(data, range, yoyEnabled ? yoyData : null);
|
||
const line = document.getElementById('updatedLine');
|
||
if (line) {
|
||
const next = new Date(Date.now() + 120000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||
line.textContent = `${data.length} readings · updated ${new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} · next refresh ${next}`;
|
||
}
|
||
} catch (e) {
|
||
area.innerHTML = `<div class="status error">Failed to load station history. ${e.message}</div>`;
|
||
}
|
||
}
|
||
|
||
// ── Init ──────────────────────────────────────────────────────────────────────
|
||
document.querySelectorAll('.range-btn[data-range]').forEach(btn => {
|
||
btn.addEventListener('click', () => {
|
||
document.querySelectorAll('.range-btn[data-range]').forEach(b => b.classList.remove('active'));
|
||
btn.classList.add('active');
|
||
currentRange = btn.dataset.range;
|
||
yoyData = null; yoyEnabled = false; yoyBtn.classList.remove('active');
|
||
updateYoyVisibility(currentRange);
|
||
loadHistory(currentRange);
|
||
});
|
||
});
|
||
|
||
document.getElementById('refreshBtn').addEventListener('click', () => {
|
||
updateLatest();
|
||
loadHistory(currentRange);
|
||
});
|
||
|
||
// Auto-refresh latest + charts every 2 minutes
|
||
setInterval(updateLatest, 120000);
|
||
setInterval(() => loadHistory(currentRange), 120000);
|
||
|
||
// ── Pressure trend ────────────────────────────────────────────────────────────
|
||
async function updatePressureTrend() {
|
||
try {
|
||
const r = await fetch('/api/station/history?range=6h');
|
||
if (!r.ok) return;
|
||
const data = await r.json();
|
||
if (data.length < 2) return;
|
||
const recent = data[data.length - 1]?.baromrelin;
|
||
const older = data[Math.max(0, data.length - 19)]?.baromrelin; // ~3h ago (at 1/min, 18 readings = ~18min... adjust for actual density)
|
||
const threeHoursAgo = Date.now() - 3 * 3600000;
|
||
const baseline = data.find(r => r.dateutc >= threeHoursAgo)?.baromrelin;
|
||
if (recent == null || baseline == null) return;
|
||
const diff = recent - baseline;
|
||
const el = document.getElementById('sTrend');
|
||
if (!el) return;
|
||
if (diff > 0.02) { el.textContent = '↑'; el.title = `Rising +${diff.toFixed(3)} inHg in 3h`; el.style.color = 'var(--cool)'; }
|
||
else if (diff < -0.02) { el.textContent = '↓'; el.title = `Falling ${diff.toFixed(3)} inHg in 3h`; el.style.color = 'var(--accent)'; }
|
||
else { el.textContent = '→'; el.title = `Steady (${diff > 0 ? '+' : ''}${diff.toFixed(3)} inHg in 3h)`; el.style.color = 'var(--ink-light)'; }
|
||
} catch {}
|
||
}
|
||
|
||
// ── Rain totals + degree days ─────────────────────────────────────────────────
|
||
async function updateExtStats() {
|
||
try {
|
||
const [rt, dd, an] = await Promise.all([
|
||
fetch('/api/station/rain-totals').then(r => r.ok ? r.json() : null),
|
||
fetch('/api/station/degree-days?rangeMs=' + (30 * 86400000)).then(r => r.ok ? r.json() : null),
|
||
fetch('/api/station/anomaly').then(r => r.ok ? r.json() : null),
|
||
]);
|
||
if (rt) {
|
||
const s = (id, v) => { const e = document.getElementById(id); if (e) e.textContent = v != null ? v : '—'; };
|
||
s('sRain7d', rt.rain7d);
|
||
s('sRain30d', rt.rain30d);
|
||
s('sRainYTD', rt.rainYTD);
|
||
}
|
||
if (dd) {
|
||
document.getElementById('sHDD').textContent = dd.hdd;
|
||
document.getElementById('sCDD').textContent = dd.cdd;
|
||
}
|
||
if (an?.available) {
|
||
const tile = document.getElementById('sAnomalyTile');
|
||
tile.style.display = '';
|
||
const diff = an.currentTemp != null && an.mean != null ? (an.currentTemp - an.mean) : null;
|
||
document.getElementById('sAnomaly').textContent = an.anomaly === 'above' ? 'Above Normal' : an.anomaly === 'below' ? 'Below Normal' : 'Near Normal';
|
||
document.getElementById('sAnomalyNote').textContent = diff != null ? `${diff > 0 ? '+' : ''}${diff.toFixed(1)}°F vs avg` : '';
|
||
tile.className = 'stat' + (an.anomaly === 'above' ? ' anomaly-above' : an.anomaly === 'below' ? ' anomaly-below' : '');
|
||
}
|
||
} catch {}
|
||
}
|
||
|
||
// ── Records section ───────────────────────────────────────────────────────────
|
||
function fmtDate(ts) {
|
||
if (!ts) return '—';
|
||
return new Date(ts).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
||
}
|
||
|
||
function renderRecordPeriods(d, ri) {
|
||
const dayDate = (day) => day ? Date.parse(day + 'T12:00:00Z') : null;
|
||
const n = d.notable || {};
|
||
const nv = (x, f) => x ? f(x.value) : '—';
|
||
let html = '<div class="rec-h">Notable days</div><div class="records-grid">' + [
|
||
['Hottest Day', nv(n.hottestDay, v => v.toFixed(1) + '°F'), n.hottestDay],
|
||
['Coldest Night', nv(n.coldestDay, v => v.toFixed(1) + '°F'), n.coldestDay],
|
||
['Coolest Daytime High', nv(n.coolestHigh, v => v.toFixed(1) + '°F'), n.coolestHigh],
|
||
['Warmest Night', nv(n.warmestLow, v => v.toFixed(1) + '°F'), n.warmestLow],
|
||
['Windiest Day', nv(n.windiestDay, v => v.toFixed(1) + ' mph'), n.windiestDay],
|
||
['Wettest Day', nv(n.wettestDay, v => v.toFixed(2) + '"'), n.wettestDay],
|
||
].map(([l, v, o]) => ri(l, v, o ? dayDate(o.day) : null)).join('') + '</div>';
|
||
if (d.wettestMonth || d.wettestYear) {
|
||
html += '<div class="rec-h">Wettest periods</div><div class="records-grid">'
|
||
+ (d.wettestMonth ? ri('Wettest Month', d.wettestMonth.rain.toFixed(2) + '"', dayDate(d.wettestMonth.month + '-15')) : '')
|
||
+ (d.wettestYear ? ri('Wettest Year', d.wettestYear.rain.toFixed(2) + '"', dayDate(d.wettestYear.year + '-07-01')) : '')
|
||
+ '</div>';
|
||
}
|
||
const mname = (m) => new Date(m + '-15T12:00:00Z').toLocaleDateString('en-US', { month: 'short', year: 'numeric', timeZone: 'UTC' });
|
||
const row = (label, a) => `<tr><td>${label}</td><td>${a.hi != null ? a.hi.toFixed(1) : '—'}</td><td>${a.lo != null ? a.lo.toFixed(1) : '—'}</td><td>${a.gust != null ? a.gust.toFixed(0) : '—'}</td><td>${a.rain.toFixed(2)}</td><td>${a.days}</td></tr>`;
|
||
const head = '<tr><th></th><th>High °F</th><th>Low °F</th><th>Gust</th><th>Rain "</th><th>Days</th></tr>';
|
||
if (d.years && d.years.length) {
|
||
html += `<div class="rec-h">By year</div><div class="rec-scroll"><table class="rec-table">${head}${[...d.years].reverse().map(y => row(y.year, y)).join('')}</table></div>`;
|
||
}
|
||
if (d.months && d.months.length) {
|
||
html += `<div class="rec-h">By month</div><div class="rec-scroll"><table class="rec-table">${head}${[...d.months].reverse().map(m => row(mname(m.month), m)).join('')}</table></div>`;
|
||
}
|
||
return html;
|
||
}
|
||
|
||
async function loadRecords() {
|
||
const panel = document.getElementById('recordsPanel');
|
||
if (!panel) return;
|
||
panel.addEventListener('toggle', async () => {
|
||
if (!panel.open) return;
|
||
const body = document.getElementById('recordsBody');
|
||
if (body.querySelector('.records-grid')) return; // already loaded
|
||
try {
|
||
const r = await fetch('/api/station/records');
|
||
if (!r.ok) { body.innerHTML = '<div class="status">No records yet.</div>'; return; }
|
||
const d = await r.json();
|
||
if (!d) { body.innerHTML = '<div class="status">No data recorded yet.</div>'; return; }
|
||
const since = d.firstReading ? `${d.totalReadings.toLocaleString()} readings over ${d.daysRecorded} days since ${fmtDate(d.firstReading)}` : '';
|
||
const cutoff = Date.now() - 30 * 24 * 3600 * 1000;
|
||
const ri = (label, val, date) => {
|
||
const recent = date && new Date(date).getTime() > cutoff;
|
||
return `<div class="record-item${recent ? ' record-best' : ''}"><div class="record-label">${label}</div><div class="record-val">${val}</div><div class="record-date">${fmtDate(date)}</div></div>`;
|
||
};
|
||
body.innerHTML = `
|
||
<p style="font-size:0.8rem;color:var(--ink-light);margin-bottom:0.75rem">${since}</p>
|
||
<div class="records-grid">
|
||
${ri('Hottest', d.highTemp != null ? d.highTemp.toFixed(1) + '°F' : '—', d.highTempDate)}
|
||
${ri('Coldest', d.lowTemp != null ? d.lowTemp.toFixed(1) + '°F' : '—', d.lowTempDate)}
|
||
${ri('Highest Gust', d.highGust != null ? d.highGust.toFixed(1) + ' mph' : '—', d.highGustDate)}
|
||
${ri('Highest Rain Rate', d.highRainRate != null ? d.highRainRate.toFixed(2) + ' in/hr' : '—', d.highRainRateDate)}
|
||
${ri('Wettest Day', d.highDailyRain != null ? d.highDailyRain.toFixed(2) + '"' : '—', d.highDailyRainDate)}
|
||
${ri('Highest UV', d.highUV != null ? d.highUV.toFixed(0) : '—', d.highUVDate)}
|
||
${ri('Peak Solar', d.highSolar != null ? d.highSolar.toFixed(0) + ' W/m²' : '—', d.highSolarDate)}
|
||
${ri('Highest Pressure', d.highPressure != null ? d.highPressure.toFixed(2) + ' inHg' : '—', d.highPressureDate)}
|
||
${ri('Lowest Pressure', d.lowPressure != null ? d.lowPressure.toFixed(2) + ' inHg' : '—', d.lowPressureDate)}
|
||
${ri('Highest Humidity', d.highHumidity != null ? d.highHumidity + '%' : '—', d.highHumidityDate)}
|
||
${ri('Lowest Humidity', d.lowHumidity != null ? d.lowHumidity + '%' : '—', d.lowHumidityDate)}
|
||
</div>
|
||
${renderRecordPeriods(d, ri)}
|
||
<div class="rec-actions">
|
||
<a href="/api/station/backup.jsonl" download>⬇ Download all readings</a>
|
||
<a href="/api/station/records-backup.json" download>⬇ Download records</a>
|
||
</div>`;
|
||
} catch { document.getElementById('recordsBody').innerHTML = '<div class="status error">Failed to load records.</div>'; }
|
||
}, { once: false });
|
||
}
|
||
|
||
// ── Year-over-year ────────────────────────────────────────────────────────────
|
||
let yoyEnabled = false;
|
||
let yoyData = null;
|
||
|
||
const yoyBtn = document.getElementById('yoyBtn');
|
||
yoyBtn.addEventListener('click', async () => {
|
||
yoyEnabled = !yoyEnabled;
|
||
yoyBtn.classList.toggle('active', yoyEnabled);
|
||
if (yoyEnabled && !yoyData) {
|
||
yoyBtn.textContent = '…';
|
||
try {
|
||
const r = await fetch(`/api/station/yoy?range=${currentRange}`);
|
||
yoyData = r.ok ? await r.json() : null;
|
||
} catch {}
|
||
yoyBtn.textContent = 'YoY';
|
||
}
|
||
loadHistory(currentRange);
|
||
});
|
||
|
||
// Show YoY button only for long ranges
|
||
function updateYoyVisibility(range) {
|
||
const longRange = ['1y','2y','3y'].includes(range);
|
||
yoyBtn.style.display = longRange ? '' : 'none';
|
||
if (!longRange) { yoyEnabled = false; yoyData = null; yoyBtn.classList.remove('active'); }
|
||
}
|
||
|
||
// Patch loadHistory to pass yoyData to renderCharts
|
||
const _origLoadHistory = loadHistory;
|
||
// Override renderCharts to accept yoy
|
||
const _origRenderCharts = renderCharts;
|
||
|
||
// ── Trend arrows ─────────────────────────────────────────────────────────────
|
||
async function updateTrends() {
|
||
try {
|
||
const r = await fetch('/api/station/trend');
|
||
if (!r.ok) return;
|
||
const t = await r.json();
|
||
const arrows = { up: '▲', down: '▼', flat: '–' };
|
||
const cls = { up: 'trend-up', down: 'trend-down', flat: 'trend-flat' };
|
||
const map = { tempf: 'trTempF', humidity: 'trHum', dewPoint: 'trDew', windspeedmph: 'trWind', baromrelin: 'trPres', uv: 'trUV', solarradiation: 'trSolar' };
|
||
for (const [key, elId] of Object.entries(map)) {
|
||
const el = document.getElementById(elId);
|
||
if (!el || !t[key]) continue;
|
||
el.textContent = arrows[t[key]] || '';
|
||
el.className = 'stat-trend ' + (cls[t[key]] || '');
|
||
}
|
||
} catch {}
|
||
}
|
||
|
||
// ── Offline banner ────────────────────────────────────────────────────────────
|
||
function updateOfflineBanner(lastDateutc) {
|
||
const banner = document.getElementById('offlineBanner');
|
||
if (!banner) return;
|
||
if (!lastDateutc) { banner.style.display = 'none'; return; }
|
||
const mins = Math.round((Date.now() - lastDateutc) / 60000);
|
||
if (mins > 15) {
|
||
banner.textContent = `⚠ Station last reported ${mins} minutes ago — may be offline`;
|
||
banner.style.display = 'block';
|
||
} else {
|
||
banner.style.display = 'none';
|
||
}
|
||
}
|
||
|
||
// ── Monthly summary ───────────────────────────────────────────────────────────
|
||
const monthlyPanel = document.getElementById('monthlyPanel');
|
||
if (monthlyPanel) {
|
||
monthlyPanel.addEventListener('toggle', async () => {
|
||
if (!monthlyPanel.open) return;
|
||
const body = document.getElementById('monthlyBody');
|
||
if (body.querySelector('table')) return;
|
||
try {
|
||
const r = await fetch('/api/station/monthly-summary');
|
||
if (!r.ok) throw new Error();
|
||
const data = await r.json();
|
||
if (!data.length) { body.innerHTML = '<div class="status">No monthly data yet.</div>'; return; }
|
||
const rows = data.slice().reverse().map(m => {
|
||
const [yr, mo] = m.month.split('-');
|
||
const label = new Date(+yr, +mo - 1, 1).toLocaleDateString('en-US', { month: 'long', year: 'numeric' });
|
||
return `<tr>
|
||
<td>${label}</td>
|
||
<td>${m.avgTemp != null ? m.avgTemp.toFixed(1) + '°F' : '—'}</td>
|
||
<td>${m.highTemp != null ? m.highTemp.toFixed(1) + '°F' : '—'}</td>
|
||
<td>${m.highGust != null ? m.highGust + ' mph' : '—'}</td>
|
||
<td>${m.totalRain != null ? m.totalRain.toFixed(2) + '"' : '—'}</td>
|
||
<td style="color:var(--ink-light)">${m.readings.toLocaleString()}</td>
|
||
</tr>`;
|
||
}).join('');
|
||
body.innerHTML = `<div class="monthly-table-wrap"><table class="monthly-table">
|
||
<thead><tr><th>Month</th><th>Avg Temp</th><th>High Temp</th><th>Max Gust</th><th>Total Rain</th><th>Readings</th></tr></thead>
|
||
<tbody>${rows}</tbody></table></div>`;
|
||
} catch { document.getElementById('monthlyBody').innerHTML = '<div class="status error">Failed to load.</div>'; }
|
||
}, { once: false });
|
||
}
|
||
|
||
// ── Temperature heatmap ───────────────────────────────────────────────────────
|
||
const heatmapPanel = document.getElementById('heatmapPanel');
|
||
if (heatmapPanel) {
|
||
heatmapPanel.addEventListener('toggle', async () => {
|
||
if (!heatmapPanel.open) return;
|
||
const body = document.getElementById('heatmapBody');
|
||
if (body.querySelector('.heatmap-wrap')) return;
|
||
try {
|
||
const r = await fetch('/api/station/history?range=1y');
|
||
if (!r.ok) throw new Error();
|
||
const readings = await r.json();
|
||
if (!readings.length) { body.innerHTML = '<div class="status">No data yet.</div>'; return; }
|
||
// Daily high temp
|
||
const byDay = {};
|
||
for (const rd of readings) {
|
||
if (rd.tempf == null || !rd.dateutc) continue;
|
||
const day = new Date(rd.dateutc).toISOString().slice(0, 10);
|
||
if (byDay[day] == null || rd.tempf > byDay[day]) byDay[day] = rd.tempf;
|
||
}
|
||
// Color scale: cold = cool blue, hot = accent orange
|
||
function heatColor(t) {
|
||
if (t == null) return 'var(--border)';
|
||
const norm = Math.max(0, Math.min(1, (t - 20) / 90));
|
||
const r = Math.round(42 + norm * (224 - 42));
|
||
const g = Math.round(107 + norm * (120 - 107));
|
||
const b = Math.round(138 + norm * (42 - 138));
|
||
return `rgb(${r},${g},${b})`;
|
||
}
|
||
// Build grid: iterate days from 1yr ago to today
|
||
const today = new Date(); today.setHours(23, 59, 59, 0);
|
||
const start = new Date(today); start.setFullYear(start.getFullYear() - 1); start.setDate(start.getDate() + 1);
|
||
// Align to Sunday
|
||
const startDow = start.getDay(); // 0=Sun
|
||
const cells = [];
|
||
for (let i = 0; i < startDow; i++) cells.push(null); // padding
|
||
const cur = new Date(start);
|
||
while (cur <= today) {
|
||
const key = cur.toISOString().slice(0, 10);
|
||
cells.push({ date: key, temp: byDay[key] ?? null });
|
||
cur.setDate(cur.getDate() + 1);
|
||
}
|
||
const cellHtml = cells.map(c => {
|
||
if (!c) return `<div class="heatmap-cell" style="background:transparent"></div>`;
|
||
const t = c.temp;
|
||
const label = t != null ? `${c.date}: ${t.toFixed(1)}°F` : c.date;
|
||
return `<div class="heatmap-cell" style="background:${heatColor(t)}" title="${label}"></div>`;
|
||
}).join('');
|
||
body.innerHTML = `
|
||
<div class="heatmap-wrap"><div class="heatmap">${cellHtml}</div></div>
|
||
<div class="heatmap-legend">
|
||
<span>Cooler</span>
|
||
${[20,35,50,65,80,95,110].map(t => `<div class="heatmap-legend-swatch" style="background:${heatColor(t)}"></div>`).join('')}
|
||
<span>Hotter</span>
|
||
</div>`;
|
||
} catch { document.getElementById('heatmapBody').innerHTML = '<div class="status error">Failed to load.</div>'; }
|
||
}, { once: false });
|
||
}
|
||
|
||
// ── Tile color coding ─────────────────────────────────────────────────────────
|
||
function tempColorClass(t) {
|
||
if (t == null) return '';
|
||
if (t >= 95) return 'stat-hot';
|
||
if (t >= 80) return 'stat-warm';
|
||
if (t <= 32) return 'stat-cold';
|
||
if (t <= 50) return 'stat-cool';
|
||
return '';
|
||
}
|
||
function uvColorClass(uv) {
|
||
if (uv == null || uv < 3) return '';
|
||
if (uv < 6) return 'stat-uv-mod';
|
||
if (uv < 8) return 'stat-uv-high';
|
||
return 'stat-uv-xhigh';
|
||
}
|
||
function applyClass(el, cls) {
|
||
if (!el) return;
|
||
el.classList.remove('stat-hot','stat-warm','stat-cool','stat-cold','stat-raining','stat-uv-mod','stat-uv-high','stat-uv-xhigh');
|
||
if (cls) el.classList.add(cls);
|
||
}
|
||
function colorTiles(d) {
|
||
// Hero temp color
|
||
const heroTemp = document.getElementById('sTempF');
|
||
if (heroTemp && d.tempf != null) {
|
||
const cls = tempColorClass(d.tempf);
|
||
const colorMap = { 'stat-hot': 'var(--accent)', 'stat-warm': '#c8a000', 'stat-cold': '#4a7cc7', 'stat-cool': 'var(--cool)', '': 'var(--ink)' };
|
||
heroTemp.style.color = colorMap[cls] || 'var(--ink)';
|
||
}
|
||
// UV tile
|
||
const uvTile = document.querySelector('[id="sUV"]')?.closest('.hero-stat-val')?.parentElement;
|
||
// (using the hero-grid cell)
|
||
const uvParent = document.getElementById('sUV')?.closest('div[class=""]');
|
||
// Rain rate
|
||
const rrTile = document.getElementById('statRainRateTile');
|
||
if (rrTile) applyClass(rrTile, d.rainratein > 0 ? 'stat-raining' : '');
|
||
const rTile = document.getElementById('statRainTile');
|
||
if (rTile) applyClass(rTile, d.rainratein > 0 ? 'stat-raining' : '');
|
||
// Wind tile
|
||
const wTile = document.getElementById('statWindTile');
|
||
if (wTile) applyClass(wTile, d.windspeedmph >= 30 ? 'stat-hot' : d.windspeedmph >= 20 ? 'stat-warm' : '');
|
||
}
|
||
|
||
// ── Sparklines ────────────────────────────────────────────────────────────────
|
||
function drawSparkline(canvasId, values, color) {
|
||
const canvas = document.getElementById(canvasId);
|
||
if (!canvas) return;
|
||
const dpr = window.devicePixelRatio || 1;
|
||
const W = canvas.offsetWidth || 100, H = 22;
|
||
canvas.width = W * dpr; canvas.height = H * dpr;
|
||
canvas.style.width = W + 'px'; canvas.style.height = H + 'px';
|
||
const ctx = canvas.getContext('2d');
|
||
ctx.scale(dpr, dpr);
|
||
const valid = values.filter(v => v != null);
|
||
if (valid.length < 2) return;
|
||
const min = Math.min(...valid), max = Math.max(...valid);
|
||
const range = max - min || 1;
|
||
const pad = 2;
|
||
ctx.clearRect(0, 0, W, H);
|
||
// gradient fill
|
||
const grad = ctx.createLinearGradient(0, 0, 0, H);
|
||
grad.addColorStop(0, color + '55'); grad.addColorStop(1, color + '00');
|
||
ctx.beginPath();
|
||
const step = (W - pad * 2) / (values.length - 1);
|
||
let startX = pad, startY;
|
||
let drawing = false;
|
||
const pts = [];
|
||
values.forEach((v, i) => {
|
||
if (v == null) return;
|
||
const x = pad + i * step;
|
||
const y = H - pad - ((v - min) / range) * (H - pad * 3);
|
||
pts.push([x, y]);
|
||
});
|
||
if (!pts.length) return;
|
||
ctx.beginPath();
|
||
ctx.moveTo(pts[0][0], pts[0][1]);
|
||
for (let i = 1; i < pts.length; i++) ctx.lineTo(pts[i][0], pts[i][1]);
|
||
ctx.strokeStyle = color; ctx.lineWidth = 1.5; ctx.lineJoin = 'round';
|
||
ctx.stroke();
|
||
// fill
|
||
ctx.lineTo(pts[pts.length-1][0], H); ctx.lineTo(pts[0][0], H); ctx.closePath();
|
||
ctx.fillStyle = grad; ctx.fill();
|
||
}
|
||
|
||
function updateSparklines(readings) {
|
||
const step = readings.length > 120 ? Math.ceil(readings.length / 120) : 1;
|
||
const pts = step > 1 ? readings.filter((_, i) => i % step === 0) : readings;
|
||
const c = chartColors();
|
||
drawSparkline('spHum', pts.map(r => r.humidity), c.cool);
|
||
drawSparkline('spPres', pts.map(r => r.baromrelin), c.accent);
|
||
drawSparkline('spSolar', pts.map(r => r.solarradiation), c.accent);
|
||
drawSparkline('spWind', pts.map(r => r.windspeedmph), c.cool);
|
||
}
|
||
|
||
// ── Today at a glance ─────────────────────────────────────────────────────────
|
||
function updateTodayBar(readings) {
|
||
const midnightToday = new Date(); midnightToday.setHours(0,0,0,0);
|
||
const today = readings.filter(r => r.dateutc >= midnightToday.getTime());
|
||
if (!today.length) return;
|
||
const temps = today.map(r => r.tempf).filter(v => v != null);
|
||
const gusts = today.map(r => r.windgustmph).filter(v => v != null);
|
||
const maxRain = Math.max(...today.map(r => r.dailyrainin || 0));
|
||
if (temps.length) {
|
||
document.getElementById('todayHigh').textContent = Math.max(...temps).toFixed(1);
|
||
document.getElementById('todayLow').textContent = Math.min(...temps).toFixed(1);
|
||
}
|
||
document.getElementById('todayRain').textContent = maxRain.toFixed(2);
|
||
if (gusts.length) document.getElementById('todayGust').textContent = Math.max(...gusts).toFixed(1);
|
||
document.getElementById('todayBar').style.display = '';
|
||
}
|
||
|
||
// ── NWS Alerts ────────────────────────────────────────────────────────────────
|
||
const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||
const SEV_CLASS = { Extreme: 'sev-extreme', Severe: 'sev-severe', Moderate: 'sev-moderate', Minor: 'sev-minor' };
|
||
async function loadAlerts() {
|
||
const section = document.getElementById('alertsSection');
|
||
if (!section) return;
|
||
try {
|
||
const r = await fetch('/api/dashboard/alerts');
|
||
if (!r.ok) return;
|
||
const data = await r.json();
|
||
if (data.noLocation) {
|
||
section.style.marginBottom = '1rem';
|
||
section.innerHTML = '<div class="alert-card sev-unknown" style="font-size:0.85rem">⚠ No location set — <a href="/dashboard/settings" style="color:var(--cool)">add your coordinates in Settings</a> to see NWS alerts.</div>';
|
||
return;
|
||
}
|
||
const features = data.features || [];
|
||
if (!features.length) { section.innerHTML = ''; section.style.marginBottom = '0'; return; }
|
||
section.style.marginBottom = '1rem';
|
||
section.innerHTML = features.map(f => {
|
||
const p = f.properties || {};
|
||
const sev = p.severity || 'unknown';
|
||
const cls = SEV_CLASS[sev] || 'sev-unknown';
|
||
const ends = p.ends ? new Date(p.ends).toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }) : '';
|
||
const desc = (p.description || '').trim();
|
||
const firstSentence = (p.headline && !desc) ? p.headline : (desc.replace(/\s+/g, ' ').match(/^.*?[.!?](\s|$)/) || [desc.replace(/\s+/g, ' ')])[0].trim();
|
||
const short = firstSentence.length > 140 ? firstSentence.slice(0, 137) + '…' : firstSentence;
|
||
const instr = (p.instruction || '').trim();
|
||
return `<div class="alert-card ${cls}"><details>
|
||
<summary>
|
||
<div class="alert-title">${esc(p.event || 'Alert')}</div>
|
||
<div class="alert-meta">${esc(p.areaDesc || '')}${ends ? ' · until ' + ends : ''}</div>
|
||
${short ? `<div class="alert-summary">${esc(short)}</div>` : ''}
|
||
<div class="alert-hint">Click for full details ▾</div>
|
||
</summary>
|
||
<div class="alert-full">${esc(desc || p.headline || 'No further details.')}${instr ? `<h4>What to do</h4>${esc(instr)}` : ''}</div>
|
||
</details></div>`;
|
||
}).join('');
|
||
} catch {}
|
||
}
|
||
|
||
// ── Streak ─────────────────────────────────────────────────────────────────────
|
||
async function loadStreak() {
|
||
try {
|
||
const r = await fetch('/api/station/streak');
|
||
if (!r.ok) return;
|
||
const d = await r.json();
|
||
if (!d.type || !d.count) return;
|
||
const tile = document.getElementById('sStreakTile');
|
||
tile.style.display = '';
|
||
document.getElementById('sStreak').textContent = d.count;
|
||
document.getElementById('sStreakNote').textContent = d.count === 1 ? `${d.type} day` : `consecutive ${d.type} days`;
|
||
tile.style.borderColor = d.type === 'wet' ? 'var(--cool)' : 'var(--border)';
|
||
} catch {}
|
||
}
|
||
|
||
// ── Alert History ─────────────────────────────────────────────────────────────
|
||
const TAG_LABELS = {
|
||
'nws': 'NWS Alert',
|
||
'wind-threshold': 'Wind Gust',
|
||
'temp-threshold': 'Low Temp',
|
||
'aqi-threshold': 'Air Quality',
|
||
'rain-threshold': 'Rain Rate',
|
||
'temp-high': 'High Temp',
|
||
'temp-low': 'Station Low',
|
||
'freeze': 'Freeze',
|
||
'wind-station': 'Station Wind',
|
||
'station-offline': 'Station Offline',
|
||
};
|
||
|
||
let alertHistoryDays = 30;
|
||
|
||
async function loadAlertHistory(days) {
|
||
alertHistoryDays = days;
|
||
const body = document.getElementById('alertHistoryBody');
|
||
body.innerHTML = '<div class="status">Loading…</div>';
|
||
try {
|
||
const r = await fetch(`/api/dashboard/alert-history?days=${days}`);
|
||
if (!r.ok) throw new Error();
|
||
const items = await r.json();
|
||
const dayBtns = body.querySelectorAll?.('.range-btn') || [];
|
||
body.innerHTML = `
|
||
<div class="alert-history-bar">
|
||
<span style="font-size:0.82rem;color:var(--ink-light)">Range:</span>
|
||
${[7,30,90].map(d => `<button class="range-btn${d === days ? ' active' : ''}" data-ahdays="${d}">${d}d</button>`).join('')}
|
||
<span style="margin-left:auto;font-size:0.78rem;color:var(--ink-light)">${items.length} event${items.length !== 1 ? 's' : ''}</span>
|
||
</div>
|
||
${items.length === 0
|
||
? `<div class="ah-empty">No alerts logged in the last ${days} days.</div>`
|
||
: `<div class="alert-history-table-wrap"><table class="alert-history-table">
|
||
<thead><tr><th>Time</th><th>Type</th><th>Event</th><th>Details</th></tr></thead>
|
||
<tbody>${items.map(it => {
|
||
const isNws = it.type === 'nws';
|
||
const badge = `<span class="ah-badge ${isNws ? 'nws' : 'threshold'}">${isNws ? 'NWS' : 'Threshold'}</span>`;
|
||
const label = isNws ? (it.event || 'Alert') : (TAG_LABELS[it.tag] || it.tag || '—');
|
||
const detail = isNws ? (it.headline || '') : (it.body || '');
|
||
const full = isNws ? [it.headline, it.description, it.instruction && ('What to do: ' + it.instruction)].filter(Boolean).join('\n\n') : detail;
|
||
const time = new Date(it.ts).toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
|
||
return `<tr class="ah-row" data-full="${esc(full)}" title="Click to expand">
|
||
<td style="white-space:nowrap;color:var(--ink-light);font-size:0.78rem">${time}</td>
|
||
<td>${badge}</td>
|
||
<td style="font-weight:600">${label}</td>
|
||
<td><div class="ah-body">${esc(detail)}</div></td>
|
||
</tr>`;
|
||
}).join('')}</tbody>
|
||
</table></div>`
|
||
}`;
|
||
body.querySelectorAll('.ah-row').forEach(row => {
|
||
row.addEventListener('click', () => {
|
||
const el = row.querySelector('.ah-body');
|
||
const open = row.classList.toggle('open');
|
||
el.textContent = open ? row.dataset.full : el.dataset.short;
|
||
});
|
||
const el = row.querySelector('.ah-body');
|
||
el.dataset.short = el.textContent;
|
||
});
|
||
body.querySelectorAll('[data-ahdays]').forEach(btn => {
|
||
btn.addEventListener('click', () => loadAlertHistory(parseInt(btn.dataset.ahdays)));
|
||
});
|
||
} catch {
|
||
document.getElementById('alertHistoryBody').innerHTML = '<div class="status error">Failed to load alert history.</div>';
|
||
}
|
||
}
|
||
|
||
const alertHistoryPanel = document.getElementById('alertHistoryPanel');
|
||
if (alertHistoryPanel) {
|
||
alertHistoryPanel.addEventListener('toggle', () => {
|
||
if (!alertHistoryPanel.open) return;
|
||
const body = document.getElementById('alertHistoryBody');
|
||
if (body.querySelector('table, .ah-empty')) return;
|
||
loadAlertHistory(alertHistoryDays);
|
||
}, { once: false });
|
||
}
|
||
|
||
// ── Scroll handler: add blur shadow to sticky header ─────────────────────────
|
||
window.addEventListener('scroll', () => {
|
||
document.querySelector('.header').classList.toggle('scrolled', window.scrollY > 5);
|
||
}, { passive: true });
|
||
|
||
// ── Skeleton: shimmer stat tiles until first data arrives ─────────────────────
|
||
document.querySelectorAll('.stat').forEach(s => s.classList.add('loading'));
|
||
|
||
// ── Init all new features ─────────────────────────────────────────────────────
|
||
updateLatest();
|
||
updatePressureTrend();
|
||
updateExtStats();
|
||
updateTrends();
|
||
loadRecords();
|
||
loadHistory(currentRange);
|
||
loadAlerts();
|
||
loadStreak();
|
||
setInterval(updatePressureTrend, 120000);
|
||
setInterval(updateExtStats, 300000);
|
||
setInterval(updateTrends, 120000);
|
||
setInterval(loadAlerts, 5 * 60 * 1000);
|
||
</script>
|
||
</body>
|
||
</html>
|