- New Station tab (top tabs, bottom nav, swipe order) carrying the old /conditions look: radar-ring hero, condition pill, wind compass, atmosphere/precip/solar cards, indoor - Scoped sl-* styles and station-live.js; units follow the app's metric/imperial setting - Empty/error states when no station is connected - /conditions now 301-redirects to /?tab=station; removed conditions.html, /api/conditions/public and the old My Station drawer - /api/ambient-config also returns the station name - Dashboard and settings links updated; SW cache v8; manifest shortcut for Station - Drops an indoor-temp block in applyStationOverlay that referenced an out-of-scope helper Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
244 lines
12 KiB
JavaScript
244 lines
12 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>
|
|
<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;
|
|
|
|
function ensureShell() {
|
|
if (shellReady) return;
|
|
root.innerHTML = SHELL;
|
|
shellReady = true;
|
|
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);
|
|
|
|
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', 'Add your Ambient Weather API keys in Settings to see live readings from your own station here.');
|
|
} 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>';
|
|
})();
|