Publish Container / publish (push) Successful in 2m13s
- '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>
284 lines
15 KiB
JavaScript
284 lines
15 KiB
JavaScript
/* 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 & 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 & 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>';
|
||
})();
|