Log official NWS observations for a reference ZIP and overlay them on the dashboard
Publish Container / publish (push) Successful in 2m33s
Publish Container / publish (push) Successful in 2m33s
- Resolves ZIP (default 24550) to the nearest NWS observation station; logs every observation (normalised imperial values + raw METAR), back-fills ~7 days, snapshots the hourly forecast every 3h, and logs alerts for that point - Chart overlay (temp, humidity/dew point, wind, pressure) with an NWS toggle in the History bar - 'Official NWS observation' card on the Now tab comparing against your station - Settings: change the reference ZIP and see logging status - Exact-unit conversions for NWS values (verified against known conversions) Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
5ed673bacc
commit
acdcd6a2c5
5 files changed
+364
-20
No files matched your search
+55
-15
@@ -536,7 +536,7 @@ 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 id="sunHealthRow" class="two-col"><div id="sunCard"></div><div id="healthCard"></div><div id="nwsCard" style="grid-column:1/-1"></div></div>
|
||||
</div><!-- /now -->
|
||||
|
||||
<div class="tab-pane" data-pane="charts">
|
||||
@@ -555,6 +555,7 @@ input, select, textarea { font-size: 16px; } /* stops iOS zooming in on focus */
|
||||
<button class="range-btn" data-range="all" title="Everything recorded so far">All</button>
|
||||
<button class="refresh-btn" id="refreshBtn">↻ Refresh</button>
|
||||
<button class="range-btn" id="yoyBtn" style="display:none" title="Overlay same period from 1 year ago">YoY</button>
|
||||
<button class="range-btn nws-toggle" id="nwsBtn" title="Overlay official NWS observations for your reference ZIP">NWS overlay</button>
|
||||
</div>
|
||||
<div class="updated" id="updatedLine"></div>
|
||||
|
||||
@@ -846,8 +847,35 @@ function initPresTend(pagePts, pageRange) {
|
||||
drawPresTend(pagePts, pageRange);
|
||||
}
|
||||
|
||||
// ── NWS overlay (official observations for the reference ZIP) ─────────────────
|
||||
let nwsOverlay = true;
|
||||
try { nwsOverlay = localStorage.getItem('nwsOverlay') !== '0'; } catch {}
|
||||
const NWS_COLOR = '#a78bfa';
|
||||
|
||||
// Map sparse NWS observations onto the chart's points (averaging when several land on one point)
|
||||
function nwsAlign(pts, nws, key) {
|
||||
if (!nws || !nws.length || !pts.length) return null;
|
||||
const sum = new Array(pts.length).fill(0), cnt = new Array(pts.length).fill(0);
|
||||
const span = pts.length > 1 ? (pts[pts.length - 1].dateutc - pts[0].dateutc) / (pts.length - 1) : 3600000;
|
||||
const tol = Math.max(span / 2, 35 * 60000);
|
||||
for (const o of nws) {
|
||||
const v = o[key]; if (v == null) continue;
|
||||
let lo = 0, hi = pts.length - 1;
|
||||
while (lo < hi) { const m = (lo + hi) >> 1; if (pts[m].dateutc < o.ts) lo = m + 1; else hi = m; }
|
||||
let idx = lo;
|
||||
if (idx > 0 && Math.abs(pts[idx - 1].dateutc - o.ts) < Math.abs(pts[idx].dateutc - o.ts)) idx--;
|
||||
if (Math.abs(pts[idx].dateutc - o.ts) > tol) continue;
|
||||
sum[idx] += v; cnt[idx]++;
|
||||
}
|
||||
const out = sum.map((t, i) => (cnt[i] ? +(t / cnt[i]).toFixed(2) : null));
|
||||
return out.some(v => v != null) ? out : null;
|
||||
}
|
||||
function nwsDataset(label, data, dashed) {
|
||||
return { label, data, borderColor: NWS_COLOR, backgroundColor: NWS_COLOR, borderWidth: 1.6, borderDash: dashed ? [4, 3] : [], pointRadius: 3, pointHoverRadius: 5, tension: 0, spanGaps: true, fill: false };
|
||||
}
|
||||
|
||||
// ── Render charts ────────────────────────────────────────────────────────────
|
||||
function renderCharts(readings, range, yoy = null) {
|
||||
function renderCharts(readings, range, yoy = null, nws = null) {
|
||||
const area = document.getElementById('chartsArea');
|
||||
if (!readings.length) {
|
||||
area.innerHTML = '<div class="status">No recorded data for this range yet. Data is collected every minute while the station is active.</div>';
|
||||
@@ -953,28 +981,28 @@ function renderCharts(readings, range, yoy = null) {
|
||||
}
|
||||
tempDatasets.push(lineDataset('Temp °F', field(pts, 'tempf'), c.accent));
|
||||
tempDatasets.push(lineDataset('Feels Like', field(pts, 'feelsLike'), c.cool, true));
|
||||
const nwsLab = nws && nws.length ? 'NWS ' + (nws[nws.length - 1].station || '') : 'NWS';
|
||||
const addNws = (sets, key, suffix, dashed) => { const a = nwsAlign(pts, nws, key); if (a) sets.push(nwsDataset(`${nwsLab} ${suffix}`, a, dashed)); };
|
||||
addNws(tempDatasets, 'tempf', 'temp');
|
||||
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', { plugins: { legend: { labels: { filter: i => !['Max', 'Min'].includes(i.text) } } } });
|
||||
|
||||
makeChart('cHum', 'line',
|
||||
[lineDataset('Humidity %', field(pts, 'humidity'), c.cool),
|
||||
lineDataset('Dew Point °F', field(pts, 'dewPoint'), c.accent, true)],
|
||||
lbls, null);
|
||||
const humSets = [lineDataset('Humidity %', field(pts, 'humidity'), c.cool), lineDataset('Dew Point °F', field(pts, 'dewPoint'), c.accent, true)];
|
||||
addNws(humSets, 'humidity', 'humidity'); addNws(humSets, 'dewPoint', 'dew pt', true);
|
||||
makeChart('cHum', 'line', humSets, lbls, null);
|
||||
|
||||
makeChart('cWind', 'line',
|
||||
[lineDataset('Wind mph', field(pts, 'windspeedmph'), c.cool),
|
||||
lineDataset('Gust mph', field(pts, 'windgustmph'), c.accent, true)],
|
||||
lbls, 'mph');
|
||||
const windSets = [lineDataset('Wind mph', field(pts, 'windspeedmph'), c.cool), lineDataset('Gust mph', field(pts, 'windgustmph'), c.accent, true)];
|
||||
addNws(windSets, 'windspeedmph', 'wind'); addNws(windSets, 'windgustmph', 'gust', true);
|
||||
makeChart('cWind', 'line', windSets, lbls, 'mph');
|
||||
|
||||
const hasPres = pts.some(r => r.baromrelin != null);
|
||||
if (hasPres) {
|
||||
makeChart('cPres', 'line',
|
||||
[lineDataset('Rel. Pressure', field(pts, 'baromrelin'), c.accent),
|
||||
lineDataset('Abs. Pressure', field(pts, 'baromabsin'), c.cool, true)],
|
||||
lbls, 'inHg');
|
||||
const presSets = [lineDataset('Rel. Pressure', field(pts, 'baromrelin'), c.accent), lineDataset('Abs. Pressure', field(pts, 'baromabsin'), c.cool, true)];
|
||||
addNws(presSets, 'baromrelin', 'pressure');
|
||||
makeChart('cPres', 'line', presSets, lbls, 'inHg');
|
||||
}
|
||||
|
||||
makeChart('cRainRate', 'bar',
|
||||
@@ -1135,11 +1163,12 @@ async function loadHistory(range) {
|
||||
area.innerHTML = '<div class="status" id="loadingMsg">Loading…</div>';
|
||||
try {
|
||||
const pts = ['7d','30d','90d','1y','2y','3y','all'].includes(range) ? '&points=720' : '';
|
||||
const nwsP = nwsOverlay ? fetch(`/api/nws/history?range=${range}`).then(x => (x.ok ? x.json() : [])).catch(() => []) : Promise.resolve([]);
|
||||
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();
|
||||
renderCharts(data, range, yoyEnabled ? yoyData : null);
|
||||
renderCharts(data, range, yoyEnabled ? yoyData : null, await nwsP);
|
||||
const line = document.getElementById('updatedLine');
|
||||
if (line) {
|
||||
const next = new Date(Date.now() + 120000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
@@ -1333,6 +1362,17 @@ async function loadRecords() {
|
||||
let yoyEnabled = false;
|
||||
let yoyData = null;
|
||||
|
||||
const nwsBtn = document.getElementById('nwsBtn');
|
||||
if (nwsBtn) {
|
||||
nwsBtn.classList.toggle('active', nwsOverlay);
|
||||
nwsBtn.addEventListener('click', () => {
|
||||
nwsOverlay = !nwsOverlay;
|
||||
try { localStorage.setItem('nwsOverlay', nwsOverlay ? '1' : '0'); } catch {}
|
||||
nwsBtn.classList.toggle('active', nwsOverlay);
|
||||
loadHistory(currentRange);
|
||||
});
|
||||
}
|
||||
|
||||
const yoyBtn = document.getElementById('yoyBtn');
|
||||
yoyBtn.addEventListener('click', async () => {
|
||||
yoyEnabled = !yoyEnabled;
|
||||
|
||||
Reference in new issue
Block a user