Files
weather/app/public/station-live.js
T
Nate EmmertandClaude Sonnet 5.5 dde59e3865
Publish Container / publish (push) Successful in 2m13s
Station tab: explain what the station is
- 'What is this station?' card: a personal weather station measuring real conditions on site
  (not a forecast), how it differs from the Now tab, update cadence, what Live/Offline mean
- Lists the instruments this station actually reports (temp/humidity, wind, rain, pressure,
  sun, indoor), using the station's real name
- Open for new visitors, remembers once closed; empty state and Now-tab badge/tab hover explain it too

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 15:16:33 -04:00

284 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* Station tab: live readings from the personal weather station, in the former /conditions look.
app.js calls renderStationTab(device) whenever new station data arrives. */
(() => {
'use strict';
const root = document.getElementById('station-live');
if (!root) return;
const cardinal = (deg) => (deg == null ? '—' : ['N','NNE','NE','ENE','E','ESE','SE','SSE','S','SSW','SW','WSW','W','WNW','NW','NNW'][Math.round(+deg / 22.5) % 16]);
const metric = () => typeof state !== 'undefined' && state.units !== 'us';
const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
const fTemp = (f) => (num(f) == null ? '—' : String(Math.round(metric() ? (f - 32) * 5 / 9 : f)));
const fSpeed = (m) => (num(m) == null ? '—' : String(Math.round(metric() ? m * 1.60934 : m)));
const fRain = (i) => (num(i) == null ? '—' : metric() ? (i * 25.4).toFixed(1) : i.toFixed(2));
const fPres = (i) => (num(i) == null ? '—' : metric() ? String(Math.round(i * 33.8639)) : i.toFixed(2));
const units = () => ({ t: metric() ? '°C' : '°F', s: metric() ? 'km/h' : 'mph', r: metric() ? 'mm' : 'in', p: metric() ? 'hPa' : 'inHg' });
function tempColor(f) {
if (f == null) return 'var(--sl-text)';
if (f <= 32) return '#93c5fd';
if (f <= 50) return '#60a5fa';
if (f <= 65) return 'var(--sl-text)';
if (f <= 80) return '#f59e0b';
if (f <= 90) return '#f97316';
return '#ef4444';
}
function uvInfo(uv) {
if (uv == null) return ['', 'var(--sl-text)'];
if (uv < 3) return ['Low', '#4ade80'];
if (uv < 6) return ['Moderate', '#facc15'];
if (uv < 8) return ['High', '#f97316'];
if (uv < 11) return ['Very High', '#ef4444'];
return ['Extreme', '#d946ef'];
}
function conditionText(d) {
if (!d) return '—';
if (d.rainratein > 0.05) return '🌧 Raining';
const s = d.solarradiation;
if (s != null) {
if (s > 700) return '☀️ Sunny';
if (s > 250) return '🌤 Partly Cloudy';
if (s > 60) return '⛅ Mostly Cloudy';
if (s > 5) return '☁️ Overcast';
}
return '🌙 Night';
}
const SHELL = `
<div class="sl-bar">
<span class="sl-name" id="sl-name">My Station</span>
<span class="sl-live" id="sl-live"><span class="sl-dot"></span><span id="sl-live-text">Live</span></span>
<span><span class="sl-time" id="sl-time"></span></span>
</div>
<details class="sl-about" id="sl-about">
<summary><span class="sl-about-ico">ⓘ</span><span>What is this station?</span><span class="sl-about-chev">›</span></summary>
<div class="sl-about-body">
<p><b id="sl-about-name">This station</b> is a <b>personal weather station</b>: real instruments on the owner's property that measure the weather <i>right here, right now</i>. These numbers are measurements, not a forecast or a computer model.</p>
<p class="sl-about-h">What it measures</p>
<ul id="sl-sensors" class="sl-sensors"><li>Waiting for the first reading…</li></ul>
<p class="sl-about-h">How it fits with the rest of the app</p>
<ul class="sl-sensors">
<li><b>Now</b> shows a forecast model for the city you picked. <b>Station</b> shows what one spot is actually measuring.</li>
<li>When you see <b>📡 Your station</b> on the Now tab, its temperature, wind and rain come from this station.</li>
<li>It reports about every minute or two. <b>Live</b> means the last reading is under 15 minutes old; <b>Offline</b> means older.</li>
<li>Because it's one spot, it can differ a little from the airport or the forecast, and that's normal.</li>
</ul>
<p class="sl-about-foot">History, records and charts, plus a side-by-side with the official National Weather Service station, are on the <a href="/dashboard">Station Dashboard</a> (sign-in required).</p>
</div>
</details>
<section class="sl-hero">
<div class="sl-hero-dots"></div><div class="sl-hero-sweep"></div><div class="sl-hero-ring"></div><div class="sl-hero-ring2"></div>
<div class="sl-hero-body">
<div class="sl-temp-row"><div class="sl-temp-num" id="sl-temp">—</div><div class="sl-temp-deg" id="sl-deg"></div></div>
<div class="sl-hero-sub" id="sl-sub"></div>
<div><span class="sl-pill" id="sl-pill">Loading…</span></div>
</div>
</section>
<div class="sl-section"><div class="sl-label">Atmosphere</div>
<div class="sl-row3">
<div class="sl-card"><div class="sl-card-label">Humidity</div><div class="sl-card-val" id="sl-hum">—</div><div class="sl-card-unit">%</div></div>
<div class="sl-card"><div class="sl-card-label">Dew Point</div><div class="sl-card-val" id="sl-dew">—</div><div class="sl-card-unit" id="sl-dew-u">°F</div></div>
<div class="sl-card"><div class="sl-card-label">Pressure</div><div class="sl-card-val" id="sl-pres">—</div><div class="sl-card-unit" id="sl-pres-u">inHg</div></div>
</div>
</div>
<div class="sl-section"><div class="sl-label">Wind</div>
<div class="sl-wind">
<canvas class="sl-compass" id="sl-compass" width="152" height="152" style="width:152px;height:152px"></canvas>
<div class="sl-wind-grid">
<div><div class="sl-wind-label">Speed</div><div class="sl-wind-val" id="sl-ws">—</div><div class="sl-wind-unit sl-su">mph</div></div>
<div><div class="sl-wind-label">Gust</div><div class="sl-wind-val" id="sl-wg">—</div><div class="sl-wind-unit sl-su">mph</div></div>
<div><div class="sl-wind-label">From</div><div class="sl-wind-val" id="sl-wd">—</div><div class="sl-wind-unit" id="sl-wdeg"></div></div>
<div><div class="sl-wind-label">Max Today</div><div class="sl-wind-val" id="sl-wm">—</div><div class="sl-wind-unit sl-su">mph</div></div>
</div>
</div>
</div>
<div class="sl-section"><div class="sl-label">Precipitation &amp; Solar</div>
<div class="sl-row4">
<div class="sl-card"><div class="sl-card-label">Rain Today</div><div class="sl-card-val" id="sl-rain">—</div><div class="sl-card-unit sl-ru">in</div></div>
<div class="sl-card"><div class="sl-card-label">Rain Rate</div><div class="sl-card-val" id="sl-rate">—</div><div class="sl-card-unit sl-rru">in / hr</div></div>
<div class="sl-card"><div class="sl-card-label">UV Index</div><div class="sl-card-val" id="sl-uv">—</div><div class="sl-card-unit" id="sl-uvl"></div></div>
<div class="sl-card"><div class="sl-card-label">Solar Rad.</div><div class="sl-card-val" id="sl-solar">—</div><div class="sl-card-unit">W/m²</div></div>
</div>
</div>
<div class="sl-section" id="sl-indoor-sec" hidden><div class="sl-label">Indoor</div>
<div class="sl-row2">
<div class="sl-card"><div class="sl-card-label">Temperature</div><div class="sl-card-val" id="sl-tin">—</div><div class="sl-card-unit sl-tu">°F</div></div>
<div class="sl-card"><div class="sl-card-label">Humidity</div><div class="sl-card-val" id="sl-hin">—</div><div class="sl-card-unit">%</div></div>
</div>
</div>
<div class="sl-foot"><span id="sl-foot">Refreshes automatically every minute</span> · <a class="sl-link" href="/dashboard">Dashboard →</a></div>`;
const EMPTY = (title, text) => `
<div class="sl-empty"><b>${title}</b>${text}<div><button type="button" id="sl-goto-settings">Open Settings</button></div></div>`;
const $ = (id) => document.getElementById(id);
const set = (id, v) => { const el = $(id); if (el) el.textContent = v; };
const setAll = (sel, v) => root.querySelectorAll(sel).forEach((el) => { el.textContent = v; });
let shellReady = false;
let lastDir = null;
// "What is this station?" starts open for new visitors and stays closed once dismissed
function initAbout() {
const d = $('sl-about');
if (!d) return;
let closed = false; try { closed = localStorage.getItem('stationAboutClosed') === '1'; } catch {}
d.open = !closed;
d.addEventListener('toggle', () => { try { localStorage.setItem('stationAboutClosed', d.open ? '0' : '1'); } catch {} });
}
function ensureShell() {
if (shellReady) return;
root.innerHTML = SHELL;
shellReady = true;
initAbout();
drawCompass(null, null);
}
function showMessage(title, text) {
shellReady = false;
root.innerHTML = EMPTY(title, text);
$('sl-goto-settings')?.addEventListener('click', () => { if (typeof activateTab === 'function') activateTab('settings'); });
}
function drawCompass(dir, speedText) {
const canvas = $('sl-compass');
if (!canvas) return;
const dpr = Math.min(window.devicePixelRatio || 1, 2);
const size = 152;
canvas.width = size * dpr; canvas.height = size * dpr;
const ctx = canvas.getContext('2d');
ctx.scale(dpr, dpr);
const cs = getComputedStyle(root);
const v = (n, f) => cs.getPropertyValue(n).trim() || f;
const cCard = v('--sl-surface', '#fff'), cRing = v('--sl-border', '#ccd6e2'), cLabel = v('--sl-muted', '#5a7089');
const cAccent = v('--sl-accent', '#2563eb'), cNorth = v('--sl-north', '#dc2626');
const cx = size / 2, cy = size / 2, r = size / 2 - 7;
ctx.fillStyle = cCard; ctx.beginPath(); ctx.arc(cx, cy, r + 5, 0, Math.PI * 2); ctx.fill();
[1 / 3, 2 / 3, 1].forEach((f) => { ctx.beginPath(); ctx.arc(cx, cy, r * f, 0, Math.PI * 2); ctx.strokeStyle = cRing; ctx.lineWidth = 0.7; ctx.stroke(); });
for (let i = 0; i < 8; i++) {
const a = ((i * 45 - 90) * Math.PI) / 180, major = i % 2 === 0, len = major ? 9 : 5;
ctx.beginPath(); ctx.moveTo(cx + r * Math.cos(a), cy + r * Math.sin(a));
ctx.lineTo(cx + (r - len) * Math.cos(a), cy + (r - len) * Math.sin(a));
ctx.strokeStyle = major ? cLabel : cRing; ctx.lineWidth = major ? 1.5 : 0.7; ctx.stroke();
}
[['N', -90, cNorth], ['E', 0, cLabel], ['S', 90, cLabel], ['W', 180, cLabel]].forEach(([l, deg, color]) => {
const a = (deg * Math.PI) / 180;
ctx.font = `700 ${l === 'N' ? 11 : 10}px 'Barlow', sans-serif`;
ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillStyle = color;
ctx.fillText(l, cx + (r - 18) * Math.cos(a), cy + (r - 18) * Math.sin(a));
});
if (dir != null) {
const a = ((+dir - 90) * Math.PI) / 180, tip = r * 0.6;
const tx = cx + tip * Math.cos(a), ty = cy + tip * Math.sin(a);
ctx.strokeStyle = cAccent; ctx.lineWidth = 2; ctx.lineCap = 'round';
ctx.beginPath(); ctx.moveTo(cx, cy); ctx.lineTo(tx, ty); ctx.stroke();
const ha = Math.atan2(ty - cy, tx - cx), hl = 13;
ctx.fillStyle = cAccent; ctx.beginPath(); ctx.moveTo(tx, ty);
ctx.lineTo(tx - hl * Math.cos(ha - 0.38), ty - hl * Math.sin(ha - 0.38));
ctx.lineTo(tx - hl * 0.55 * Math.cos(ha), ty - hl * 0.55 * Math.sin(ha));
ctx.lineTo(tx - hl * Math.cos(ha + 0.38), ty - hl * Math.sin(ha + 0.38));
ctx.closePath(); ctx.fill();
const ta = a + Math.PI;
ctx.globalAlpha = 0.3; ctx.lineWidth = 1.5; ctx.strokeStyle = cAccent;
ctx.beginPath(); ctx.moveTo(cx, cy); ctx.lineTo(cx + r * 0.22 * Math.cos(ta), cy + r * 0.22 * Math.sin(ta)); ctx.stroke();
ctx.globalAlpha = 1;
ctx.fillStyle = cAccent; ctx.beginPath(); ctx.arc(cx, cy, 3, 0, Math.PI * 2); ctx.fill();
}
}
window.renderStationTab = function renderStationTab(device) {
const d = device?.lastData;
if (!d) return;
ensureShell();
const u = units();
const name = (typeof state !== 'undefined' && state.stationName) || device.info?.name || 'My Station';
set('sl-name', name);
// Describe the instruments this particular station really has
set('sl-about-name', name);
const has = (...k) => k.some((f) => d[f] != null);
const sensors = [
has('tempf', 'humidity') && '🌡 <b>Temperature &amp; humidity</b>: a shaded outdoor sensor',
has('windspeedmph', 'winddir', 'windgustmph') && '💨 <b>Wind</b>: speed, gusts and direction (anemometer and wind vane)',
has('dailyrainin', 'rainratein') && '🌧 <b>Rain</b>: a rain gauge that tips with each bit of rain',
has('baromrelin') && '🧭 <b>Pressure</b>: a barometer (falling pressure often means weather is coming)',
has('uv', 'solarradiation') && '☀️ <b>Sun</b>: UV and solar-radiation sensors',
has('tempinf', 'humidityin') && '🏠 <b>Indoor</b>: a sensor inside the house',
].filter(Boolean);
if (sensors.length) $('sl-sensors').innerHTML = sensors.map((x) => `<li>${x}</li>`).join('');
const fresh = d.dateutc && Date.now() - d.dateutc < 15 * 60 * 1000;
const live = $('sl-live');
live.classList.toggle('stale', !fresh);
set('sl-live-text', fresh ? 'Live' : 'Offline');
if (d.dateutc) {
const t = new Date(d.dateutc).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
set('sl-time', t);
set('sl-foot', `Updated ${t} · refreshes automatically`);
}
const tempEl = $('sl-temp'), degEl = $('sl-deg');
tempEl.textContent = fTemp(d.tempf);
degEl.textContent = d.tempf != null ? u.t : '';
const col = tempColor(d.tempf);
tempEl.style.color = col; degEl.style.color = col;
const fl = d.feelsLike ?? d.tempf;
set('sl-sub', fl != null ? `Feels like ${fTemp(fl)}${u.t}` : '');
set('sl-pill', fresh ? conditionText(d) : 'Station offline');
set('sl-hum', d.humidity != null ? String(Math.round(d.humidity)) : '—');
set('sl-dew', fTemp(d.dewPoint)); set('sl-dew-u', u.t);
set('sl-pres', fPres(d.baromrelin)); set('sl-pres-u', u.p);
set('sl-ws', fSpeed(d.windspeedmph)); set('sl-wg', fSpeed(d.windgustmph)); set('sl-wm', fSpeed(d.maxdailygust));
set('sl-wd', cardinal(d.winddir)); set('sl-wdeg', d.winddir != null ? Math.round(+d.winddir) + '°' : '');
setAll('.sl-su', u.s);
lastDir = d.winddir ?? null;
drawCompass(lastDir);
set('sl-rain', fRain(d.dailyrainin)); set('sl-rate', fRain(d.rainratein));
setAll('.sl-ru', u.r); setAll('.sl-rru', `${u.r} / hr`);
const [uvLabel, uvColor] = uvInfo(d.uv);
set('sl-uv', d.uv != null ? String(Math.round(d.uv)) : '—');
$('sl-uv').style.color = uvColor; set('sl-uvl', uvLabel);
set('sl-solar', d.solarradiation != null ? String(Math.round(d.solarradiation)) : '—');
const sec = $('sl-indoor-sec');
if (d.tempinf != null) {
sec.hidden = false;
set('sl-tin', fTemp(d.tempinf)); setAll('.sl-tu', u.t);
set('sl-hin', d.humidityin != null ? String(Math.round(d.humidityin)) : '—');
} else sec.hidden = true;
};
// Called by app.js when the station can't be reached / isn't configured
window.stationTabMessage = function (kind, detail) {
if (shellReady) {
// Keep showing the last readings but flag them as stale
const live = $('sl-live'); if (live) { live.classList.add('stale'); set('sl-live-text', 'Offline'); }
return;
}
if (kind === 'unconfigured') {
showMessage('No station connected', 'This tab shows live readings from a <b>personal weather station</b> (a real device on someone\'s property that measures conditions on the spot, not a forecast). Add your Ambient Weather API keys in Settings to connect yours.');
} else {
showMessage('Station unavailable', detail || 'Could not reach your station right now. It will keep retrying.');
}
};
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => shellReady && drawCompass(lastDir));
new MutationObserver(() => shellReady && drawCompass(lastDir)).observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
root.innerHTML = '<div class="sl-empty"><b>Loading…</b>Fetching your station readings.</div>';
})();