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
+100
-78
@@ -13,6 +13,9 @@
|
||||
<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>
|
||||
<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>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
:root {
|
||||
@@ -388,10 +391,20 @@ input, select, textarea { font-size: 16px; } /* stops iOS zooming in on focus */
|
||||
</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="offline-banner" id="offlineBanner"></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 -->
|
||||
<div class="section-card">
|
||||
<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 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 -->
|
||||
<div class="range-bar">
|
||||
<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>
|
||||
|
||||
</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 -->
|
||||
<details class="settings-panel" id="recordsPanel" style="margin-top:1.5rem">
|
||||
<summary>All-Time Station Records</summary>
|
||||
@@ -559,14 +581,12 @@ input, select, textarea { font-size: 16px; } /* stops iOS zooming in on focus */
|
||||
</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>
|
||||
<div id="calendarCard" class="section-card" style="margin-top:1rem"><div class="status">Loading calendar…</div></div>
|
||||
|
||||
</div><!-- /records -->
|
||||
|
||||
<div class="tab-pane" data-pane="insights" id="insightsTab">
|
||||
<div id="insightsBody"><div class="status">Loading insights…</div></div>
|
||||
<!-- Alert History -->
|
||||
<details class="settings-panel" id="alertHistoryPanel" style="margin-top:1rem">
|
||||
<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>
|
||||
</details>
|
||||
</div><!-- /insights -->
|
||||
|
||||
</div>
|
||||
|
||||
@@ -626,6 +647,10 @@ function makeChart(canvasId, type, datasets, labels, yLabel, opts = {}) {
|
||||
maintainAspectRatio: false,
|
||||
interaction: { mode: 'index', intersect: false },
|
||||
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 } },
|
||||
tooltip: {
|
||||
backgroundColor: 'rgba(0,0,0,0.75)',
|
||||
@@ -650,9 +675,23 @@ function makeChart(canvasId, type, datasets, labels, yLabel, opts = {}) {
|
||||
|
||||
const mergedOpts = deepMerge(defaults, opts);
|
||||
charts[canvasId] = new Chart(ctx, { type, data: { labels, datasets }, options: mergedOpts });
|
||||
ctx.ondblclick = () => { charts[canvasId].resetZoom(); markZoomed(charts[canvasId], false); };
|
||||
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) {
|
||||
const out = { ...a };
|
||||
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>
|
||||
|
||||
<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 -->
|
||||
<div class="chart-card">
|
||||
<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>
|
||||
|
||||
${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>
|
||||
`;
|
||||
|
||||
const tempDatasets = [
|
||||
lineDataset('Temp °F', field(pts, 'tempf'), c.accent),
|
||||
lineDataset('Feels Like', field(pts, 'feelsLike'), c.cool, true),
|
||||
];
|
||||
const tempDatasets = [];
|
||||
if (pts.some(r => r.tempMax != null)) {
|
||||
// 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) {
|
||||
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('cTemp', 'line', tempDatasets, lbls, '°F', { plugins: { legend: { labels: { filter: i => !['Max', 'Min'].includes(i.text) } } } });
|
||||
|
||||
makeChart('cHum', 'line',
|
||||
[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)],
|
||||
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 } } });
|
||||
const solarSets = [lineDataset('Solar W/m²', field(pts, 'solarradiation'), c.accent)];
|
||||
if (typeof clearSkyGHI === 'function' && ['1h', '6h', '24h', '2d', '7d'].includes(range) && window.STATION_LOC) {
|
||||
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
|
||||
const roseCanvas = document.getElementById('windRoseCanvas');
|
||||
@@ -993,7 +1071,8 @@ 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}`);
|
||||
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.ok) throw new Error('Failed');
|
||||
const data = await r.json();
|
||||
@@ -1001,7 +1080,7 @@ async function loadHistory(range) {
|
||||
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}`;
|
||||
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) {
|
||||
area.innerHTML = `<div class="status error">Failed to load station history. ${e.message}</div>`;
|
||||
@@ -1251,64 +1330,6 @@ if (monthlyPanel) {
|
||||
}, { 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 '';
|
||||
@@ -1574,5 +1595,6 @@ setInterval(updateExtStats, 300000);
|
||||
setInterval(updateTrends, 120000);
|
||||
setInterval(loadAlerts, 5 * 60 * 1000);
|
||||
</script>
|
||||
<script src="/dashboard-ui.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in new issue
Block a user