/* 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)); // Tabs replace the collapsible panels: keep every panel open (their lazy loaders fire on toggle) document.querySelectorAll('details.settings-panel').forEach((d) => { d.classList.add('always-open'); d.querySelector('summary')?.addEventListener('click', (e) => e.preventDefault()); d.open = true; }); // 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 = '
'; 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 `Set your station location in Settings to see sunrise, sunset, day length and moon phase.
No sunrise/sunset at this latitude today.
'; 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 = ``; const delta = stTomorrow ? (stTomorrow.set - stTomorrow.rise) - (st.set - st.rise) : null; body = `Needs at least 14 recorded days to compare periods.
'; 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 = `Last ${W} days vs the ${W} before
${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 = `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.
`; 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 = `This month vs ${years.length}-year station average
${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 = `Share of readings with measurable wind, by direction it blows from.
Wind direction history builds up as new readings are saved.
'; cards.forEach((x) => { const div = document.createElement('div'); const top = x.b.indexOf(Math.max(...x.b)); div.innerHTML = `${x.name} · from ${cardinal(top * 22.5)} (${Math.round((x.b[top] / x.total) * 100)}%)`; roses.appendChild(div); drawRose(div.querySelector('canvas'), x.b); }); } // ── Who's signed in: role-aware header links ───────────────────────────────── (async function roleAwareHeader() { try { const me = await getJSON('/api/dashboard/me'); const right = document.querySelector('.header-right'); if (!right) return; const settings = right.querySelector('a[href="/dashboard/settings"]'); if (me.role !== 'admin' && settings) settings.remove(); const acct = document.createElement('a'); acct.href = '/dashboard/account'; acct.className = 'logout'; acct.textContent = me.role === 'admin' ? 'Account & Users' : 'Account'; acct.title = `Signed in as ${me.username} (${me.role})`; (settings && me.role === 'admin' ? settings : right.querySelector('a[href="/dashboard/logout"]'))?.before(acct); const nm = $('stationName'); if (nm && !nm.textContent) nm.textContent = me.username; window.DASH_USER = me; } catch {} })(); // ── 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(); })();