Redesign dashboard: tabs, insights, rain, sun/moon, zoomable charts
- Tab bar (bottom nav on phones) splitting Now / Charts / Rain / Records / Insights - Unified tiles with vs-yesterday deltas, hero 24h trace, wind compass, today hi/lo - Highlights chips, sun & moon card, station health card - Charts: min/max envelope for long ranges (server-side bucketing), pinch/wheel zoom, pressure tendency, indoor vs outdoor, clear-sky solar overlay - Rain tab: daily + cumulative, monthly totals; calendar heatmaps (temp/rain/gust) - Insights: period trends, station normals, diurnal range, coverage, seasonal wind roses - Server: /api/station/daily, history ?points= bucketing, 2d range, daily avg + wind histogram aggregates; fix dry days never recording rain=0 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
a97d104d14
commit
1ad2dbb860
4 files changed
+853
-83
No files matched your search
@@ -0,0 +1,105 @@
|
|||||||
|
/* Dashboard UI layer: tabs, tiles, hero extras, cards, calendars */
|
||||||
|
:root { --header-h: 64px; --tabbar-h: 54px; --radius: 14px; }
|
||||||
|
|
||||||
|
/* ── Tabs ── */
|
||||||
|
.tabbar { position: sticky; top: var(--header-h); z-index: 9; display: flex; gap: 0.25rem; padding: 0.4rem calc(1rem + env(safe-area-inset-left)) 0.4rem calc(1rem + env(safe-area-inset-right));
|
||||||
|
background: color-mix(in srgb, var(--paper) 88%, transparent); backdrop-filter: blur(14px); -webkit-backdrop-filter: blur(14px); border-bottom: 1px solid var(--border); overflow-x: auto; scrollbar-width: none; }
|
||||||
|
.tabbar::-webkit-scrollbar { display: none; }
|
||||||
|
.tab { flex: 1 0 auto; display: inline-flex; align-items: center; justify-content: center; gap: 0.45rem; min-height: 40px; padding: 0 1rem; border: 1px solid transparent; border-radius: 999px;
|
||||||
|
background: transparent; color: var(--ink-light); font: inherit; font-size: 0.85rem; font-weight: 600; cursor: pointer; transition: background .18s, color .18s, border-color .18s; touch-action: manipulation; }
|
||||||
|
.tab:hover { color: var(--ink); background: var(--paper-dark); }
|
||||||
|
.tab.active { background: var(--accent); color: #fff; }
|
||||||
|
.tab-ico { font-size: 0.95rem; line-height: 1; }
|
||||||
|
.tab-pane { display: none; animation: pane-in .28s cubic-bezier(.2,0,0,1); }
|
||||||
|
.tab-pane.active { display: block; }
|
||||||
|
@keyframes pane-in { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
/* Thumb-reach bottom navigation, safe-area aware */
|
||||||
|
.tabbar { position: fixed; top: auto; bottom: 0; left: 0; right: 0; z-index: 20; border-top: 1px solid var(--border); border-bottom: none; justify-content: space-between;
|
||||||
|
padding: 0.35rem calc(0.5rem + env(safe-area-inset-right)) calc(0.35rem + env(safe-area-inset-bottom)) calc(0.5rem + env(safe-area-inset-left)); overflow: visible; background: color-mix(in srgb, var(--paper-dark) 92%, transparent); }
|
||||||
|
.tab { flex: 1 1 0; flex-direction: column; gap: 0.1rem; min-height: 50px; padding: 0 0.2rem; border-radius: 12px; font-size: 0.68rem; }
|
||||||
|
.tab-ico { font-size: 1.15rem; }
|
||||||
|
.page { padding-bottom: calc(5.5rem + env(safe-area-inset-bottom)) !important; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Unified tile language ── */
|
||||||
|
.section-card, .chart-card, .stat, .today-bar, .settings-panel, .ui-card { border-radius: var(--radius); }
|
||||||
|
.stat { position: relative; overflow: hidden; }
|
||||||
|
.delta { font-size: 0.72rem; margin-top: 0.3rem; color: var(--ink-light); font-variant-numeric: tabular-nums; white-space: nowrap; }
|
||||||
|
.delta.up { color: var(--accent); } .delta.down { color: var(--cool); } .delta.flat { color: var(--ink-light); }
|
||||||
|
.hero-stat .delta, .hero-grid > div .delta { margin-top: 0.15rem; }
|
||||||
|
.hero-grid > div { background: var(--paper); border: 1px solid var(--border); border-radius: 12px; padding: 0.65rem 0.8rem; }
|
||||||
|
|
||||||
|
/* ── Hero extras ── */
|
||||||
|
.hero-main { position: relative; isolation: isolate; }
|
||||||
|
.hero-main { padding-bottom: 3.4rem; }
|
||||||
|
.hero-spark { position: absolute; left: 0; right: 0; bottom: 0; width: 100%; height: 44px; opacity: 0.8; pointer-events: none; border-radius: 8px; }
|
||||||
|
.hero-hilo { display: flex; gap: 0.9rem; margin-top: 0.55rem; font-size: 0.85rem; font-variant-numeric: tabular-nums; }
|
||||||
|
.hero-hilo .hi { color: var(--accent); font-weight: 700; } .hero-hilo .lo { color: var(--cool); font-weight: 700; }
|
||||||
|
|
||||||
|
/* ── Highlights ── */
|
||||||
|
.highlights { display: flex; flex-wrap: wrap; gap: 0.5rem; margin-bottom: 1rem; }
|
||||||
|
.highlights:empty { display: none; }
|
||||||
|
.chip { display: inline-flex; align-items: center; gap: 0.4rem; padding: 0.45rem 0.8rem; border-radius: 999px; font-size: 0.8rem; font-weight: 600;
|
||||||
|
background: color-mix(in srgb, var(--accent) 12%, var(--paper-dark)); border: 1px solid color-mix(in srgb, var(--accent) 45%, var(--border)); }
|
||||||
|
.chip.cool { background: color-mix(in srgb, var(--cool) 12%, var(--paper-dark)); border-color: color-mix(in srgb, var(--cool) 45%, var(--border)); }
|
||||||
|
|
||||||
|
/* ── Cards & grids ── */
|
||||||
|
.two-col { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin-bottom: 1rem; }
|
||||||
|
.two-col:empty { display: none; }
|
||||||
|
@media (max-width: 720px) { .two-col { grid-template-columns: 1fr; } }
|
||||||
|
.ui-card { background: var(--paper-dark); border: 1px solid var(--border); padding: 1.1rem 1.2rem; }
|
||||||
|
.ui-card h3 { font-size: 0.68rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.09em; color: var(--ink-light); margin-bottom: 0.9rem; }
|
||||||
|
.kv { display: grid; grid-template-columns: repeat(auto-fit, minmax(110px, 1fr)); gap: 0.7rem 1rem; }
|
||||||
|
.kv b { display: block; font-size: 1.15rem; line-height: 1.2; font-variant-numeric: tabular-nums; }
|
||||||
|
.kv span { font-size: 0.68rem; text-transform: uppercase; letter-spacing: 0.05em; color: var(--ink-light); }
|
||||||
|
.kv small { display: block; font-size: 0.72rem; color: var(--ink-light); }
|
||||||
|
.sun-arc { width: 100%; height: 86px; display: block; margin-bottom: 0.7rem; }
|
||||||
|
.bar-meter { height: 8px; border-radius: 99px; background: var(--border); overflow: hidden; margin-top: 0.35rem; }
|
||||||
|
.bar-meter > i { display: block; height: 100%; background: var(--cool); border-radius: 99px; }
|
||||||
|
.pill { display: inline-block; padding: 0.15rem 0.55rem; border-radius: 99px; font-size: 0.72rem; font-weight: 700; background: var(--paper); border: 1px solid var(--border); }
|
||||||
|
.pill.ok { color: #3ca050; border-color: #3ca05088; } .pill.warn { color: var(--accent); border-color: var(--accent); }
|
||||||
|
.insight-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 1rem; margin-bottom: 1rem; }
|
||||||
|
.cmp-row { display: flex; justify-content: space-between; align-items: baseline; gap: 0.5rem; padding: 0.4rem 0; border-bottom: 1px solid var(--border); font-size: 0.85rem; }
|
||||||
|
.cmp-row:last-child { border-bottom: none; }
|
||||||
|
.cmp-row b { font-variant-numeric: tabular-nums; }
|
||||||
|
.note { font-size: 0.78rem; color: var(--ink-light); line-height: 1.45; }
|
||||||
|
.roses { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 0.8rem; text-align: center; }
|
||||||
|
.roses canvas { width: 100%; max-width: 170px; height: auto; aspect-ratio: 1; margin: 0 auto; display: block; }
|
||||||
|
.roses small { display: block; font-size: 0.72rem; color: var(--ink-light); margin-top: 0.2rem; }
|
||||||
|
.chart-card .chart-title { display: flex; align-items: center; justify-content: space-between; gap: 0.5rem; }
|
||||||
|
.chart-reset { font: inherit; font-size: 0.68rem; text-transform: none; letter-spacing: 0; padding: 0.2rem 0.6rem; border-radius: 99px; border: 1px solid var(--border); background: var(--paper); color: var(--ink); cursor: pointer; min-height: 28px; }
|
||||||
|
|
||||||
|
/* ── Wind compass ── */
|
||||||
|
.wind-wrap { display: flex; gap: 1rem; align-items: center; flex-wrap: wrap; }
|
||||||
|
.wind-wrap .stats { flex: 1 1 280px; }
|
||||||
|
#windCompass { flex: 0 0 auto; width: 116px; height: 116px; margin: 0 auto; }
|
||||||
|
|
||||||
|
/* ── Calendar ── */
|
||||||
|
.cal-bar { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; margin-bottom: 0.8rem; }
|
||||||
|
.cal-months { display: grid; grid-auto-flow: column; grid-auto-columns: 14px; gap: 3px; font-size: 0.62rem; color: var(--ink-light); height: 1rem; }
|
||||||
|
.heatmap-cell { cursor: default; }
|
||||||
|
.heatmap-wrap { scrollbar-width: thin; }
|
||||||
|
.heatmap-cell.today { outline: 2px solid var(--ink); outline-offset: -1px; }
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.hero-spark { height: 40px; }
|
||||||
|
.hero-grid > div { padding: 0.55rem 0.65rem; }
|
||||||
|
.ui-card { padding: 0.9rem; }
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) { .tab-pane { animation: none; } }
|
||||||
|
|
||||||
|
/* ── Phone fit: never let canvases force the grid wider than the screen ── */
|
||||||
|
canvas.sparkline { max-width: 100%; }
|
||||||
|
.hero-grid > div, .stat, .ui-card, .chart-card { min-width: 0; }
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.hero-main { width: 100%; }
|
||||||
|
.hero-grid { grid-template-columns: repeat(2, minmax(0, 1fr)) !important; gap: 0.6rem !important; }
|
||||||
|
.hero-grid > div { padding: 0.55rem 0.65rem; }
|
||||||
|
.stats.secondary { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
|
.wind-wrap { justify-content: center; }
|
||||||
|
.wind-wrap .stats { flex-basis: 100%; }
|
||||||
|
#windCompass { width: 132px; height: 132px; }
|
||||||
|
.section-card { padding-left: 1rem; padding-right: 1rem; }
|
||||||
|
}
|
||||||
|
@media (max-width: 640px) { .header { position: static; } }
|
||||||
@@ -0,0 +1,575 @@
|
|||||||
|
/* Dashboard UI layer — builds on the globals from dashboard.html (charts, makeChart, chartColors, …) */
|
||||||
|
(() => {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const $ = (id) => document.getElementById(id);
|
||||||
|
const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
|
||||||
|
const fmt = (v, d = 1) => (num(v) == null ? '—' : v.toFixed(d));
|
||||||
|
const mean = (a) => (a.length ? a.reduce((x, y) => x + y, 0) / a.length : null);
|
||||||
|
const sum = (a) => a.reduce((x, y) => x + y, 0);
|
||||||
|
const MS_DAY = 86400000;
|
||||||
|
|
||||||
|
let DAILY = []; // per-day summaries from /api/station/daily (station-local days)
|
||||||
|
let RECENT = []; // raw readings, last 48h
|
||||||
|
let LATEST = null;
|
||||||
|
let LOC = null;
|
||||||
|
let TZ = 'America/New_York';
|
||||||
|
let rainRange = 90;
|
||||||
|
let calMetric = 'hi';
|
||||||
|
const rendered = {}; // which lazy tabs have been drawn for the current data
|
||||||
|
|
||||||
|
async function getJSON(url) {
|
||||||
|
const r = await fetch(url);
|
||||||
|
if (r.status === 401) { location.href = '/dashboard/login'; throw new Error('auth'); }
|
||||||
|
if (!r.ok) throw new Error(url);
|
||||||
|
return r.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Date helpers (station-local day strings, YYYY-MM-DD) ─────────────────────
|
||||||
|
function dayStr(ts, tz = TZ) {
|
||||||
|
try { return new Intl.DateTimeFormat('en-CA', { timeZone: tz }).format(new Date(ts)); }
|
||||||
|
catch { return new Date(ts).toISOString().slice(0, 10); }
|
||||||
|
}
|
||||||
|
const dayMs = (s) => Date.parse(s + 'T12:00:00Z');
|
||||||
|
const addDays = (s, n) => new Date(dayMs(s) + n * MS_DAY).toISOString().slice(0, 10);
|
||||||
|
const todayStr = () => dayStr(Date.now());
|
||||||
|
const prettyDay = (s) => new Date(dayMs(s)).toLocaleDateString('en-US', { month: 'short', day: 'numeric', timeZone: 'UTC' });
|
||||||
|
const prettyMonth = (m) => { const d = new Date(m + '-15T12:00:00Z'); return d.toLocaleDateString('en-US', { month: 'short', timeZone: 'UTC' }) + (d.getUTCMonth() === 0 || true ? ` '${String(d.getUTCFullYear()).slice(2)}` : ''); };
|
||||||
|
const todayRow = () => DAILY.find((d) => d.d === todayStr()) || null;
|
||||||
|
|
||||||
|
// ── Tabs ─────────────────────────────────────────────────────────────────────
|
||||||
|
const TABS = ['now', 'charts', 'rain', 'records', 'insights'];
|
||||||
|
function setTab(name, updateHash = true) {
|
||||||
|
if (!TABS.includes(name)) name = 'now';
|
||||||
|
document.querySelectorAll('.tab').forEach((t) => t.classList.toggle('active', t.dataset.tab === name));
|
||||||
|
document.querySelectorAll('.tab-pane').forEach((p) => p.classList.toggle('active', p.dataset.pane === name));
|
||||||
|
if (updateHash) history.replaceState(null, '', '#' + name);
|
||||||
|
try { sessionStorage.setItem('dashTab', name); } catch {}
|
||||||
|
requestAnimationFrame(() => Object.values(typeof charts !== 'undefined' ? charts : {}).forEach((c) => { try { c.resize(); } catch {} }));
|
||||||
|
renderTab(name);
|
||||||
|
if (updateHash) window.scrollTo({ top: 0 });
|
||||||
|
}
|
||||||
|
function activeTab() { return document.querySelector('.tab.active')?.dataset.tab || 'now'; }
|
||||||
|
function renderTab(name) {
|
||||||
|
if (name === 'rain' && !rendered.rain) { renderRain(); rendered.rain = true; }
|
||||||
|
if (name === 'records' && !rendered.cal) { renderCalendar(); rendered.cal = true; }
|
||||||
|
if (name === 'insights' && !rendered.insights) { renderInsights(); rendered.insights = true; }
|
||||||
|
}
|
||||||
|
document.querySelectorAll('.tab').forEach((t) => t.addEventListener('click', () => setTab(t.dataset.tab)));
|
||||||
|
window.addEventListener('hashchange', () => setTab(location.hash.slice(1), false));
|
||||||
|
|
||||||
|
// Keep sticky tab bar flush under the (variable-height) header
|
||||||
|
const headerEl = document.querySelector('.header');
|
||||||
|
if (headerEl && 'ResizeObserver' in window) {
|
||||||
|
new ResizeObserver(() => document.documentElement.style.setProperty('--header-h', headerEl.offsetHeight + 'px')).observe(headerEl);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Astronomy ────────────────────────────────────────────────────────────────
|
||||||
|
const RAD = Math.PI / 180;
|
||||||
|
function solarPos(ts, lat, lon) {
|
||||||
|
const d = ts / MS_DAY + 2440587.5 - 2451545.0;
|
||||||
|
const L = (280.46 + 0.9856474 * d) % 360;
|
||||||
|
const g = ((357.528 + 0.9856003 * d) % 360) * RAD;
|
||||||
|
const lam = (L + 1.915 * Math.sin(g) + 0.02 * Math.sin(2 * g)) * RAD;
|
||||||
|
const eps = (23.439 - 0.0000004 * d) * RAD;
|
||||||
|
const dec = Math.asin(Math.sin(eps) * Math.sin(lam));
|
||||||
|
const ra = Math.atan2(Math.cos(eps) * Math.sin(lam), Math.cos(lam));
|
||||||
|
const gmst = ((280.46061837 + 360.98564736629 * d) % 360) * RAD;
|
||||||
|
const H = gmst + lon * RAD - ra;
|
||||||
|
const sinEl = Math.sin(lat * RAD) * Math.sin(dec) + Math.cos(lat * RAD) * Math.cos(dec) * Math.cos(H);
|
||||||
|
return { sinEl, elevation: Math.asin(sinEl) / RAD };
|
||||||
|
}
|
||||||
|
// Haurwitz clear-sky global horizontal irradiance (W/m²)
|
||||||
|
window.clearSkyGHI = function (ts) {
|
||||||
|
if (!LOC) return null;
|
||||||
|
const s = solarPos(ts, LOC.lat, LOC.lon).sinEl;
|
||||||
|
return s > 0.02 ? Math.round(1098 * s * Math.exp(-0.057 / s)) : 0;
|
||||||
|
};
|
||||||
|
function sunTimes(noonMs, lat, lon) {
|
||||||
|
const jd = noonMs / MS_DAY + 2440587.5;
|
||||||
|
const n = Math.ceil(jd - 2451545.0 + 0.0008);
|
||||||
|
const Js = n - lon / 360;
|
||||||
|
const M = ((357.5291 + 0.98560028 * Js) % 360) * RAD;
|
||||||
|
const C = 1.9148 * Math.sin(M) + 0.02 * Math.sin(2 * M) + 0.0003 * Math.sin(3 * M);
|
||||||
|
const lam = (((M / RAD + C + 180 + 102.9372) % 360)) * RAD;
|
||||||
|
const Jt = 2451545.0 + Js + 0.0053 * Math.sin(M) - 0.0069 * Math.sin(2 * lam);
|
||||||
|
const sinDec = Math.sin(lam) * Math.sin(23.4397 * RAD);
|
||||||
|
const cosDec = Math.cos(Math.asin(sinDec));
|
||||||
|
const cosW = (Math.sin(-0.833 * RAD) - Math.sin(lat * RAD) * sinDec) / (Math.cos(lat * RAD) * cosDec);
|
||||||
|
if (cosW > 1 || cosW < -1) return null; // polar day/night
|
||||||
|
const w = Math.acos(cosW) / RAD;
|
||||||
|
const toMs = (j) => (j - 2440587.5) * MS_DAY;
|
||||||
|
return { rise: toMs(Jt - w / 360), set: toMs(Jt + w / 360), noon: toMs(Jt) };
|
||||||
|
}
|
||||||
|
const SYNODIC = 29.530588853;
|
||||||
|
const NEW_MOON = Date.UTC(2000, 0, 6, 18, 14);
|
||||||
|
function moonInfo(ts) {
|
||||||
|
const phase = ((((ts - NEW_MOON) / MS_DAY) / SYNODIC) % 1 + 1) % 1;
|
||||||
|
const names = ['New Moon', 'Waxing Crescent', 'First Quarter', 'Waxing Gibbous', 'Full Moon', 'Waning Gibbous', 'Last Quarter', 'Waning Crescent'];
|
||||||
|
const icons = ['🌑', '🌒', '🌓', '🌔', '🌕', '🌖', '🌗', '🌘'];
|
||||||
|
const i = Math.round(phase * 8) % 8;
|
||||||
|
const untilFull = ((0.5 - phase + 1) % 1) * SYNODIC;
|
||||||
|
const untilNew = ((1 - phase) % 1) * SYNODIC;
|
||||||
|
return { name: names[i], icon: icons[i], illum: (1 - Math.cos(2 * Math.PI * phase)) / 2, untilFull, untilNew };
|
||||||
|
}
|
||||||
|
const clock = (ms) => new Date(ms).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
|
||||||
|
const hm = (ms) => { const m = Math.round(ms / 60000); return `${Math.floor(m / 60)}h ${m % 60}m`; };
|
||||||
|
|
||||||
|
// ── Now tab ──────────────────────────────────────────────────────────────────
|
||||||
|
const DELTAS = [
|
||||||
|
{ id: 'sHum', key: 'humidity', unit: '%', d: 0, eps: 1 },
|
||||||
|
{ id: 'sDew', key: 'dewPoint', unit: '°', d: 1, eps: 0.5 },
|
||||||
|
{ id: 'sPres', key: 'baromrelin', unit: ' inHg', d: 2, eps: 0.01 },
|
||||||
|
{ id: 'sWind', key: 'windspeedmph', unit: ' mph', d: 1, eps: 0.5 },
|
||||||
|
{ id: 'sUV', key: 'uv', unit: '', d: 1, eps: 0.5 },
|
||||||
|
{ id: 'sSolar', key: 'solarradiation', unit: ' W/m²', d: 0, eps: 10 },
|
||||||
|
];
|
||||||
|
function nearestReading(ts, tolMs = 30 * 60000) {
|
||||||
|
let best = null, bd = Infinity;
|
||||||
|
for (const r of RECENT) { const dd = Math.abs(r.dateutc - ts); if (dd < bd) { bd = dd; best = r; } }
|
||||||
|
return bd <= tolMs ? best : null;
|
||||||
|
}
|
||||||
|
function setDelta(host, key, cur, prev, unit, d, eps, after) {
|
||||||
|
if (!host) return;
|
||||||
|
let el = host.querySelector(`.delta[data-k="${key}"]`);
|
||||||
|
if (!el) {
|
||||||
|
el = document.createElement('div');
|
||||||
|
el.className = 'delta'; el.dataset.k = key;
|
||||||
|
if (after && after.parentNode === host) after.after(el); else host.appendChild(el);
|
||||||
|
}
|
||||||
|
if (num(cur) == null || num(prev) == null) { el.textContent = ''; return; }
|
||||||
|
const diff = cur - prev;
|
||||||
|
const cls = Math.abs(diff) < eps ? 'flat' : diff > 0 ? 'up' : 'down';
|
||||||
|
el.className = 'delta ' + cls;
|
||||||
|
el.textContent = cls === 'flat' ? 'Same as yesterday' : `${diff > 0 ? '▲' : '▼'} ${Math.abs(diff).toFixed(d)}${unit} vs yesterday`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawArea(canvas, values, color) {
|
||||||
|
if (!canvas) return;
|
||||||
|
const dpr = window.devicePixelRatio || 1;
|
||||||
|
const W = canvas.clientWidth || 200, H = canvas.clientHeight || 60;
|
||||||
|
canvas.width = W * dpr; canvas.height = H * dpr;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
ctx.scale(dpr, dpr);
|
||||||
|
const vals = values.filter((v) => v != null);
|
||||||
|
if (vals.length < 2) return;
|
||||||
|
const min = Math.min(...vals), max = Math.max(...vals), rng = max - min || 1;
|
||||||
|
const step = W / (values.length - 1);
|
||||||
|
const pts = [];
|
||||||
|
values.forEach((v, i) => { if (v != null) pts.push([i * step, H - 4 - ((v - min) / rng) * (H - 12)]); });
|
||||||
|
const g = ctx.createLinearGradient(0, 0, 0, H);
|
||||||
|
g.addColorStop(0, color + '66'); g.addColorStop(1, color + '00');
|
||||||
|
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 = 2; ctx.lineJoin = 'round'; ctx.stroke();
|
||||||
|
ctx.lineTo(pts[pts.length - 1][0], H); ctx.lineTo(pts[0][0], H); ctx.closePath();
|
||||||
|
ctx.fillStyle = g; ctx.fill();
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureHeroExtras() {
|
||||||
|
const main = document.querySelector('.hero-main');
|
||||||
|
if (!main || main.querySelector('.hero-spark')) return;
|
||||||
|
const cv = document.createElement('canvas');
|
||||||
|
cv.className = 'hero-spark'; cv.id = 'heroSpark';
|
||||||
|
main.prepend(cv);
|
||||||
|
const hilo = document.createElement('div');
|
||||||
|
hilo.className = 'hero-hilo'; hilo.id = 'heroHiLo';
|
||||||
|
$('heroWindLine')?.after(hilo);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureWindCompass() {
|
||||||
|
if ($('windCompass')) return;
|
||||||
|
const tile = $('statWindTile');
|
||||||
|
const stats = tile?.closest('.stats');
|
||||||
|
if (!stats) return;
|
||||||
|
const wrap = document.createElement('div');
|
||||||
|
wrap.className = 'wind-wrap';
|
||||||
|
wrap.innerHTML = '<div id="windCompass"></div>';
|
||||||
|
stats.before(wrap);
|
||||||
|
wrap.appendChild(stats);
|
||||||
|
}
|
||||||
|
function updateCompass(r) {
|
||||||
|
const el = $('windCompass');
|
||||||
|
if (!el || !r) return;
|
||||||
|
const dir = num(r.winddir), spd = num(r.windspeedmph) ?? 0;
|
||||||
|
const gust = num(r.windgustmph);
|
||||||
|
const rot = dir != null ? (dir + 180) % 360 : 0; // arrow shows where the wind is heading
|
||||||
|
const ticks = Array.from({ length: 16 }, (_, i) => {
|
||||||
|
const a = i * 22.5 * RAD, big = i % 4 === 0;
|
||||||
|
const x1 = 58 + Math.sin(a) * (big ? 44 : 48), y1 = 58 - Math.cos(a) * (big ? 44 : 48);
|
||||||
|
const x2 = 58 + Math.sin(a) * 52, y2 = 58 - Math.cos(a) * 52;
|
||||||
|
return `<line x1="${x1.toFixed(1)}" y1="${y1.toFixed(1)}" x2="${x2.toFixed(1)}" y2="${y2.toFixed(1)}" stroke="var(--border)" stroke-width="${big ? 1.6 : 1}"/>`;
|
||||||
|
}).join('');
|
||||||
|
el.innerHTML = `<svg viewBox="0 0 116 116" role="img" aria-label="Wind ${dir != null ? cardinal(dir) : 'calm'} ${spd.toFixed(1)} mph">
|
||||||
|
<circle cx="58" cy="58" r="52" fill="var(--paper)" stroke="var(--border)"/>${ticks}
|
||||||
|
<g font-size="9" font-weight="700" fill="var(--ink-light)" text-anchor="middle">
|
||||||
|
<text x="58" y="19">N</text><text x="100" y="61">E</text><text x="58" y="103">S</text><text x="16" y="61">W</text></g>
|
||||||
|
${spd > 0.5 && dir != null ? `<g transform="rotate(${rot} 58 58)"><path d="M58 24 L67 56 L58 50 L49 56 Z" fill="var(--accent)"/><line x1="58" y1="50" x2="58" y2="86" stroke="var(--accent)" stroke-width="2" stroke-linecap="round"/></g>` : ''}
|
||||||
|
<text x="58" y="60" text-anchor="middle" font-size="15" font-weight="800" fill="var(--ink)">${spd > 0.5 ? spd.toFixed(0) : '0'}</text>
|
||||||
|
<text x="58" y="72" text-anchor="middle" font-size="7.5" fill="var(--ink-light)">mph${dir != null && spd > 0.5 ? ' · ' + cardinal(dir) : ''}</text>
|
||||||
|
${gust != null && gust > spd + 1 ? `<text x="58" y="82" text-anchor="middle" font-size="7" fill="var(--ink-light)">gust ${gust.toFixed(0)}</text>` : ''}
|
||||||
|
</svg>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderNowExtras() {
|
||||||
|
ensureHeroExtras();
|
||||||
|
ensureWindCompass();
|
||||||
|
if (!LATEST) return;
|
||||||
|
const prev = nearestReading(LATEST.dateutc - MS_DAY);
|
||||||
|
// Delta lines under each tile
|
||||||
|
for (const m of DELTAS) {
|
||||||
|
const val = document.getElementById(m.id);
|
||||||
|
const host = val?.parentElement;
|
||||||
|
setDelta(host, m.key, LATEST[m.key], prev?.[m.key], m.unit, m.d, m.eps, host?.querySelector('.hero-stat-unit, .stat-unit') || val);
|
||||||
|
}
|
||||||
|
setDelta(document.querySelector('.hero-main'), 'tempf', LATEST.tempf, prev?.tempf, '°', 1, 0.5, document.querySelector('.hero-temp-row'));
|
||||||
|
|
||||||
|
// Hero 24h temperature trace
|
||||||
|
const last24 = RECENT.filter((r) => r.dateutc >= LATEST.dateutc - MS_DAY);
|
||||||
|
drawArea($('heroSpark'), last24.map((r) => r.tempf), chartColors().accent);
|
||||||
|
if (typeof updateSparklines === 'function') updateSparklines(last24);
|
||||||
|
|
||||||
|
// Today's range (from the persistent daily store — always the station-local day)
|
||||||
|
const t = todayRow();
|
||||||
|
if (t) {
|
||||||
|
if ($('heroHiLo')) $('heroHiLo').innerHTML = `<span>Today</span><span class="hi">↑ ${fmt(t.hi)}°</span><span class="lo">↓ ${fmt(t.lo)}°</span>`;
|
||||||
|
$('todayHigh').textContent = fmt(t.hi); $('todayLow').textContent = fmt(t.lo);
|
||||||
|
$('todayRain').textContent = fmt(t.rain, 2); $('todayGust').textContent = fmt(t.gust);
|
||||||
|
$('todayBar').style.display = '';
|
||||||
|
}
|
||||||
|
updateCompass(LATEST);
|
||||||
|
renderSunCard();
|
||||||
|
renderHealthCard();
|
||||||
|
renderHighlights();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSunCard() {
|
||||||
|
const host = $('sunCard');
|
||||||
|
if (!host) return;
|
||||||
|
if (!LOC) {
|
||||||
|
host.innerHTML = `<div class="ui-card"><h3>Sun & Moon</h3><p class="note">Set your station location in <a href="/dashboard/settings">Settings</a> to see sunrise, sunset, day length and moon phase.</p></div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const now = Date.now();
|
||||||
|
const n = new Date();
|
||||||
|
const noon = new Date(n.getFullYear(), n.getMonth(), n.getDate(), 12).getTime();
|
||||||
|
const st = sunTimes(noon, LOC.lat, LOC.lon);
|
||||||
|
const stTomorrow = sunTimes(noon + MS_DAY, LOC.lat, LOC.lon);
|
||||||
|
const moon = moonInfo(now);
|
||||||
|
const el = solarPos(now, LOC.lat, LOC.lon).elevation;
|
||||||
|
let arc = '';
|
||||||
|
let body = '<p class="note">No sunrise/sunset at this latitude today.</p>';
|
||||||
|
if (st) {
|
||||||
|
const f = Math.max(0, Math.min(1, (now - st.rise) / (st.set - st.rise)));
|
||||||
|
const day = now >= st.rise && now <= st.set;
|
||||||
|
const x = 20 + 260 * f, y = 76 - 58 * Math.sin(Math.PI * f);
|
||||||
|
arc = `<svg class="sun-arc" viewBox="0 0 300 86" preserveAspectRatio="xMidYMid meet" aria-hidden="true">
|
||||||
|
<line x1="10" y1="76" x2="290" y2="76" stroke="var(--border)"/>
|
||||||
|
<path d="M20 76 A130 58 0 0 1 280 76" fill="none" stroke="var(--border)" stroke-dasharray="4 4"/>
|
||||||
|
<circle cx="${x.toFixed(1)}" cy="${y.toFixed(1)}" r="8" fill="${day ? '#f5b82e' : 'var(--ink-light)'}" opacity="${day ? 1 : 0.5}"/></svg>`;
|
||||||
|
const delta = stTomorrow ? (stTomorrow.set - stTomorrow.rise) - (st.set - st.rise) : null;
|
||||||
|
body = `<div class="kv">
|
||||||
|
<div><b>${clock(st.rise)}</b><span>Sunrise</span></div>
|
||||||
|
<div><b>${clock(st.set)}</b><span>Sunset</span></div>
|
||||||
|
<div><b>${hm(st.set - st.rise)}</b><span>Daylight</span>${delta != null ? `<small>${delta >= 0 ? '+' : '−'}${Math.abs(Math.round(delta / 1000))}s tomorrow</small>` : ''}</div>
|
||||||
|
<div><b>${clock(st.noon)}</b><span>Solar noon</span><small>sun ${el > 0 ? Math.round(el) + '° up' : 'below horizon'}</small></div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
host.innerHTML = `<div class="ui-card"><h3>Sun & Moon</h3>${arc}${body}
|
||||||
|
<div class="kv" style="margin-top:0.9rem;padding-top:0.8rem;border-top:1px solid var(--border)">
|
||||||
|
<div><b>${moon.icon} ${Math.round(moon.illum * 100)}%</b><span>${moon.name}</span></div>
|
||||||
|
<div><b>${prettyDay(dayStr(now + moon.untilFull * MS_DAY))}</b><span>Next full</span></div>
|
||||||
|
<div><b>${prettyDay(dayStr(now + moon.untilNew * MS_DAY))}</b><span>Next new</span></div>
|
||||||
|
</div></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function medianIntervalMin() {
|
||||||
|
const d = [];
|
||||||
|
for (let i = 1; i < RECENT.length; i++) { const x = RECENT[i].dateutc - RECENT[i - 1].dateutc; if (x > 0 && x < 20 * 60000) d.push(x); }
|
||||||
|
if (!d.length) return null;
|
||||||
|
d.sort((a, b) => a - b);
|
||||||
|
return d[d.length >> 1] / 60000;
|
||||||
|
}
|
||||||
|
function coverageFor(row, perDay) { return row && perDay ? Math.min(100, (row.n / perDay) * 100) : null; }
|
||||||
|
|
||||||
|
function renderHealthCard() {
|
||||||
|
const host = $('healthCard');
|
||||||
|
if (!host || !LATEST) return;
|
||||||
|
const ageMin = Math.round((Date.now() - LATEST.dateutc) / 60000);
|
||||||
|
const ok = ageMin <= 10;
|
||||||
|
const interval = medianIntervalMin();
|
||||||
|
const perDay = interval ? 1440 / interval : null;
|
||||||
|
const past = DAILY.filter((d) => d.d !== todayStr()).slice(-30);
|
||||||
|
const cov = perDay && past.length ? mean(past.map((d) => coverageFor(d, perDay))) : null;
|
||||||
|
const t = todayRow();
|
||||||
|
host.innerHTML = `<div class="ui-card"><h3>Station Health</h3><div class="kv">
|
||||||
|
<div><b><span class="pill ${ok ? 'ok' : 'warn'}">${ok ? 'Online' : 'Delayed'}</span></b><span>Last report</span><small>${ageMin <= 0 ? 'just now' : ageMin + ' min ago'}</small></div>
|
||||||
|
<div><b>${LATEST.battout == null ? '—' : LATEST.battout === 1 ? 'OK' : 'Low'}</b><span>Battery</span></div>
|
||||||
|
<div><b>${interval ? interval.toFixed(interval % 1 ? 1 : 0) + ' min' : '—'}</b><span>Report interval</span></div>
|
||||||
|
<div><b>${t ? t.n.toLocaleString() : '—'}</b><span>Readings today</span></div>
|
||||||
|
<div><b>${cov != null ? cov.toFixed(0) + '%' : '—'}</b><span>30-day coverage</span>${cov != null ? `<div class="bar-meter"><i style="width:${cov.toFixed(0)}%"></i></div>` : ''}</div>
|
||||||
|
<div><b>${DAILY.length}</b><span>Days recorded</span><small>${DAILY[0] ? 'since ' + prettyDay(DAILY[0].d) : ''}</small></div>
|
||||||
|
</div></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ordinal(n) { const s = ['th', 'st', 'nd', 'rd'], v = n % 100; return n + (s[(v - 20) % 10] || s[v] || s[0]); }
|
||||||
|
function renderHighlights() {
|
||||||
|
const host = $('highlights');
|
||||||
|
if (!host) return;
|
||||||
|
const t = todayRow();
|
||||||
|
const hist = DAILY.filter((d) => d.d !== todayStr());
|
||||||
|
if (!t || hist.length < 7) { host.innerHTML = ''; return; }
|
||||||
|
const chips = [];
|
||||||
|
const check = (key, label, better, minVal, icon, cool, unit, d = 1, partial = true) => {
|
||||||
|
const v = t[key];
|
||||||
|
if (num(v) == null || (minVal != null && v < minVal)) return;
|
||||||
|
const others = hist.filter((x) => num(x[key]) != null);
|
||||||
|
if (others.length < 7) return;
|
||||||
|
const rank = others.filter((x) => better(x[key], v)).length + 1;
|
||||||
|
const val = `${v.toFixed(d)}${unit}`;
|
||||||
|
const so = partial ? ' so far' : '';
|
||||||
|
if (rank === 1) { chips.push({ t: `${icon} ${label} on record (${val}${so})`, cool }); return; }
|
||||||
|
if (rank <= 3) { chips.push({ t: `${icon} ${ordinal(rank)} ${label.toLowerCase()} on record (${val}${so})`, cool }); return; }
|
||||||
|
let since = 0;
|
||||||
|
for (let i = hist.length - 1; i >= 0; i--) { if (num(hist[i][key]) != null && !better(v, hist[i][key]) && hist[i][key] !== v) { since = hist.length - i; break; } }
|
||||||
|
if (since >= 14) chips.push({ t: `${icon} ${label} in ${since} days (${val}${so})`, cool });
|
||||||
|
};
|
||||||
|
check('hi', 'Hottest day', (a, b) => a >= b, null, '🔥', false, '°');
|
||||||
|
check('lo', 'Coldest night', (a, b) => a <= b, null, '❄️', true, '°', 1, true);
|
||||||
|
check('gust', 'Windiest day', (a, b) => a >= b, 10, '💨', true, ' mph');
|
||||||
|
check('rain', 'Wettest day', (a, b) => a >= b, 0.1, '🌧', true, '"', 2);
|
||||||
|
host.innerHTML = chips.slice(0, 4).map((c) => `<span class="chip${c.cool ? ' cool' : ''}">${c.t}</span>`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Rain tab ─────────────────────────────────────────────────────────────────
|
||||||
|
function windowDays(n) {
|
||||||
|
const end = todayStr();
|
||||||
|
const map = new Map(DAILY.map((d) => [d.d, d]));
|
||||||
|
const out = [];
|
||||||
|
for (let i = n - 1; i >= 0; i--) { const s = addDays(end, -i); out.push({ d: s, row: map.get(s) || null }); }
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
function renderRain() {
|
||||||
|
const host = $('rainTab');
|
||||||
|
if (!host) return;
|
||||||
|
if (!DAILY.length) { host.innerHTML = '<div class="status">No daily data recorded yet.</div>'; return; }
|
||||||
|
const win = windowDays(rainRange).filter((x, i, a) => x.row || i >= a.findIndex((y) => y.row));
|
||||||
|
const known = win.filter((x) => x.row && num(x.row.rain) != null);
|
||||||
|
const total = sum(known.map((x) => x.row.rain));
|
||||||
|
const rainy = known.filter((x) => x.row.rain >= 0.01);
|
||||||
|
const wettest = known.reduce((b, x) => (!b || x.row.rain > b.row.rain ? x : b), null);
|
||||||
|
let dry = 0, run = 0;
|
||||||
|
for (const x of win) { if (x.row && x.row.rain < 0.01) { run++; dry = Math.max(dry, run); } else run = 0; }
|
||||||
|
host.innerHTML = `
|
||||||
|
<div class="range-bar" id="rainRange"><span>Window:</span>
|
||||||
|
${[30, 90, 365].map((n) => `<button class="range-btn${n === rainRange ? ' active' : ''}" data-rr="${n}">${n === 365 ? '1y' : n + 'd'}</button>`).join('')}</div>
|
||||||
|
<div class="stats secondary" style="margin-bottom:1rem">
|
||||||
|
<div class="stat"><div class="stat-label">Total</div><div class="stat-val">${total.toFixed(2)}</div><div class="stat-unit">in</div></div>
|
||||||
|
<div class="stat"><div class="stat-label">Rainy days</div><div class="stat-val">${rainy.length}</div><div class="stat-unit">of ${known.length} days</div></div>
|
||||||
|
<div class="stat"><div class="stat-label">Wettest day</div><div class="stat-val">${wettest ? wettest.row.rain.toFixed(2) : '—'}</div><div class="stat-unit">${wettest ? prettyDay(wettest.d) : ''}</div></div>
|
||||||
|
<div class="stat"><div class="stat-label">Avg / rainy day</div><div class="stat-val">${rainy.length ? (total / rainy.length).toFixed(2) : '—'}</div><div class="stat-unit">in</div></div>
|
||||||
|
<div class="stat"><div class="stat-label">Longest dry spell</div><div class="stat-val">${dry}</div><div class="stat-unit">days</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="charts">
|
||||||
|
<div class="chart-card wide"><div class="chart-title">Daily rain & cumulative total</div><div class="chart-wrap"><canvas id="rnDaily"></canvas></div></div>
|
||||||
|
<div class="chart-card wide"><div class="chart-title">Monthly totals</div><div class="chart-wrap" style="height:200px"><canvas id="rnMonthly"></canvas></div></div>
|
||||||
|
</div>`;
|
||||||
|
host.querySelectorAll('[data-rr]').forEach((b) => b.addEventListener('click', () => { rainRange = +b.dataset.rr; renderRain(); }));
|
||||||
|
|
||||||
|
const c = chartColors();
|
||||||
|
let cum = 0;
|
||||||
|
const cumul = win.map((x) => { if (x.row && num(x.row.rain) != null) cum += x.row.rain; return +cum.toFixed(2); });
|
||||||
|
makeChart('rnDaily', 'bar', [
|
||||||
|
{ ...barDataset('Daily rain (in)', win.map((x) => (x.row ? x.row.rain : null)), c.cool), yAxisID: 'y' },
|
||||||
|
{ type: 'line', label: 'Cumulative (in)', data: cumul, borderColor: c.accent, backgroundColor: c.accent + '00', borderWidth: 2, pointRadius: 0, tension: 0.2, yAxisID: 'y2' },
|
||||||
|
], win.map((x) => prettyDay(x.d)), 'in/day', {
|
||||||
|
scales: { y: { min: 0 }, y2: { position: 'right', min: 0, grid: { drawOnChartArea: false }, ticks: { color: c.inkLight, font: { size: 11 } } } },
|
||||||
|
});
|
||||||
|
const byMonth = {};
|
||||||
|
for (const d of DAILY) if (num(d.rain) != null) { const m = d.d.slice(0, 7); byMonth[m] = (byMonth[m] || 0) + d.rain; }
|
||||||
|
const months = Object.keys(byMonth).sort();
|
||||||
|
makeChart('rnMonthly', 'bar', [barDataset('Total rain (in)', months.map((m) => +byMonth[m].toFixed(2)), c.cool)],
|
||||||
|
months.map(prettyMonth), 'in', { scales: { y: { min: 0 } } });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Calendar (Records tab) ───────────────────────────────────────────────────
|
||||||
|
const CAL = {
|
||||||
|
hi: { label: 'High temp', get: (d) => d.hi, color: (v) => { if (v == null) return 'var(--border)'; const n = Math.max(0, Math.min(1, (v - 20) / 90)); return `rgb(${Math.round(42 + n * 182)},${Math.round(107 + n * 13)},${Math.round(138 - n * 96)})`; }, fmt: (v) => v.toFixed(1) + '°F', legend: [20, 40, 60, 80, 100] },
|
||||||
|
lo: { label: 'Low temp', get: (d) => d.lo, color: (v) => CAL.hi.color(v), fmt: (v) => v.toFixed(1) + '°F', legend: [20, 40, 60, 80, 100] },
|
||||||
|
rain: { label: 'Rain', get: (d) => d.rain, color: (v) => (v == null ? 'var(--border)' : v < 0.01 ? 'color-mix(in srgb, var(--border) 60%, transparent)' : `color-mix(in srgb, var(--cool) ${Math.round(25 + Math.min(1, v / 1.5) * 75)}%, transparent)`), fmt: (v) => v.toFixed(2) + '"', legend: [0, 0.1, 0.4, 0.8, 1.5] },
|
||||||
|
gust: { label: 'Peak gust', get: (d) => d.gust, color: (v) => (v == null ? 'var(--border)' : `color-mix(in srgb, var(--accent) ${Math.round(10 + Math.min(1, v / 40) * 90)}%, transparent)`), fmt: (v) => v.toFixed(0) + ' mph', legend: [0, 10, 20, 30, 40] },
|
||||||
|
};
|
||||||
|
function renderCalendar() {
|
||||||
|
const host = $('calendarCard');
|
||||||
|
if (!host) return;
|
||||||
|
if (!DAILY.length) { host.innerHTML = '<div class="status">No daily data yet.</div>'; return; }
|
||||||
|
const M = CAL[calMetric];
|
||||||
|
const map = new Map(DAILY.map((d) => [d.d, d]));
|
||||||
|
const end = todayStr();
|
||||||
|
let start = addDays(end, -364);
|
||||||
|
const dow = new Date(dayMs(start)).getUTCDay();
|
||||||
|
start = addDays(start, -dow);
|
||||||
|
const cells = [], labels = [];
|
||||||
|
let col = 0, lastMonth = '';
|
||||||
|
for (let s = start, i = 0; s <= end; s = addDays(s, 1), i++) {
|
||||||
|
if (i % 7 === 0) {
|
||||||
|
col = i / 7;
|
||||||
|
const m = s.slice(0, 7);
|
||||||
|
if (m !== lastMonth && s >= addDays(end, -364)) { labels.push(`<span style="grid-column:${col + 1}">${new Date(dayMs(s)).toLocaleDateString('en-US', { month: 'short', timeZone: 'UTC' })}</span>`); lastMonth = m; }
|
||||||
|
}
|
||||||
|
const row = map.get(s), v = row ? M.get(row) : null;
|
||||||
|
const before = s < addDays(end, -364);
|
||||||
|
cells.push(`<div class="heatmap-cell${s === end ? ' today' : ''}" style="background:${before ? 'transparent' : M.color(num(v))}" title="${prettyDay(s)}${num(v) != null ? ': ' + M.fmt(v) : ''}"></div>`);
|
||||||
|
}
|
||||||
|
host.innerHTML = `<div class="section-card-hdr">Calendar</div>
|
||||||
|
<div class="cal-bar">${Object.entries(CAL).map(([k, m]) => `<button class="range-btn${k === calMetric ? ' active' : ''}" data-cal="${k}">${m.label}</button>`).join('')}</div>
|
||||||
|
<div class="heatmap-wrap"><div class="cal-months">${labels.join('')}</div><div class="heatmap">${cells.join('')}</div></div>
|
||||||
|
<div class="heatmap-legend"><span>Low</span>${M.legend.map((v) => `<div class="heatmap-legend-swatch" style="background:${M.color(v)}"></div>`).join('')}<span>High</span></div>`;
|
||||||
|
host.querySelectorAll('[data-cal]').forEach((b) => b.addEventListener('click', () => { calMetric = b.dataset.cal; renderCalendar(); }));
|
||||||
|
const wrap = host.querySelector('.heatmap-wrap');
|
||||||
|
if (wrap) wrap.scrollLeft = wrap.scrollWidth; // show the most recent weeks on phones
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Insights tab ─────────────────────────────────────────────────────────────
|
||||||
|
function drawRose(canvas, buckets) {
|
||||||
|
const dpr = window.devicePixelRatio || 1, S = 170;
|
||||||
|
canvas.width = S * dpr; canvas.height = S * dpr;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
ctx.scale(dpr, dpr);
|
||||||
|
const cs = getComputedStyle(document.documentElement);
|
||||||
|
const border = cs.getPropertyValue('--border').trim(), accent = cs.getPropertyValue('--accent').trim(), light = cs.getPropertyValue('--ink-light').trim();
|
||||||
|
const cx = S / 2, R = S / 2 - 18, max = Math.max(...buckets, 1), sl = (Math.PI * 2) / 16;
|
||||||
|
for (let i = 1; i <= 3; i++) { ctx.beginPath(); ctx.arc(cx, cx, (R * i) / 3, 0, Math.PI * 2); ctx.strokeStyle = border + '88'; ctx.lineWidth = 0.6; ctx.stroke(); }
|
||||||
|
ctx.fillStyle = light; ctx.font = '10px sans-serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
|
||||||
|
['N', 'E', 'S', 'W'].forEach((l, i) => { const a = (i * Math.PI) / 2 - Math.PI / 2; ctx.fillText(l, cx + Math.cos(a) * (R + 10), cx + Math.sin(a) * (R + 10)); });
|
||||||
|
buckets.forEach((b, i) => {
|
||||||
|
const len = (b / max) * R; if (len < 1) return;
|
||||||
|
const a = i * sl - Math.PI / 2 - sl / 2;
|
||||||
|
ctx.beginPath(); ctx.moveTo(cx, cx); ctx.arc(cx, cx, len, a, a + sl); ctx.closePath();
|
||||||
|
ctx.fillStyle = accent + 'cc'; ctx.fill(); ctx.strokeStyle = accent; ctx.lineWidth = 0.5; ctx.stroke();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function seasonOf(month, south) {
|
||||||
|
const m = +month;
|
||||||
|
const s = m === 12 || m <= 2 ? 0 : m <= 5 ? 1 : m <= 8 ? 2 : 3; // DJF MAM JJA SON
|
||||||
|
return south ? (s + 2) % 4 : s;
|
||||||
|
}
|
||||||
|
function trendRow(label, cur, prev, d, unit, goodUp) {
|
||||||
|
if (cur == null || prev == null) return `<div class="cmp-row"><span>${label}</span><b>—</b></div>`;
|
||||||
|
const diff = cur - prev;
|
||||||
|
const cls = Math.abs(diff) < Math.pow(10, -d) ? 'flat' : diff > 0 ? 'up' : 'down';
|
||||||
|
return `<div class="cmp-row"><span>${label}</span><span><b>${cur.toFixed(d)}${unit}</b> <span class="delta ${cls}" style="display:inline">${cls === 'flat' ? '=' : (diff > 0 ? '▲ ' : '▼ ') + Math.abs(diff).toFixed(d) + unit}</span></span></div>`;
|
||||||
|
}
|
||||||
|
function renderInsights() {
|
||||||
|
const host = $('insightsBody');
|
||||||
|
if (!host) return;
|
||||||
|
if (DAILY.length < 3) { host.innerHTML = '<div class="status">Insights unlock after a few days of recorded data.</div>'; return; }
|
||||||
|
const past = DAILY.filter((d) => d.d !== todayStr());
|
||||||
|
const W = Math.min(30, Math.floor(past.length / 2));
|
||||||
|
let compare = '<p class="note">Needs at least 14 recorded days to compare periods.</p>';
|
||||||
|
if (W >= 7) {
|
||||||
|
const cur = past.slice(-W), prev = past.slice(-2 * W, -W);
|
||||||
|
const col = (a, k) => a.map((x) => x[k]).filter((v) => num(v) != null);
|
||||||
|
compare = `<p class="note" style="margin-bottom:0.5rem">Last ${W} days vs the ${W} before</p>
|
||||||
|
${trendRow('Avg temp', mean(col(cur, 'avg')), mean(col(prev, 'avg')), 1, '°')}
|
||||||
|
${trendRow('Avg high', mean(col(cur, 'hi')), mean(col(prev, 'hi')), 1, '°')}
|
||||||
|
${trendRow('Avg low', mean(col(cur, 'lo')), mean(col(prev, 'lo')), 1, '°')}
|
||||||
|
${trendRow('Rain total', sum(col(cur, 'rain')), sum(col(prev, 'rain')), 2, '"')}
|
||||||
|
${trendRow('Rainy days', col(cur, 'rain').filter((v) => v >= 0.01).length, col(prev, 'rain').filter((v) => v >= 0.01).length, 0, '')}
|
||||||
|
${trendRow('Peak gust', Math.max(...col(cur, 'gust'), 0), Math.max(...col(prev, 'gust'), 0), 0, ' mph')}`;
|
||||||
|
}
|
||||||
|
// Normals: this calendar month vs the same month in earlier years
|
||||||
|
const thisMonth = todayStr().slice(5, 7), thisYear = todayStr().slice(0, 4);
|
||||||
|
const monthRows = (y) => DAILY.filter((d) => d.d.startsWith(`${y}-${thisMonth}`));
|
||||||
|
const years = [...new Set(DAILY.map((d) => d.d.slice(0, 4)))].filter((y) => y !== thisYear && monthRows(y).length >= 20);
|
||||||
|
const curRows = monthRows(thisYear);
|
||||||
|
let normals = `<p class="note">Station normals unlock once a full year of data is recorded — ${DAILY.length} day${DAILY.length === 1 ? '' : 's'} so far. Until then, use the period comparison.</p>`;
|
||||||
|
if (years.length && curRows.length) {
|
||||||
|
const avgOf = (y, k) => mean(monthRows(y).map((d) => d[k]).filter((v) => num(v) != null));
|
||||||
|
const normAvg = (k) => mean(years.map((y) => avgOf(y, k)).filter((v) => v != null));
|
||||||
|
const rainNorm = mean(years.map((y) => sum(monthRows(y).map((d) => d.rain || 0))));
|
||||||
|
normals = `<p class="note" style="margin-bottom:0.5rem">This month vs ${years.length}-year station average</p>
|
||||||
|
${trendRow('Avg temp', mean(curRows.map((d) => d.avg).filter((v) => v != null)), normAvg('avg'), 1, '°')}
|
||||||
|
${trendRow('Avg high', mean(curRows.map((d) => d.hi).filter((v) => v != null)), normAvg('hi'), 1, '°')}
|
||||||
|
${trendRow('Avg low', mean(curRows.map((d) => d.lo).filter((v) => v != null)), normAvg('lo'), 1, '°')}
|
||||||
|
${trendRow('Rain (month to date vs full month avg)', sum(curRows.map((d) => d.rain || 0)), rainNorm, 2, '"')}`;
|
||||||
|
}
|
||||||
|
host.innerHTML = `
|
||||||
|
<div class="insight-grid">
|
||||||
|
<div class="ui-card"><h3>Trends</h3>${compare}</div>
|
||||||
|
<div class="ui-card"><h3>Vs normal</h3>${normals}</div>
|
||||||
|
</div>
|
||||||
|
<div class="charts" style="margin-bottom:1rem">
|
||||||
|
<div class="chart-card wide"><div class="chart-title">Daily temperature range (last 60 days)</div><div class="chart-wrap"><canvas id="inDiurnal"></canvas></div></div>
|
||||||
|
<div class="chart-card wide"><div class="chart-title">Data coverage by day</div><div class="chart-wrap" style="height:150px"><canvas id="inCoverage"></canvas></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="ui-card" style="margin-bottom:1rem"><h3>Prevailing wind by season</h3><div class="roses" id="seasonRoses"></div>
|
||||||
|
<p class="note" style="margin-top:0.7rem">Share of readings with measurable wind, by direction it blows from.</p></div>`;
|
||||||
|
|
||||||
|
const c = chartColors();
|
||||||
|
const last60 = past.slice(-60);
|
||||||
|
makeChart('inDiurnal', 'bar', [
|
||||||
|
{ label: 'Low–High', data: last60.map((d) => (d.lo != null && d.hi != null ? [d.lo, d.hi] : null)), backgroundColor: c.accent + '77', borderColor: c.accent, borderWidth: 1, borderRadius: 3, borderSkipped: false },
|
||||||
|
{ type: 'line', label: 'Average', data: last60.map((d) => d.avg), borderColor: c.cool, backgroundColor: c.cool + '00', borderWidth: 2, pointRadius: 0, tension: 0.3 },
|
||||||
|
], last60.map((d) => prettyDay(d.d)), '°F', (() => {
|
||||||
|
const los = last60.map((d) => d.lo).filter((v) => v != null), his = last60.map((d) => d.hi).filter((v) => v != null);
|
||||||
|
return los.length ? { scales: { y: { min: Math.floor(Math.min(...los) - 3), max: Math.ceil(Math.max(...his) + 3) } } } : {};
|
||||||
|
})());
|
||||||
|
|
||||||
|
const interval = medianIntervalMin();
|
||||||
|
const perDay = interval ? 1440 / interval : null;
|
||||||
|
const cov = past.slice(-45);
|
||||||
|
if (perDay) {
|
||||||
|
const vals = cov.map((d) => +coverageFor(d, perDay).toFixed(0));
|
||||||
|
const ds = barDataset('Coverage %', vals, c.cool);
|
||||||
|
ds.backgroundColor = vals.map((v) => (v < 90 ? c.accent + 'bb' : c.cool + 'bb'));
|
||||||
|
makeChart('inCoverage', 'bar', [ds], cov.map((d) => prettyDay(d.d)), '%', { scales: { y: { min: 0, max: 100 } } });
|
||||||
|
}
|
||||||
|
|
||||||
|
const south = LOC && LOC.lat < 0;
|
||||||
|
const names = south ? ['Summer', 'Autumn', 'Winter', 'Spring'] : ['Winter', 'Spring', 'Summer', 'Fall'];
|
||||||
|
const seasons = names.map(() => new Array(16).fill(0));
|
||||||
|
for (const d of DAILY) if (d.wd) { const s = seasonOf(d.d.slice(5, 7), south); d.wd.forEach((v, i) => { seasons[s][i] += v; }); }
|
||||||
|
const roses = $('seasonRoses');
|
||||||
|
const cards = seasons.map((b, i) => ({ name: names[i], b, total: sum(b) })).filter((x) => x.total > 0);
|
||||||
|
if (!cards.length) roses.innerHTML = '<p class="note">Wind direction history builds up as new readings are saved.</p>';
|
||||||
|
cards.forEach((x) => {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
const top = x.b.indexOf(Math.max(...x.b));
|
||||||
|
div.innerHTML = `<canvas></canvas><small><b>${x.name}</b> · from ${cardinal(top * 22.5)} (${Math.round((x.b[top] / x.total) * 100)}%)</small>`;
|
||||||
|
roses.appendChild(div);
|
||||||
|
drawRose(div.querySelector('canvas'), x.b);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Data refresh ─────────────────────────────────────────────────────────────
|
||||||
|
async function refreshData() {
|
||||||
|
try {
|
||||||
|
const [daily, recent] = await Promise.all([
|
||||||
|
getJSON('/api/station/daily').catch(() => []),
|
||||||
|
getJSON('/api/station/history?range=2d').catch(() => []),
|
||||||
|
]);
|
||||||
|
DAILY = daily; RECENT = recent;
|
||||||
|
LATEST = RECENT[RECENT.length - 1] || null;
|
||||||
|
if (LATEST?.tz) TZ = LATEST.tz;
|
||||||
|
if (!LOC) {
|
||||||
|
const loc = await getJSON('/api/dashboard/location').catch(() => null);
|
||||||
|
if (loc && num(loc.lat) != null) {
|
||||||
|
LOC = loc; window.STATION_LOC = loc;
|
||||||
|
if (typeof loadHistory === 'function') loadHistory(currentRange); // redraw so clear-sky overlay appears
|
||||||
|
}
|
||||||
|
}
|
||||||
|
renderNowExtras();
|
||||||
|
rendered.rain = rendered.cal = rendered.insights = false;
|
||||||
|
renderTab(activeTab());
|
||||||
|
} catch (e) { if (e && e.message !== 'auth') console.error('[dashboard-ui]', e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('refreshBtn')?.addEventListener('click', refreshData);
|
||||||
|
setInterval(refreshData, 120000);
|
||||||
|
|
||||||
|
let initial = location.hash.slice(1);
|
||||||
|
if (!TABS.includes(initial)) { try { initial = sessionStorage.getItem('dashTab') || 'now'; } catch { initial = 'now'; } }
|
||||||
|
setTab(initial, false);
|
||||||
|
refreshData();
|
||||||
|
})();
|
||||||
+100
-78
@@ -13,6 +13,9 @@
|
|||||||
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png">
|
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png">
|
||||||
<title>Station Dashboard</title>
|
<title>Station Dashboard</title>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/hammerjs@2.0.8/hammer.min.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-zoom@2.0.1/dist/chartjs-plugin-zoom.min.js"></script>
|
||||||
|
<link rel="stylesheet" href="/dashboard-ui.css">
|
||||||
<style>
|
<style>
|
||||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
:root {
|
:root {
|
||||||
@@ -388,10 +391,20 @@ input, select, textarea { font-size: 16px; } /* stops iOS zooming in on focus */
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<nav class="tabbar" id="tabbar" aria-label="Dashboard sections">
|
||||||
|
<button class="tab active" data-tab="now"><span class="tab-ico">◉</span><span>Now</span></button>
|
||||||
|
<button class="tab" data-tab="charts"><span class="tab-ico">〰</span><span>Charts</span></button>
|
||||||
|
<button class="tab" data-tab="rain"><span class="tab-ico">☂</span><span>Rain</span></button>
|
||||||
|
<button class="tab" data-tab="records"><span class="tab-ico">★</span><span>Records</span></button>
|
||||||
|
<button class="tab" data-tab="insights"><span class="tab-ico">✦</span><span>Insights</span></button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
<div class="page">
|
<div class="page">
|
||||||
<div class="offline-banner" id="offlineBanner"></div>
|
<div class="offline-banner" id="offlineBanner"></div>
|
||||||
<div id="alertsSection" style="margin-bottom:0"></div>
|
<div id="alertsSection" style="margin-bottom:0"></div>
|
||||||
|
|
||||||
|
<div class="tab-pane active" data-pane="now">
|
||||||
|
<div id="highlights" class="highlights"></div>
|
||||||
<!-- Hero: Current Conditions -->
|
<!-- Hero: Current Conditions -->
|
||||||
<div class="section-card">
|
<div class="section-card">
|
||||||
<div class="section-card-hdr">Current Conditions</div>
|
<div class="section-card-hdr">Current Conditions</div>
|
||||||
@@ -521,6 +534,10 @@ input, select, textarea { font-size: 16px; } /* stops iOS zooming in on focus */
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="sunHealthRow" class="two-col"><div id="sunCard"></div><div id="healthCard"></div></div>
|
||||||
|
</div><!-- /now -->
|
||||||
|
|
||||||
|
<div class="tab-pane" data-pane="charts">
|
||||||
<!-- Range selector -->
|
<!-- Range selector -->
|
||||||
<div class="range-bar">
|
<div class="range-bar">
|
||||||
<span>History:</span>
|
<span>History:</span>
|
||||||
@@ -543,6 +560,11 @@ input, select, textarea { font-size: 16px; } /* stops iOS zooming in on focus */
|
|||||||
<div class="status" id="loadingMsg">Loading history…</div>
|
<div class="status" id="loadingMsg">Loading history…</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
</div><!-- /charts -->
|
||||||
|
|
||||||
|
<div class="tab-pane" data-pane="rain" id="rainTab"><div class="status">Loading rain…</div></div>
|
||||||
|
|
||||||
|
<div class="tab-pane" data-pane="records">
|
||||||
<!-- Records -->
|
<!-- Records -->
|
||||||
<details class="settings-panel" id="recordsPanel" style="margin-top:1.5rem">
|
<details class="settings-panel" id="recordsPanel" style="margin-top:1.5rem">
|
||||||
<summary>All-Time Station Records</summary>
|
<summary>All-Time Station Records</summary>
|
||||||
@@ -559,14 +581,12 @@ input, select, textarea { font-size: 16px; } /* stops iOS zooming in on focus */
|
|||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<!-- Temperature Heatmap -->
|
<div id="calendarCard" class="section-card" style="margin-top:1rem"><div class="status">Loading calendar…</div></div>
|
||||||
<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>
|
|
||||||
|
|
||||||
|
</div><!-- /records -->
|
||||||
|
|
||||||
|
<div class="tab-pane" data-pane="insights" id="insightsTab">
|
||||||
|
<div id="insightsBody"><div class="status">Loading insights…</div></div>
|
||||||
<!-- Alert History -->
|
<!-- Alert History -->
|
||||||
<details class="settings-panel" id="alertHistoryPanel" style="margin-top:1rem">
|
<details class="settings-panel" id="alertHistoryPanel" style="margin-top:1rem">
|
||||||
<summary>Alert History</summary>
|
<summary>Alert History</summary>
|
||||||
@@ -574,6 +594,7 @@ input, select, textarea { font-size: 16px; } /* stops iOS zooming in on focus */
|
|||||||
<div class="status">Loading…</div>
|
<div class="status">Loading…</div>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
|
</div><!-- /insights -->
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -626,6 +647,10 @@ function makeChart(canvasId, type, datasets, labels, yLabel, opts = {}) {
|
|||||||
maintainAspectRatio: false,
|
maintainAspectRatio: false,
|
||||||
interaction: { mode: 'index', intersect: false },
|
interaction: { mode: 'index', intersect: false },
|
||||||
plugins: {
|
plugins: {
|
||||||
|
zoom: {
|
||||||
|
zoom: { wheel: { enabled: true }, pinch: { enabled: true }, mode: 'x', onZoomComplete: ({ chart }) => markZoomed(chart) },
|
||||||
|
pan: { enabled: true, mode: 'x', threshold: 10, onPanComplete: ({ chart }) => markZoomed(chart) },
|
||||||
|
},
|
||||||
legend: { display: datasets.length > 1, labels: { color: c.inkLight, font: { size: 11 }, boxWidth: 10 } },
|
legend: { display: datasets.length > 1, labels: { color: c.inkLight, font: { size: 11 }, boxWidth: 10 } },
|
||||||
tooltip: {
|
tooltip: {
|
||||||
backgroundColor: 'rgba(0,0,0,0.75)',
|
backgroundColor: 'rgba(0,0,0,0.75)',
|
||||||
@@ -650,9 +675,23 @@ function makeChart(canvasId, type, datasets, labels, yLabel, opts = {}) {
|
|||||||
|
|
||||||
const mergedOpts = deepMerge(defaults, opts);
|
const mergedOpts = deepMerge(defaults, opts);
|
||||||
charts[canvasId] = new Chart(ctx, { type, data: { labels, datasets }, options: mergedOpts });
|
charts[canvasId] = new Chart(ctx, { type, data: { labels, datasets }, options: mergedOpts });
|
||||||
|
ctx.ondblclick = () => { charts[canvasId].resetZoom(); markZoomed(charts[canvasId], false); };
|
||||||
return charts[canvasId];
|
return charts[canvasId];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function markZoomed(chart, on = true) {
|
||||||
|
const card = chart.canvas.closest('.chart-card');
|
||||||
|
if (!card) return;
|
||||||
|
let btn = card.querySelector('.chart-reset');
|
||||||
|
if (!btn) {
|
||||||
|
btn = document.createElement('button');
|
||||||
|
btn.className = 'chart-reset'; btn.textContent = 'Reset zoom';
|
||||||
|
btn.addEventListener('click', () => { chart.resetZoom(); markZoomed(chart, false); });
|
||||||
|
card.querySelector('.chart-title')?.appendChild(btn);
|
||||||
|
}
|
||||||
|
btn.style.display = on ? '' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
function deepMerge(a, b) {
|
function deepMerge(a, b) {
|
||||||
const out = { ...a };
|
const out = { ...a };
|
||||||
for (const k of Object.keys(b || {})) {
|
for (const k of Object.keys(b || {})) {
|
||||||
@@ -794,6 +833,11 @@ function renderCharts(readings, range, yoy = null) {
|
|||||||
<div class="chart-wrap"><canvas id="cPres"></canvas></div>
|
<div class="chart-wrap"><canvas id="cPres"></canvas></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="chart-card wide" id="cardPresTend">
|
||||||
|
<div class="chart-title">Pressure Tendency · 3-hour change (inHg)</div>
|
||||||
|
<div class="chart-wrap" style="height:140px"><canvas id="cPresTend"></canvas></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Rain -->
|
<!-- Rain -->
|
||||||
<div class="chart-card">
|
<div class="chart-card">
|
||||||
<div class="chart-title">Rain Rate (in/hr)</div>
|
<div class="chart-title">Rain Rate (in/hr)</div>
|
||||||
@@ -817,18 +861,27 @@ function renderCharts(readings, range, yoy = null) {
|
|||||||
<div class="chart-wrap"><canvas id="cSolar"></canvas></div>
|
<div class="chart-wrap"><canvas id="cSolar"></canvas></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
${readings.some(r => r.tempinf != null) ? `<div class="chart-card wide">
|
||||||
|
<div class="chart-title">Indoor vs Outdoor</div>
|
||||||
|
<div class="chart-wrap"><canvas id="cIndoor"></canvas></div>
|
||||||
|
</div>` : ''}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const tempDatasets = [
|
const tempDatasets = [];
|
||||||
lineDataset('Temp °F', field(pts, 'tempf'), c.accent),
|
if (pts.some(r => r.tempMax != null)) {
|
||||||
lineDataset('Feels Like', field(pts, 'feelsLike'), c.cool, true),
|
// Bucketed long ranges: show the true min/max envelope behind the average
|
||||||
];
|
tempDatasets.push({ label: 'Max', data: field(pts, 'tempMax'), borderWidth: 0, pointRadius: 0, backgroundColor: c.accent + '22', fill: '+1', tension: 0.3 });
|
||||||
|
tempDatasets.push({ label: 'Min', data: field(pts, 'tempMin'), borderWidth: 0, pointRadius: 0, backgroundColor: c.accent + '22', fill: false, tension: 0.3 });
|
||||||
|
}
|
||||||
|
tempDatasets.push(lineDataset('Temp °F', field(pts, 'tempf'), c.accent));
|
||||||
|
tempDatasets.push(lineDataset('Feels Like', field(pts, 'feelsLike'), c.cool, true));
|
||||||
if (yoy?.prior?.length) {
|
if (yoy?.prior?.length) {
|
||||||
const yoyPts = yoy.prior.length > 720 ? yoy.prior.filter((_, i) => i % Math.ceil(yoy.prior.length / 720) === 0) : yoy.prior;
|
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 });
|
tempDatasets.push({ ...lineDataset('Last Year', yoyPts.map(r => r.tempf), c.accent + '55', true), borderWidth: 1 });
|
||||||
}
|
}
|
||||||
makeChart('cTemp', 'line', tempDatasets, lbls, '°F');
|
makeChart('cTemp', 'line', tempDatasets, lbls, '°F', { plugins: { legend: { labels: { filter: i => !['Max', 'Min'].includes(i.text) } } } });
|
||||||
|
|
||||||
makeChart('cHum', 'line',
|
makeChart('cHum', 'line',
|
||||||
[lineDataset('Humidity %', field(pts, 'humidity'), c.cool),
|
[lineDataset('Humidity %', field(pts, 'humidity'), c.cool),
|
||||||
@@ -860,13 +913,38 @@ function renderCharts(readings, range, yoy = null) {
|
|||||||
[lineDataset('UV Index', field(pts, 'uv'), c.accent)],
|
[lineDataset('UV Index', field(pts, 'uv'), c.accent)],
|
||||||
lbls, null, { scales: { y: { min: 0 } } });
|
lbls, null, { scales: { y: { min: 0 } } });
|
||||||
|
|
||||||
makeChart('cSolar', 'line',
|
const solarSets = [lineDataset('Solar W/m²', field(pts, 'solarradiation'), c.accent)];
|
||||||
[lineDataset('Solar W/m²', field(pts, 'solarradiation'), c.accent)],
|
if (typeof clearSkyGHI === 'function' && ['1h', '6h', '24h', '2d', '7d'].includes(range) && window.STATION_LOC) {
|
||||||
lbls, 'W/m²', { scales: { y: { min: 0 } } });
|
const cs = pts.map(r => clearSkyGHI(r.dateutc));
|
||||||
|
if (cs.some(v => v != null)) solarSets.push(lineDataset('Clear-sky potential', cs, c.cool, true));
|
||||||
|
}
|
||||||
|
makeChart('cSolar', 'line', solarSets, lbls, 'W/m²', { scales: { y: { min: 0 } } });
|
||||||
|
|
||||||
|
// Pressure tendency: change vs 3 hours earlier (what forecasters watch)
|
||||||
|
if (hasPres) {
|
||||||
|
const tend = pts.map(r => {
|
||||||
|
const t = r.dateutc - 3 * 3600000;
|
||||||
|
let lo = 0, hi = pts.length - 1;
|
||||||
|
if (t < pts[0].dateutc) return null;
|
||||||
|
while (lo < hi) { const m = (lo + hi) >> 1; if (pts[m].dateutc < t) lo = m + 1; else hi = m; }
|
||||||
|
const base = pts[lo].baromrelin;
|
||||||
|
return r.baromrelin != null && base != null ? +(r.baromrelin - base).toFixed(3) : null;
|
||||||
|
});
|
||||||
|
const ds = barDataset('3h change', tend, c.cool);
|
||||||
|
ds.backgroundColor = tend.map(v => v == null ? 'transparent' : (v >= 0 ? c.cool + 'bb' : c.accent + 'bb'));
|
||||||
|
ds.borderWidth = 0;
|
||||||
|
makeChart('cPresTend', 'bar', [ds], lbls, 'inHg / 3h');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Indoor vs outdoor
|
||||||
|
if (pts.some(r => r.tempinf != null)) {
|
||||||
|
makeChart('cIndoor', 'line',
|
||||||
|
[lineDataset('Outdoor °F', field(pts, 'tempf'), c.accent),
|
||||||
|
lineDataset('Indoor °F', field(pts, 'tempinf'), c.cool),
|
||||||
|
lineDataset('Indoor humidity %', field(pts, 'humidityin'), c.cool, true)],
|
||||||
|
lbls, null);
|
||||||
|
}
|
||||||
|
|
||||||
// Today bar + sparklines use full readings (not downsampled pts)
|
|
||||||
updateTodayBar(readings);
|
|
||||||
updateSparklines(readings);
|
|
||||||
|
|
||||||
// Wind rose
|
// Wind rose
|
||||||
const roseCanvas = document.getElementById('windRoseCanvas');
|
const roseCanvas = document.getElementById('windRoseCanvas');
|
||||||
@@ -993,7 +1071,8 @@ async function loadHistory(range) {
|
|||||||
const area = document.getElementById('chartsArea');
|
const area = document.getElementById('chartsArea');
|
||||||
area.innerHTML = '<div class="status" id="loadingMsg">Loading…</div>';
|
area.innerHTML = '<div class="status" id="loadingMsg">Loading…</div>';
|
||||||
try {
|
try {
|
||||||
const r = await fetch(`/api/station/history?range=${range}`);
|
const pts = ['7d','30d','90d','1y','2y','3y'].includes(range) ? '&points=720' : '';
|
||||||
|
const r = await fetch(`/api/station/history?range=${range}${pts}`);
|
||||||
if (r.status === 401) { location.href = '/dashboard/login'; return; }
|
if (r.status === 401) { location.href = '/dashboard/login'; return; }
|
||||||
if (!r.ok) throw new Error('Failed');
|
if (!r.ok) throw new Error('Failed');
|
||||||
const data = await r.json();
|
const data = await r.json();
|
||||||
@@ -1001,7 +1080,7 @@ async function loadHistory(range) {
|
|||||||
const line = document.getElementById('updatedLine');
|
const line = document.getElementById('updatedLine');
|
||||||
if (line) {
|
if (line) {
|
||||||
const next = new Date(Date.now() + 120000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
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}`;
|
line.textContent = `${data.length}${data.some(d => d.n) ? ' time buckets' : ' readings'} · pinch / scroll to zoom · double-tap to reset · updated ${new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} · next refresh ${next}`;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
area.innerHTML = `<div class="status error">Failed to load station history. ${e.message}</div>`;
|
area.innerHTML = `<div class="status error">Failed to load station history. ${e.message}</div>`;
|
||||||
@@ -1251,64 +1330,6 @@ if (monthlyPanel) {
|
|||||||
}, { once: false });
|
}, { 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 ─────────────────────────────────────────────────────────
|
// ── Tile color coding ─────────────────────────────────────────────────────────
|
||||||
function tempColorClass(t) {
|
function tempColorClass(t) {
|
||||||
if (t == null) return '';
|
if (t == null) return '';
|
||||||
@@ -1574,5 +1595,6 @@ setInterval(updateExtStats, 300000);
|
|||||||
setInterval(updateTrends, 120000);
|
setInterval(updateTrends, 120000);
|
||||||
setInterval(loadAlerts, 5 * 60 * 1000);
|
setInterval(loadAlerts, 5 * 60 * 1000);
|
||||||
</script>
|
</script>
|
||||||
|
<script src="/dashboard-ui.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
+73
-5
@@ -232,12 +232,22 @@ function applyReadingToRecords(obs) {
|
|||||||
if (obs[k] != null && mergeExtreme(day, field, kind, Number(obs[k]), obs.dateutc)) changed = true;
|
if (obs[k] != null && mergeExtreme(day, field, kind, Number(obs[k]), obs.dateutc)) changed = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (obs.dailyrainin != null && Number.isFinite(Number(obs.dailyrainin)) && !(Number(obs.dailyrainin) <= (day.rain || 0))) {
|
if (obs.dailyrainin != null && Number.isFinite(Number(obs.dailyrainin)) && (day.rain == null || Number(obs.dailyrainin) > day.rain)) {
|
||||||
day.rain = Number(obs.dailyrainin); changed = true;
|
day.rain = Number(obs.dailyrainin); changed = true;
|
||||||
}
|
}
|
||||||
return changed;
|
return changed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Non-idempotent aggregates (sum/count/direction histogram). Applied once per saved reading;
|
||||||
|
// a rebuild recomputes them from the raw files instead of adding on top.
|
||||||
|
function addAggregates(agg, obs) {
|
||||||
|
if (Number.isFinite(Number(obs.tempf)) && obs.tempf != null) { agg.tsum = (agg.tsum || 0) + Number(obs.tempf); agg.tn = (agg.tn || 0) + 1; }
|
||||||
|
if (obs.winddir != null && Number.isFinite(Number(obs.winddir)) && Number(obs.windspeedmph) > 0.5) {
|
||||||
|
const wd = agg.wd || (agg.wd = new Array(16).fill(0));
|
||||||
|
wd[Math.round(Number(obs.winddir) / 22.5) % 16]++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function saveRecordsStore() {
|
function saveRecordsStore() {
|
||||||
if (!recordsDirty) return;
|
if (!recordsDirty) return;
|
||||||
try {
|
try {
|
||||||
@@ -248,9 +258,10 @@ function saveRecordsStore() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function updateRecordsFromReading(obs) {
|
function updateRecordsFromReading(obs) {
|
||||||
const before = (recordsStore.days[stationLocalDay(obs)] || {}).n || 0;
|
|
||||||
applyReadingToRecords(obs);
|
applyReadingToRecords(obs);
|
||||||
recordsStore.days[stationLocalDay(obs)].n = before + 1;
|
const day = recordsStore.days[stationLocalDay(obs)];
|
||||||
|
day.n = (day.n || 0) + 1;
|
||||||
|
addAggregates(day, obs);
|
||||||
recordsDirty = true;
|
recordsDirty = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -259,6 +270,7 @@ function rebuildRecordsFromFiles() {
|
|||||||
let files;
|
let files;
|
||||||
try { files = fs.readdirSync(STATION_DIR).filter(f => f.endsWith('.jsonl')).sort(); } catch { return 0; }
|
try { files = fs.readdirSync(STATION_DIR).filter(f => f.endsWith('.jsonl')).sort(); } catch { return 0; }
|
||||||
const counts = {};
|
const counts = {};
|
||||||
|
const fresh = {};
|
||||||
let scanned = 0;
|
let scanned = 0;
|
||||||
for (const f of files) {
|
for (const f of files) {
|
||||||
try {
|
try {
|
||||||
@@ -271,12 +283,20 @@ function rebuildRecordsFromFiles() {
|
|||||||
applyReadingToRecords(r);
|
applyReadingToRecords(r);
|
||||||
const k = stationLocalDay(r);
|
const k = stationLocalDay(r);
|
||||||
counts[k] = (counts[k] || 0) + 1;
|
counts[k] = (counts[k] || 0) + 1;
|
||||||
|
addAggregates(fresh[k] || (fresh[k] = {}), r);
|
||||||
scanned++;
|
scanned++;
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
// Reading counts come from the files; keep the larger so pruned history isn't forgotten.
|
// Reading counts come from the files; keep the larger so pruned history isn't forgotten.
|
||||||
for (const [k, n] of Object.entries(counts)) recordsStore.days[k].n = Math.max(recordsStore.days[k].n || 0, n);
|
for (const [k, n] of Object.entries(counts)) {
|
||||||
|
const day = recordsStore.days[k];
|
||||||
|
day.n = Math.max(day.n || 0, n);
|
||||||
|
// Replace aggregates only when the files hold at least as many samples as the store did
|
||||||
|
const f = fresh[k];
|
||||||
|
if (f.tn != null && f.tn >= (day.tn || 0)) { day.tsum = f.tsum; day.tn = f.tn; }
|
||||||
|
if (f.wd && (!day.wd || f.wd.reduce((a, b) => a + b, 0) >= day.wd.reduce((a, b) => a + b, 0))) day.wd = f.wd;
|
||||||
|
}
|
||||||
recordsDirty = true;
|
recordsDirty = true;
|
||||||
saveRecordsStore();
|
saveRecordsStore();
|
||||||
return scanned;
|
return scanned;
|
||||||
@@ -1309,12 +1329,42 @@ app.get('/dashboard/settings', requireDashboardAuth, (_req, res) => {
|
|||||||
res.sendFile(path.join(__dirname, 'public', 'dashboard-settings.html'));
|
res.sendFile(path.join(__dirname, 'public', 'dashboard-settings.html'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Downsample into ~`points` time buckets: averages for smooth series, true max/min for
|
||||||
|
// extremes, and a speed-weighted vector mean for wind direction.
|
||||||
|
const BUCKET_AVG = ['tempf', 'feelsLike', 'humidity', 'dewPoint', 'windspeedmph', 'baromrelin', 'baromabsin', 'uv', 'solarradiation', 'tempinf', 'humidityin'];
|
||||||
|
const BUCKET_MAX = ['windgustmph', 'rainratein', 'dailyrainin', 'maxdailygust'];
|
||||||
|
function bucketReadings(readings, points) {
|
||||||
|
const t0 = readings[0].dateutc, span = readings[readings.length - 1].dateutc - t0 || 1;
|
||||||
|
const size = span / points;
|
||||||
|
const buckets = new Map();
|
||||||
|
for (const r of readings) {
|
||||||
|
const i = Math.min(points - 1, Math.floor((r.dateutc - t0) / size));
|
||||||
|
let b = buckets.get(i);
|
||||||
|
if (!b) { b = { n: 0, tsum: 0, sums: {}, cnt: {}, max: {}, tmin: Infinity, tmax: -Infinity, vx: 0, vy: 0 }; buckets.set(i, b); }
|
||||||
|
b.n++; b.tsum += r.dateutc;
|
||||||
|
for (const k of BUCKET_AVG) if (r[k] != null) { b.sums[k] = (b.sums[k] || 0) + r[k]; b.cnt[k] = (b.cnt[k] || 0) + 1; }
|
||||||
|
for (const k of BUCKET_MAX) if (r[k] != null && (b.max[k] == null || r[k] > b.max[k])) b.max[k] = r[k];
|
||||||
|
if (r.tempf != null) { if (r.tempf < b.tmin) b.tmin = r.tempf; if (r.tempf > b.tmax) b.tmax = r.tempf; }
|
||||||
|
if (r.winddir != null) { const w = Math.max(r.windspeedmph || 0, 0.1) * Math.PI / 180; const a = r.winddir * Math.PI / 180; b.vx += Math.sin(a) * (r.windspeedmph || 0.1); b.vy += Math.cos(a) * (r.windspeedmph || 0.1); }
|
||||||
|
}
|
||||||
|
return [...buckets.entries()].sort(([a], [b]) => a - b).map(([, b]) => {
|
||||||
|
const o = { dateutc: Math.round(b.tsum / b.n), n: b.n };
|
||||||
|
for (const k of BUCKET_AVG) if (b.cnt[k]) o[k] = +(b.sums[k] / b.cnt[k]).toFixed(k === 'baromrelin' || k === 'baromabsin' ? 3 : 2);
|
||||||
|
for (const k of BUCKET_MAX) if (b.max[k] != null) o[k] = b.max[k];
|
||||||
|
if (b.tmin !== Infinity) { o.tempMin = b.tmin; o.tempMax = b.tmax; }
|
||||||
|
if (b.vx || b.vy) o.winddir = Math.round((Math.atan2(b.vx, b.vy) * 180 / Math.PI + 360) % 360);
|
||||||
|
return o;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Station history API
|
// Station history API
|
||||||
app.get('/api/station/history', requireDashboardAuth, (req, res) => {
|
app.get('/api/station/history', requireDashboardAuth, (req, res) => {
|
||||||
const rangeMap = { '1h': 3600000, '6h': 21600000, '24h': 86400000, '7d': 604800000, '30d': 2592000000, '90d': 7776000000, '1y': 31536000000, '2y': 63072000000, '3y': 94608000000 };
|
const rangeMap = { '1h': 3600000, '6h': 21600000, '24h': 86400000, '2d': 172800000, '7d': 604800000, '30d': 2592000000, '90d': 7776000000, '1y': 31536000000, '2y': 63072000000, '3y': 94608000000 };
|
||||||
const range = req.query.range || '24h';
|
const range = req.query.range || '24h';
|
||||||
const rangeMs = rangeMap[range] || rangeMap['24h'];
|
const rangeMs = rangeMap[range] || rangeMap['24h'];
|
||||||
const readings = loadStationReadings(rangeMs);
|
const readings = loadStationReadings(rangeMs);
|
||||||
|
const points = parseInt(req.query.points);
|
||||||
|
if (points >= 50 && readings.length > points) return res.json(bucketReadings(readings, points));
|
||||||
res.json(readings);
|
res.json(readings);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1419,6 +1469,24 @@ app.get('/api/station/records', requireDashboardAuth, (_req, res) => {
|
|||||||
res.json(deriveRecords());
|
res.json(deriveRecords());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Per-day summaries (extremes, average temp, rain, wind-direction histogram) for charts/insights
|
||||||
|
app.get('/api/station/daily', requireDashboardAuth, (req, res) => {
|
||||||
|
const limit = Math.min(Math.max(parseInt(req.query.days) || 0, 0), 5000);
|
||||||
|
let entries = Object.entries(recordsStore.days).sort(([a], [b]) => a.localeCompare(b));
|
||||||
|
if (limit) entries = entries.slice(-limit);
|
||||||
|
res.json(entries.map(([d, v]) => ({
|
||||||
|
d, n: v.n || 0,
|
||||||
|
hi: v.hi?.[0] ?? null, hiT: v.hi?.[1] ?? null, lo: v.lo?.[0] ?? null, loT: v.lo?.[1] ?? null,
|
||||||
|
avg: v.tn ? +(v.tsum / v.tn).toFixed(1) : null,
|
||||||
|
gust: v.gust?.[0] ?? null, gustT: v.gust?.[1] ?? null,
|
||||||
|
rain: v.rain ?? null, rate: v.rate?.[0] ?? null,
|
||||||
|
uv: v.uv?.[0] ?? null, solar: v.solar?.[0] ?? null,
|
||||||
|
pmax: v.pmax?.[0] ?? null, pmin: v.pmin?.[0] ?? null,
|
||||||
|
hmax: v.hmax?.[0] ?? null, hmin: v.hmin?.[0] ?? null,
|
||||||
|
wd: v.wd || null,
|
||||||
|
})));
|
||||||
|
});
|
||||||
|
|
||||||
// Re-scan raw readings into the records store (merge only; never lowers a record)
|
// Re-scan raw readings into the records store (merge only; never lowers a record)
|
||||||
app.post('/api/station/records/rebuild', requireDashboardAuth, (_req, res) => {
|
app.post('/api/station/records/rebuild', requireDashboardAuth, (_req, res) => {
|
||||||
const scanned = rebuildRecordsFromFiles();
|
const scanned = rebuildRecordsFromFiles();
|
||||||
|
|||||||
Reference in new issue
Block a user