Merge /conditions into the app as a Station tab
- 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>
This commit is contained in:
1 parent
7dea2734ea
commit
1830fb2e2b
11 files changed
+361
-917
No files matched your search
+10
-89
@@ -2658,7 +2658,7 @@ syncOfflineState();
|
||||
|
||||
// ── Swipe gestures for tab switching ──────────────────────────────────────────
|
||||
(function initSwipe() {
|
||||
const TABS = ['conditions', 'unified-map', 'alerts', 'learn', 'settings'];
|
||||
const TABS = ['conditions', 'station', 'unified-map', 'alerts', 'learn', 'settings'];
|
||||
let touchX = 0;
|
||||
let touchY = 0;
|
||||
let blocked = false;
|
||||
@@ -2813,7 +2813,9 @@ if (ambientSaveBtn) {
|
||||
}
|
||||
|
||||
// On load, check if server already has keys configured (other device saved them)
|
||||
fetch('/api/ambient-config').then(r => r.json()).then(({ configured }) => {
|
||||
fetch('/api/ambient-config').then(r => r.json()).then(({ configured, stationName }) => {
|
||||
if (stationName) state.stationName = stationName;
|
||||
if (!configured && !state.ambientApiKey && typeof window.stationTabMessage === 'function') window.stationTabMessage('unconfigured');
|
||||
if (configured && !state.ambientApiKey) {
|
||||
if (ambientStatusEl) { ambientStatusEl.textContent = '✓ Keys configured on server'; show(ambientStatusEl); }
|
||||
fetchAmbientStation();
|
||||
@@ -2968,10 +2970,7 @@ if (pwsSaveBtn) {
|
||||
let stationRefreshTimer = null;
|
||||
|
||||
async function fetchAmbientStation() {
|
||||
const loadingEl = document.getElementById('station-loading');
|
||||
const contentEl = document.getElementById('station-content');
|
||||
const errorEl = document.getElementById('station-error');
|
||||
const peekEl = document.getElementById('station-drawer-peek');
|
||||
const tabMsg = (kind, detail) => { if (typeof window.stationTabMessage === 'function') window.stationTabMessage(kind, detail); };
|
||||
|
||||
const apiKey = state.ambientApiKey;
|
||||
const appKey = state.ambientAppKey;
|
||||
@@ -2984,18 +2983,14 @@ async function fetchAmbientStation() {
|
||||
const r = await fetch(`/api/ambient${qs}`);
|
||||
if (!r.ok) {
|
||||
const { error } = await r.json().catch(() => ({}));
|
||||
if (errorEl) { errorEl.textContent = error || 'Station unavailable'; show(errorEl); }
|
||||
if (loadingEl) hide(loadingEl);
|
||||
tabMsg(r.status === 503 ? 'unconfigured' : 'error', error || 'Station unavailable');
|
||||
return;
|
||||
}
|
||||
const devices = await r.json();
|
||||
if (!Array.isArray(devices) || !devices.length) {
|
||||
if (errorEl) { errorEl.textContent = 'No devices found on this account.'; show(errorEl); }
|
||||
if (loadingEl) hide(loadingEl);
|
||||
tabMsg('error', 'No devices found on this account.');
|
||||
return;
|
||||
}
|
||||
if (loadingEl) hide(loadingEl);
|
||||
if (errorEl) hide(errorEl);
|
||||
renderAmbientStation(devices[0]);
|
||||
|
||||
// Auto-refresh every 60 s
|
||||
@@ -3014,8 +3009,7 @@ async function fetchAmbientStation() {
|
||||
} catch {}
|
||||
}, 60000);
|
||||
} catch (e) {
|
||||
if (errorEl) { errorEl.textContent = 'Could not reach station.'; show(errorEl); }
|
||||
if (loadingEl) hide(loadingEl);
|
||||
tabMsg('error', 'Could not reach your station.');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3075,14 +3069,6 @@ function applyStationOverlay(device) {
|
||||
|
||||
if (data.uv != null) setEl('cur-uv', data.uv);
|
||||
|
||||
const indoorCard = document.getElementById('st-indoor-card');
|
||||
if (data.tempinf != null) {
|
||||
setEl('st-tempin', fmtTemp(data.tempinf));
|
||||
setEl('st-tempin-unit', isMetric ? 'C' : 'F');
|
||||
setEl('st-humin', data.humidityin != null ? `${data.humidityin}% RH` : '');
|
||||
if (indoorCard) indoorCard.classList.remove('hidden');
|
||||
}
|
||||
|
||||
// Rain summary in hero sub-line
|
||||
const rainEl = document.getElementById('cur-precip');
|
||||
if (rainEl && data.dailyrainin != null) {
|
||||
@@ -3113,73 +3099,8 @@ function applyStationOverlay(device) {
|
||||
|
||||
function renderAmbientStation(device) {
|
||||
state.ambientDevice = device;
|
||||
const data = device?.lastData || {};
|
||||
const info = device?.info || {};
|
||||
const contentEl = document.getElementById('station-content');
|
||||
const peekEl = document.getElementById('station-drawer-peek');
|
||||
|
||||
const stName = info.name || device?.macAddress || 'My Station';
|
||||
const nameEl = document.getElementById('station-name');
|
||||
if (nameEl) nameEl.textContent = stName;
|
||||
|
||||
const updatedEl = document.getElementById('station-updated');
|
||||
if (updatedEl && data.dateutc) {
|
||||
const ago = Math.round((Date.now() - data.dateutc) / 60000);
|
||||
updatedEl.textContent = ago <= 1 ? 'just now' : `${ago} min ago`;
|
||||
}
|
||||
|
||||
const isMetric = state.units !== 'us';
|
||||
|
||||
function fmtTemp(f) {
|
||||
if (f == null) return '--';
|
||||
return isMetric ? Math.round((f - 32) * 5 / 9) : Math.round(f);
|
||||
}
|
||||
function fmtSpeed(mph) {
|
||||
if (mph == null) return '--';
|
||||
return isMetric ? Math.round(mph * 1.60934) : Math.round(mph);
|
||||
}
|
||||
function fmtRain(inches) {
|
||||
if (inches == null) return '--';
|
||||
return isMetric ? (inches * 25.4).toFixed(1) : inches.toFixed(2);
|
||||
}
|
||||
|
||||
const tempUnit = isMetric ? 'C' : 'F';
|
||||
const speedUnit = isMetric ? 'km/h' : 'mph';
|
||||
const rainUnit = isMetric ? 'mm' : 'in';
|
||||
|
||||
const setEl = (id, val) => { const el = document.getElementById(id); if (el) el.textContent = val; };
|
||||
|
||||
setEl('st-temp', fmtTemp(data.tempf));
|
||||
setEl('st-temp-unit', tempUnit);
|
||||
setEl('st-feels', data.feelsLike != null ? `Feels ${fmtTemp(data.feelsLike)}°` : '');
|
||||
setEl('st-hum', data.humidity != null ? `${data.humidity}` : '--');
|
||||
|
||||
if (data.tempf != null && data.humidity != null) {
|
||||
const tc = (data.tempf - 32) * 5 / 9;
|
||||
const alpha = Math.log(data.humidity / 100) + (17.62 * tc) / (243.12 + tc);
|
||||
const dewC = (243.12 * alpha) / (17.62 - alpha);
|
||||
const dewDisplay = isMetric ? Math.round(dewC) : Math.round(dewC * 9 / 5 + 32);
|
||||
setEl('st-dew', `Dew ${dewDisplay}°`);
|
||||
}
|
||||
|
||||
setEl('st-wind', fmtSpeed(data.windspeedmph));
|
||||
setEl('st-wind-unit', speedUnit);
|
||||
setEl('st-wind-dir', data.winddir != null ? compassDirFull(data.winddir) : '');
|
||||
setEl('st-gust', fmtSpeed(data.windgustmph));
|
||||
setEl('st-gust-unit', speedUnit);
|
||||
setEl('st-rain-day', fmtRain(data.dailyrainin));
|
||||
setEl('st-rain-unit', rainUnit);
|
||||
const rrDisplay = data.hourlyrainin != null ? `${fmtRain(data.hourlyrainin)} ${rainUnit}/hr` : '';
|
||||
setEl('st-rain-rate', rrDisplay);
|
||||
setEl('st-pressure', data.baromrelin != null ? data.baromrelin.toFixed(2) : '--');
|
||||
setEl('st-uv', data.uv != null ? data.uv : '--');
|
||||
setEl('st-solar', data.solarradiation != null ? Math.round(data.solarradiation) : '--');
|
||||
|
||||
if (peekEl && data.tempf != null) {
|
||||
peekEl.textContent = `· ${fmtTemp(data.tempf)}°${tempUnit} ${data.humidity != null ? data.humidity + '% RH' : ''}`;
|
||||
}
|
||||
|
||||
if (contentEl) show(contentEl);
|
||||
// The Station tab (station-live.js) renders the readings
|
||||
if (typeof window.renderStationTab === 'function') window.renderStationTab(device);
|
||||
|
||||
// Update the main conditions display with real station readings
|
||||
if (state.currentWeather) applyStationOverlay(device);
|
||||
|
||||
@@ -1,735 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Current Conditions</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Barlow+Condensed:wght@700;800&family=Barlow:wght@400;500;600&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
/* ── Tokens: light ─────────────────────────────────────── */
|
||||
:root {
|
||||
--bg: #edf2f7;
|
||||
--surface: #ffffff;
|
||||
--card: #f4f7fb;
|
||||
--border: #ccd6e2;
|
||||
--text: #0d1b2a;
|
||||
--muted: #5a7089;
|
||||
--accent: #2563eb;
|
||||
--ring: rgba(37,99,235,0.13);
|
||||
--dot: rgba(37,99,235,0.065);
|
||||
--live: #16a34a;
|
||||
--north: #dc2626;
|
||||
}
|
||||
/* ── Tokens: dark ──────────────────────────────────────── */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme="light"]) {
|
||||
--bg: #070c14;
|
||||
--surface: #0d1720;
|
||||
--card: #101c29;
|
||||
--border: #192e44;
|
||||
--text: #dce6f0;
|
||||
--muted: #496070;
|
||||
--accent: #60a5fa;
|
||||
--ring: rgba(96,165,250,0.14);
|
||||
--dot: rgba(96,165,250,0.07);
|
||||
--live: #4ade80;
|
||||
--north: #f87171;
|
||||
}
|
||||
}
|
||||
:root[data-theme="dark"] {
|
||||
--bg:#070c14; --surface:#0d1720; --card:#101c29; --border:#192e44;
|
||||
--text:#dce6f0; --muted:#496070; --accent:#60a5fa;
|
||||
--ring:rgba(96,165,250,0.14); --dot:rgba(96,165,250,0.07);
|
||||
--live:#4ade80; --north:#f87171;
|
||||
}
|
||||
|
||||
html, body {
|
||||
min-height: 100%;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: 'Barlow', system-ui, sans-serif;
|
||||
}
|
||||
|
||||
/* ── Header ─────────────────────────────────────────────── */
|
||||
.hdr {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1rem 1.5rem;
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
gap: 1rem;
|
||||
}
|
||||
.hdr-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
.hdr-back {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.2rem;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--muted);
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
padding: 0.32rem 0.75rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, white 5%, transparent);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
transition: background .18s cubic-bezier(.2,0,0,1),
|
||||
border-color .18s cubic-bezier(.2,0,0,1),
|
||||
color .18s cubic-bezier(.2,0,0,1),
|
||||
box-shadow .18s cubic-bezier(.2,0,0,1),
|
||||
transform .1s cubic-bezier(.2,0,0,1);
|
||||
}
|
||||
.hdr-back:hover {
|
||||
background: color-mix(in srgb, var(--accent) 18%, transparent);
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 20%, transparent);
|
||||
}
|
||||
.hdr-back:active { transform: scale(0.95); box-shadow: none; }
|
||||
.hdr-sep {
|
||||
color: var(--border);
|
||||
font-size: 0.8rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.hdr-station {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.hdr-live {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--live);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.live-dot {
|
||||
width: 7px; height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--live);
|
||||
animation: pulse 2.4s ease-in-out infinite;
|
||||
}
|
||||
@keyframes pulse {
|
||||
0%,100% { opacity:1; transform:scale(1); }
|
||||
50% { opacity:.45; transform:scale(.8); }
|
||||
}
|
||||
.hdr-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.hdr-time {
|
||||
font-size: 0.78rem;
|
||||
color: var(--muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.hdr-dash-link {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
color: var(--muted);
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
padding: 0.32rem 0.75rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, white 5%, transparent);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
transition: background .18s cubic-bezier(.2,0,0,1),
|
||||
border-color .18s cubic-bezier(.2,0,0,1),
|
||||
color .18s cubic-bezier(.2,0,0,1),
|
||||
box-shadow .18s cubic-bezier(.2,0,0,1),
|
||||
transform .1s cubic-bezier(.2,0,0,1);
|
||||
}
|
||||
.hdr-dash-link:hover {
|
||||
background: color-mix(in srgb, var(--accent) 18%, transparent);
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 20%, transparent);
|
||||
}
|
||||
.hdr-dash-link:active { transform: scale(0.95); box-shadow: none; }
|
||||
.hdr-dash-link .arr {
|
||||
display: inline-block;
|
||||
transition: transform .18s cubic-bezier(.2,0,0,1);
|
||||
}
|
||||
.hdr-dash-link:hover .arr { transform: translateX(2px); }
|
||||
|
||||
/* ── Hero ────────────────────────────────────────────────── */
|
||||
.hero {
|
||||
position: relative;
|
||||
background: var(--surface);
|
||||
padding: 3.5rem 1.5rem 3.25rem;
|
||||
text-align: center;
|
||||
overflow: hidden;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.hero-dots {
|
||||
position: absolute; inset: 0;
|
||||
background-image: radial-gradient(circle, var(--dot) 1.5px, transparent 1.5px);
|
||||
background-size: 28px 28px;
|
||||
pointer-events: none;
|
||||
}
|
||||
/* Radar sweep ring */
|
||||
.hero-sweep {
|
||||
position: absolute;
|
||||
top: 50%; left: 50%;
|
||||
width: 320px; height: 320px;
|
||||
margin: -160px 0 0 -160px;
|
||||
border-radius: 50%;
|
||||
background: conic-gradient(from 0deg, transparent 78%, var(--accent) 100%);
|
||||
opacity: .18;
|
||||
animation: sweep 14s linear infinite;
|
||||
pointer-events: none;
|
||||
}
|
||||
.hero-ring {
|
||||
position: absolute;
|
||||
top: 50%; left: 50%;
|
||||
width: 400px; height: 400px;
|
||||
margin: -200px 0 0 -200px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--border);
|
||||
pointer-events: none;
|
||||
}
|
||||
.hero-ring2 {
|
||||
position: absolute;
|
||||
top: 50%; left: 50%;
|
||||
width: 240px; height: 240px;
|
||||
margin: -120px 0 0 -120px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--border);
|
||||
pointer-events: none;
|
||||
}
|
||||
@keyframes sweep {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.hero-sweep { animation: none; }
|
||||
.live-dot { animation: none; }
|
||||
}
|
||||
.hero-body { position: relative; z-index: 1; }
|
||||
|
||||
.temp-row {
|
||||
display: inline-flex;
|
||||
align-items: flex-start;
|
||||
line-height: 1;
|
||||
font-family: 'Barlow Condensed', sans-serif;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
.temp-num {
|
||||
font-size: clamp(5.5rem, 22vw, 9.5rem);
|
||||
line-height: 1;
|
||||
transition: color .5s ease;
|
||||
}
|
||||
.temp-deg {
|
||||
font-size: clamp(1.8rem, 7vw, 3.2rem);
|
||||
margin-top: .15em;
|
||||
margin-left: .05em;
|
||||
opacity: .6;
|
||||
transition: color .5s ease;
|
||||
}
|
||||
.hero-sub {
|
||||
margin-top: .45rem;
|
||||
font-size: .95rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
.condition-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: .35rem;
|
||||
margin-top: .85rem;
|
||||
padding: .3rem 1rem;
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
font-size: .82rem;
|
||||
font-weight: 500;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* ── Content ─────────────────────────────────────────────── */
|
||||
.content {
|
||||
max-width: 680px;
|
||||
margin: 0 auto;
|
||||
padding: 1.5rem 1rem 3.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
.section-label {
|
||||
font-size: .62rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: .11em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
margin-bottom: .6rem;
|
||||
}
|
||||
|
||||
/* ── Metric grid ─────────────────────────────────────────── */
|
||||
.row3 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3,1fr);
|
||||
gap: .7rem;
|
||||
}
|
||||
.row4 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2,1fr);
|
||||
gap: .7rem;
|
||||
}
|
||||
@media (min-width: 480px) {
|
||||
.row4 { grid-template-columns: repeat(4,1fr); }
|
||||
}
|
||||
.card {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
padding: 1rem 1.1rem 0.9rem;
|
||||
}
|
||||
.card-label {
|
||||
font-size: .6rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: .1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
margin-bottom: .3rem;
|
||||
}
|
||||
.card-val {
|
||||
font-family: 'Barlow Condensed', sans-serif;
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.card-unit {
|
||||
font-size: .68rem;
|
||||
color: var(--muted);
|
||||
margin-top: .2rem;
|
||||
}
|
||||
|
||||
/* ── Wind card ───────────────────────────────────────────── */
|
||||
.wind-wrap {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
padding: 1.2rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
.wind-compass-wrap { flex-shrink: 0; }
|
||||
canvas { display: block; }
|
||||
.wind-grid {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: .75rem 1.25rem;
|
||||
}
|
||||
.wind-stat-label {
|
||||
font-size: .6rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: .1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
margin-bottom: .2rem;
|
||||
}
|
||||
.wind-stat-val {
|
||||
font-family: 'Barlow Condensed', sans-serif;
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
.wind-stat-unit {
|
||||
font-size: .65rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
@media (max-width: 460px) {
|
||||
.wind-wrap { flex-direction: column; }
|
||||
}
|
||||
|
||||
/* ── Footer ──────────────────────────────────────────────── */
|
||||
.foot {
|
||||
text-align: center;
|
||||
font-size: .7rem;
|
||||
color: var(--muted);
|
||||
padding-bottom: 1.5rem;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header class="hdr">
|
||||
<div class="hdr-left">
|
||||
<a href="/" class="hdr-back" aria-label="Back to app">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true"><path d="M10 3L5 8l5 5" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
App
|
||||
</a>
|
||||
<span class="hdr-sep">·</span>
|
||||
<span class="hdr-station" id="stationName">Genesis 8:22</span>
|
||||
</div>
|
||||
<div class="hdr-live"><span class="live-dot"></span>Live</div>
|
||||
<div class="hdr-right">
|
||||
<a href="/dashboard" class="hdr-dash-link">Dashboard <span class="arr">→</span></a>
|
||||
<div class="hdr-time" id="hdrTime"></div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="hero">
|
||||
<div class="hero-dots"></div>
|
||||
<div class="hero-sweep"></div>
|
||||
<div class="hero-ring"></div>
|
||||
<div class="hero-ring2"></div>
|
||||
<div class="hero-body">
|
||||
<div class="temp-row">
|
||||
<div class="temp-num" id="tempNum">—</div>
|
||||
<div class="temp-deg" id="tempDeg"></div>
|
||||
</div>
|
||||
<div class="hero-sub" id="heroSub"></div>
|
||||
<div><span class="condition-pill" id="condPill">Loading…</span></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<main class="content">
|
||||
|
||||
<div>
|
||||
<div class="section-label">Atmosphere</div>
|
||||
<div class="row3">
|
||||
<div class="card">
|
||||
<div class="card-label">Humidity</div>
|
||||
<div class="card-val" id="vHumidity">—</div>
|
||||
<div class="card-unit">%</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-label">Dew Point</div>
|
||||
<div class="card-val" id="vDew">—</div>
|
||||
<div class="card-unit">°F</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-label">Pressure</div>
|
||||
<div class="card-val" id="vPressure">—</div>
|
||||
<div class="card-unit">inHg</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="section-label">Wind</div>
|
||||
<div class="wind-wrap">
|
||||
<div class="wind-compass-wrap">
|
||||
<canvas id="compass" width="152" height="152" style="width:152px;height:152px"></canvas>
|
||||
</div>
|
||||
<div class="wind-grid">
|
||||
<div>
|
||||
<div class="wind-stat-label">Speed</div>
|
||||
<div class="wind-stat-val" id="vWindSpeed">—</div>
|
||||
<div class="wind-stat-unit">mph</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="wind-stat-label">Gust</div>
|
||||
<div class="wind-stat-val" id="vWindGust">—</div>
|
||||
<div class="wind-stat-unit">mph</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="wind-stat-label">From</div>
|
||||
<div class="wind-stat-val" id="vWindDir">—</div>
|
||||
<div class="wind-stat-unit" id="vWindDeg"></div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="wind-stat-label">Max Today</div>
|
||||
<div class="wind-stat-val" id="vWindMax">—</div>
|
||||
<div class="wind-stat-unit">mph</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="section-label">Precipitation & Solar</div>
|
||||
<div class="row4">
|
||||
<div class="card">
|
||||
<div class="card-label">Rain Today</div>
|
||||
<div class="card-val" id="vRainDay">—</div>
|
||||
<div class="card-unit">inches</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-label">Rain Rate</div>
|
||||
<div class="card-val" id="vRainRate">—</div>
|
||||
<div class="card-unit">in / hr</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-label">UV Index</div>
|
||||
<div class="card-val" id="vUV">—</div>
|
||||
<div class="card-unit" id="vUVLabel"></div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-label">Solar Rad.</div>
|
||||
<div class="card-val" id="vSolar">—</div>
|
||||
<div class="card-unit">W/m²</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</main>
|
||||
|
||||
<div class="foot" id="foot">Refreshes automatically every 2 minutes</div>
|
||||
|
||||
<script>
|
||||
// ── Utilities ────────────────────────────────────────────────────────────────
|
||||
const $ = id => document.getElementById(id);
|
||||
const set = (id, v) => { const el = $(id); if (el) el.textContent = v; };
|
||||
const fmt = (v, d = 1) => v != null ? (+v).toFixed(d) : '—';
|
||||
|
||||
function cardinal(deg) {
|
||||
if (deg == null) return '—';
|
||||
return ['N','NNE','NE','ENE','E','ESE','SE','SSE','S','SSW','SW','WSW','W','WNW','NW','NNW'][
|
||||
Math.round(+deg / 22.5) % 16
|
||||
];
|
||||
}
|
||||
|
||||
function tempColor(f) {
|
||||
if (f == null) return 'var(--text)';
|
||||
if (f <= 32) return '#93c5fd';
|
||||
if (f <= 50) return '#60a5fa';
|
||||
if (f <= 65) return 'var(--text)';
|
||||
if (f <= 80) return '#f59e0b';
|
||||
if (f <= 90) return '#f97316';
|
||||
return '#ef4444';
|
||||
}
|
||||
|
||||
function uvInfo(uv) {
|
||||
if (uv == null) return ['—', 'var(--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';
|
||||
}
|
||||
|
||||
// ── Compass ──────────────────────────────────────────────────────────────────
|
||||
function drawCompass(dir) {
|
||||
const canvas = $('compass');
|
||||
if (!canvas) return;
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||
const cssSize = 152;
|
||||
canvas.width = cssSize * dpr;
|
||||
canvas.height = cssSize * dpr;
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.scale(dpr, dpr);
|
||||
|
||||
const cx = cssSize / 2, cy = cssSize / 2, r = cssSize / 2 - 7;
|
||||
const dark = document.documentElement.dataset.theme === 'dark' ||
|
||||
(!document.documentElement.dataset.theme &&
|
||||
window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||
|
||||
const cCard = dark ? '#101c29' : '#f4f7fb';
|
||||
const cRing = dark ? '#192e44' : '#ccd6e2';
|
||||
const cLabel = dark ? '#496070' : '#8fa0b8';
|
||||
const cAccent = dark ? '#60a5fa' : '#2563eb';
|
||||
const cNorth = dark ? '#f87171' : '#dc2626';
|
||||
|
||||
// Face
|
||||
ctx.fillStyle = cCard;
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, r + 5, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
// Concentric rings
|
||||
[1/3, 2/3, 1].forEach(f => {
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, r * f, 0, Math.PI * 2);
|
||||
ctx.strokeStyle = cRing;
|
||||
ctx.lineWidth = .7;
|
||||
ctx.stroke();
|
||||
});
|
||||
|
||||
// Ticks (major at cardinals, minor at intercardinals)
|
||||
for (let i = 0; i < 8; i++) {
|
||||
const a = (i * 45 - 90) * Math.PI / 180;
|
||||
const isMajor = i % 2 === 0;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(cx + r * Math.cos(a), cy + r * Math.sin(a));
|
||||
ctx.lineTo(cx + (r - (isMajor ? 9 : 5)) * Math.cos(a), cy + (r - (isMajor ? 9 : 5)) * Math.sin(a));
|
||||
ctx.strokeStyle = isMajor ? cLabel : cRing;
|
||||
ctx.lineWidth = isMajor ? 1.5 : .7;
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// Cardinal labels
|
||||
const labelR = r - 18;
|
||||
const cards = [['N', -90, cNorth], ['E', 0, cLabel], ['S', 90, cLabel], ['W', 180, cLabel]];
|
||||
cards.forEach(([lbl, deg, color]) => {
|
||||
const a = deg * Math.PI / 180;
|
||||
ctx.font = `700 ${lbl === 'N' ? 11 : 10}px 'Barlow', sans-serif`;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillStyle = color;
|
||||
ctx.fillText(lbl, cx + labelR * Math.cos(a), cy + labelR * Math.sin(a));
|
||||
});
|
||||
|
||||
// Wind arrow (points FROM the origin direction)
|
||||
if (dir != null) {
|
||||
// winddir: degrees from north, clockwise, = where wind comes FROM
|
||||
// Arrow tip at the "from" direction, tail at center
|
||||
const a = ((+dir - 90) * Math.PI) / 180;
|
||||
const tipR = r * 0.60;
|
||||
const tx = cx + tipR * Math.cos(a);
|
||||
const ty = cy + tipR * Math.sin(a);
|
||||
|
||||
// Shaft
|
||||
ctx.strokeStyle = cAccent;
|
||||
ctx.lineWidth = 2;
|
||||
ctx.lineCap = 'round';
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(cx, cy);
|
||||
ctx.lineTo(tx, ty);
|
||||
ctx.stroke();
|
||||
|
||||
// Arrowhead
|
||||
const ha = Math.atan2(ty - cy, tx - cx);
|
||||
const 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();
|
||||
|
||||
// Tail stub
|
||||
const tailA = a + Math.PI;
|
||||
ctx.strokeStyle = cAccent;
|
||||
ctx.globalAlpha = .3;
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(cx, cy);
|
||||
ctx.lineTo(cx + r * 0.22 * Math.cos(tailA), cy + r * 0.22 * Math.sin(tailA));
|
||||
ctx.stroke();
|
||||
ctx.globalAlpha = 1;
|
||||
|
||||
// Center pip
|
||||
ctx.fillStyle = cAccent;
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, 3, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Fetch & render ───────────────────────────────────────────────────────────
|
||||
async function load() {
|
||||
try {
|
||||
const r = await fetch('/api/conditions/public');
|
||||
if (!r.ok) throw new Error('not ok');
|
||||
const d = await r.json();
|
||||
|
||||
if (!d) {
|
||||
$('condPill').textContent = 'No data yet';
|
||||
return;
|
||||
}
|
||||
|
||||
// Station name (server may include it)
|
||||
if (d.stationName) {
|
||||
const nameEl = $('stationName');
|
||||
if (nameEl) nameEl.textContent = d.stationName;
|
||||
}
|
||||
|
||||
// Header
|
||||
if (d.dateutc) {
|
||||
const dt = new Date(d.dateutc);
|
||||
$('hdrTime').textContent = dt.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
$('foot').textContent = `Updated ${dt.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} · refreshes automatically`;
|
||||
}
|
||||
|
||||
// Hero temperature
|
||||
const tempEl = $('tempNum');
|
||||
const degEl = $('tempDeg');
|
||||
const tv = d.tempf != null ? +d.tempf : null;
|
||||
tempEl.textContent = tv != null ? Math.round(tv) : '—';
|
||||
degEl.textContent = tv != null ? '°F' : '';
|
||||
const tc = tempColor(tv);
|
||||
tempEl.style.color = tc;
|
||||
degEl.style.color = tc;
|
||||
|
||||
// Sub line
|
||||
const fl = d.feelsLike ?? d.tempf;
|
||||
$('heroSub').textContent = fl != null ? `Feels like ${Math.round(+fl)}°F` : '';
|
||||
|
||||
// Condition pill
|
||||
$('condPill').textContent = conditionText(d);
|
||||
|
||||
// Atmosphere
|
||||
set('vHumidity', fmt(d.humidity, 0));
|
||||
set('vDew', fmt(d.dewPoint));
|
||||
set('vPressure', d.baromrelin != null ? fmt(d.baromrelin, 2) : '—');
|
||||
|
||||
// Wind
|
||||
set('vWindSpeed', fmt(d.windspeedmph, 0));
|
||||
set('vWindGust', fmt(d.windgustmph, 0));
|
||||
set('vWindDir', cardinal(d.winddir));
|
||||
set('vWindDeg', d.winddir != null ? Math.round(+d.winddir) + '°' : '');
|
||||
set('vWindMax', fmt(d.maxdailygust, 0));
|
||||
drawCompass(d.winddir);
|
||||
|
||||
// Precip + solar
|
||||
set('vRainDay', fmt(d.dailyrainin, 2));
|
||||
set('vRainRate', fmt(d.rainratein, 2));
|
||||
|
||||
const [uvLabel, uvColor] = uvInfo(d.uv);
|
||||
const uvEl = $('vUV');
|
||||
uvEl.textContent = d.uv != null ? fmt(d.uv, 0) : '—';
|
||||
uvEl.style.color = uvColor;
|
||||
set('vUVLabel', d.uv != null ? uvLabel : '');
|
||||
|
||||
set('vSolar', fmt(d.solarradiation, 0));
|
||||
|
||||
} catch {
|
||||
$('condPill').textContent = 'Station offline';
|
||||
}
|
||||
}
|
||||
|
||||
// Draw compass face immediately on load; redrawn with arrow when data arrives
|
||||
drawCompass(null);
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => drawCompass(null));
|
||||
window.addEventListener('resize', () => drawCompass(null));
|
||||
|
||||
load();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -136,7 +136,7 @@ button.primary:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
<div class="section">
|
||||
<div class="section-hdr">Station Display</div>
|
||||
<div>
|
||||
<label>Station Name (shown on public conditions page)</label>
|
||||
<label>Station Name (shown on the Station tab of the weather app)</label>
|
||||
<input id="inputStationName" type="text" placeholder="e.g. Genesis 8:22" maxlength="60" autocomplete="off">
|
||||
</div>
|
||||
<div class="settings-actions">
|
||||
|
||||
@@ -384,7 +384,7 @@ input, select, textarea { font-size: 16px; } /* stops iOS zooming in on focus */
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<span class="station-name" id="stationName"></span>
|
||||
<a href="/conditions" class="logout">Conditions</a>
|
||||
<a href="/?tab=station" class="logout">Station</a>
|
||||
<a href="/" class="logout">← Weather</a>
|
||||
<a href="/dashboard/settings" class="logout">Settings</a>
|
||||
<a href="/dashboard/logout" class="logout">Sign out</a>
|
||||
|
||||
+16
-64
@@ -13,7 +13,11 @@
|
||||
<meta name="apple-mobile-web-app-title" content="NeCloud Weather">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<link rel="stylesheet" href="https://unpkg.com/maplibre-gl@4.7.1/dist/maplibre-gl.css">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Barlow+Condensed:wght@700;800&family=Barlow:wght@400;500;600&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="style.css">
|
||||
<link rel="stylesheet" href="station-live.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="offline-banner" class="offline-banner hidden">No internet connection — showing cached data</div>
|
||||
@@ -24,6 +28,7 @@
|
||||
<h1>NeCloud Weather</h1>
|
||||
<nav class="tabs">
|
||||
<button class="tab active" data-tab="conditions">Conditions</button>
|
||||
<button class="tab" data-tab="station">Station</button>
|
||||
<button class="tab" data-tab="unified-map">Map</button>
|
||||
<button class="tab" data-tab="alerts">Alerts<span class="alert-badge hidden" id="alert-badge-nav"></span></button>
|
||||
<button class="tab" data-tab="learn">Learn</button>
|
||||
@@ -31,7 +36,6 @@
|
||||
</nav>
|
||||
<span class="tagline">Open-Meteo · RainViewer</span>
|
||||
<nav class="station-links">
|
||||
<a href="/conditions" class="station-link">Station</a>
|
||||
<a href="/dashboard" class="station-link">Dashboard</a>
|
||||
</nav>
|
||||
</header>
|
||||
@@ -242,68 +246,6 @@
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- ── MY STATION (Ambient Weather) ── -->
|
||||
<details class="details-drawer station-drawer" id="station-drawer" open>
|
||||
<summary class="drawer-summary">
|
||||
My Station
|
||||
<span class="station-drawer-peek" id="station-drawer-peek"></span>
|
||||
<span class="drawer-chevron">›</span>
|
||||
</summary>
|
||||
<div class="drawer-body" id="station-body">
|
||||
<div class="station-loading" id="station-loading">Loading station data…</div>
|
||||
<div id="station-content" class="hidden">
|
||||
<div class="station-name-row">
|
||||
<span class="station-name" id="station-name"></span>
|
||||
<span class="station-updated" id="station-updated"></span>
|
||||
</div>
|
||||
<div class="card-group drawer-card-group">
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Outdoor Temp</div>
|
||||
<div class="stat-val"><span id="st-temp">--</span>°<span id="st-temp-unit">F</span></div>
|
||||
<div class="stat-sub" id="st-feels"></div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Humidity</div>
|
||||
<div class="stat-val"><span id="st-hum">--</span><span class="stat-unit">%</span></div>
|
||||
<div class="stat-sub" id="st-dew"></div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Wind</div>
|
||||
<div class="stat-val"><span id="st-wind">--</span> <span class="stat-unit" id="st-wind-unit">mph</span></div>
|
||||
<div class="stat-sub" id="st-wind-dir"></div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Gusts</div>
|
||||
<div class="stat-val"><span id="st-gust">--</span> <span class="stat-unit" id="st-gust-unit">mph</span></div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Rain Today</div>
|
||||
<div class="stat-val"><span id="st-rain-day">--</span> <span class="stat-unit" id="st-rain-unit">in</span></div>
|
||||
<div class="stat-sub" id="st-rain-rate"></div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Pressure</div>
|
||||
<div class="stat-val"><span id="st-pressure">--</span> <span class="stat-unit">inHg</span></div>
|
||||
</div>
|
||||
<div class="stat-card" id="st-uv-card">
|
||||
<div class="stat-label">UV Index</div>
|
||||
<div class="stat-val"><span id="st-uv">--</span></div>
|
||||
</div>
|
||||
<div class="stat-card" id="st-solar-card">
|
||||
<div class="stat-label">Solar Rad.</div>
|
||||
<div class="stat-val"><span id="st-solar">--</span> <span class="stat-unit">W/m²</span></div>
|
||||
</div>
|
||||
<div class="stat-card hidden" id="st-indoor-card">
|
||||
<div class="stat-label">Indoor Temp</div>
|
||||
<div class="stat-val"><span id="st-tempin">--</span>°<span id="st-tempin-unit">F</span></div>
|
||||
<div class="stat-sub" id="st-humin"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="station-error" class="station-error hidden"></div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div class="footer-note">
|
||||
Weather: <a href="https://open-meteo.com" target="_blank">Open-Meteo</a> ·
|
||||
Radar: <a href="https://www.rainviewer.com" target="_blank">RainViewer</a> ·
|
||||
@@ -313,6 +255,11 @@
|
||||
</div>
|
||||
|
||||
<!-- ── TAB: UNIFIED MAP ── -->
|
||||
<!-- ── TAB: STATION (live personal weather station) ── -->
|
||||
<div id="tab-station" class="tab-panel hidden">
|
||||
<div id="station-live" class="sl"></div>
|
||||
</div>
|
||||
|
||||
<div id="tab-unified-map" class="tab-panel hidden">
|
||||
<div class="unified-map-wrap">
|
||||
<!-- Map Controls -->
|
||||
@@ -492,7 +439,7 @@
|
||||
|
||||
<div class="settings-section-label">Station</div>
|
||||
<div class="settings-nav-links">
|
||||
<a href="/conditions" class="settings-nav-link">Live Conditions</a>
|
||||
<a href="/?tab=station" class="settings-nav-link">Live Station</a>
|
||||
<a href="/dashboard" class="settings-nav-link">Dashboard</a>
|
||||
</div>
|
||||
|
||||
@@ -1336,6 +1283,10 @@
|
||||
<span class="bottom-icon">⛅</span>
|
||||
<span class="bottom-label">Now</span>
|
||||
</button>
|
||||
<button class="bottom-tab" data-tab="station" type="button">
|
||||
<span class="bottom-icon">📡</span>
|
||||
<span class="bottom-label">Station</span>
|
||||
</button>
|
||||
<button class="bottom-tab" data-tab="unified-map" type="button">
|
||||
<span class="bottom-icon">🗺️</span>
|
||||
<span class="bottom-label">Map</span>
|
||||
@@ -1357,6 +1308,7 @@
|
||||
|
||||
<script src="https://unpkg.com/maplibre-gl@4.7.1/dist/maplibre-gl.js"></script>
|
||||
<script src="app.js"></script>
|
||||
<script src="station-live.js"></script>
|
||||
<script>
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.register('/sw.js');
|
||||
|
||||
@@ -43,6 +43,13 @@
|
||||
"url": "/?tab=conditions",
|
||||
"icons": [{ "src": "/icons/icon-192.png", "sizes": "192x192" }]
|
||||
},
|
||||
{
|
||||
"name": "My Station",
|
||||
"short_name": "Station",
|
||||
"description": "Live readings from your weather station",
|
||||
"url": "/?tab=station",
|
||||
"icons": [{ "src": "/icons/icon-192.png", "sizes": "192x192" }]
|
||||
},
|
||||
{
|
||||
"name": "Radar Map",
|
||||
"short_name": "Map",
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
/* Station tab — the former /conditions page look, scoped so it can't touch the rest of the app */
|
||||
.sl {
|
||||
--sl-bg: #edf2f7; --sl-surface: #ffffff; --sl-card: #f4f7fb; --sl-border: #ccd6e2;
|
||||
--sl-text: #0d1b2a; --sl-muted: #5a7089; --sl-accent: #2563eb;
|
||||
--sl-dot: rgba(37,99,235,0.065); --sl-live: #16a34a; --sl-north: #dc2626;
|
||||
font-family: 'Barlow', system-ui, sans-serif; color: var(--sl-text);
|
||||
max-width: 720px; margin: 0 auto; padding: 0.25rem 0 2.5rem;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme="light"]) .sl {
|
||||
--sl-bg: #070c14; --sl-surface: #0d1720; --sl-card: #101c29; --sl-border: #192e44;
|
||||
--sl-text: #dce6f0; --sl-muted: #6f8aa3; --sl-accent: #60a5fa;
|
||||
--sl-dot: rgba(96,165,250,0.07); --sl-live: #4ade80; --sl-north: #f87171;
|
||||
}
|
||||
}
|
||||
:root[data-theme="dark"] .sl {
|
||||
--sl-bg: #070c14; --sl-surface: #0d1720; --sl-card: #101c29; --sl-border: #192e44;
|
||||
--sl-text: #dce6f0; --sl-muted: #6f8aa3; --sl-accent: #60a5fa;
|
||||
--sl-dot: rgba(96,165,250,0.07); --sl-live: #4ade80; --sl-north: #f87171;
|
||||
}
|
||||
.sl *, .sl *::before, .sl *::after { box-sizing: border-box; }
|
||||
|
||||
/* Status bar */
|
||||
.sl-bar { display: flex; align-items: center; justify-content: space-between; gap: 0.75rem; padding: 0.7rem 1rem; margin-bottom: 0.9rem;
|
||||
background: var(--sl-surface); border: 1px solid var(--sl-border); border-radius: 14px; }
|
||||
.sl-name { font-size: 0.8rem; font-weight: 600; letter-spacing: 0.08em; text-transform: uppercase; color: var(--sl-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.sl-live { display: flex; align-items: center; gap: 0.45rem; font-size: 0.7rem; font-weight: 600; letter-spacing: 0.08em; text-transform: uppercase; color: var(--sl-live); white-space: nowrap; }
|
||||
.sl-live.stale { color: var(--sl-muted); }
|
||||
.sl-dot { width: 7px; height: 7px; border-radius: 50%; background: currentColor; animation: sl-pulse 2.4s ease-in-out infinite; }
|
||||
.sl-live.stale .sl-dot { animation: none; opacity: .5; }
|
||||
@keyframes sl-pulse { 0%,100% { opacity: 1; transform: scale(1); } 50% { opacity: .45; transform: scale(.8); } }
|
||||
.sl-time { font-size: 0.78rem; color: var(--sl-muted); font-variant-numeric: tabular-nums; white-space: nowrap; }
|
||||
|
||||
/* Hero */
|
||||
.sl-hero { position: relative; background: var(--sl-surface); padding: 3rem 1.25rem 2.75rem; text-align: center; overflow: hidden; border: 1px solid var(--sl-border); border-radius: 18px; }
|
||||
.sl-hero-dots { position: absolute; inset: 0; background-image: radial-gradient(circle, var(--sl-dot) 1.5px, transparent 1.5px); background-size: 28px 28px; pointer-events: none; }
|
||||
.sl-hero-sweep { position: absolute; top: 50%; left: 50%; width: 320px; height: 320px; margin: -160px 0 0 -160px; border-radius: 50%;
|
||||
background: conic-gradient(from 0deg, transparent 78%, var(--sl-accent) 100%); opacity: .18; animation: sl-sweep 14s linear infinite; pointer-events: none; }
|
||||
.sl-hero-ring, .sl-hero-ring2 { position: absolute; top: 50%; left: 50%; border-radius: 50%; border: 1px solid var(--sl-border); pointer-events: none; }
|
||||
.sl-hero-ring { width: 400px; height: 400px; margin: -200px 0 0 -200px; }
|
||||
.sl-hero-ring2 { width: 240px; height: 240px; margin: -120px 0 0 -120px; }
|
||||
@keyframes sl-sweep { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
|
||||
@media (prefers-reduced-motion: reduce) { .sl-hero-sweep, .sl-dot { animation: none; } }
|
||||
.sl-hero-body { position: relative; z-index: 1; }
|
||||
.sl-temp-row { display: inline-flex; align-items: flex-start; line-height: 1; font-family: 'Barlow Condensed', sans-serif; font-weight: 800; letter-spacing: -0.03em; }
|
||||
.sl-temp-num { font-size: clamp(5.5rem, 22vw, 9rem); line-height: 1; transition: color .5s ease; }
|
||||
.sl-temp-deg { font-size: clamp(1.8rem, 7vw, 3rem); margin-top: .15em; margin-left: .05em; opacity: .6; transition: color .5s ease; }
|
||||
.sl-hero-sub { margin-top: .45rem; font-size: .95rem; color: var(--sl-muted); }
|
||||
.sl-pill { display: inline-flex; align-items: center; gap: .35rem; margin-top: .85rem; padding: .3rem 1rem; background: var(--sl-card); border: 1px solid var(--sl-border); border-radius: 999px; font-size: .82rem; font-weight: 500; color: var(--sl-muted); }
|
||||
|
||||
/* Sections */
|
||||
.sl-section { margin-top: 1.4rem; }
|
||||
.sl-label { font-size: .62rem; font-weight: 600; letter-spacing: .11em; text-transform: uppercase; color: var(--sl-muted); margin-bottom: .6rem; }
|
||||
.sl-row3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: .7rem; }
|
||||
.sl-row4 { display: grid; grid-template-columns: repeat(2, 1fr); gap: .7rem; }
|
||||
.sl-row2 { display: grid; grid-template-columns: repeat(2, 1fr); gap: .7rem; }
|
||||
@media (min-width: 480px) { .sl-row4 { grid-template-columns: repeat(4, 1fr); } }
|
||||
.sl-card { background: var(--sl-card); border: 1px solid var(--sl-border); border-radius: 14px; padding: 1rem 1.1rem .9rem; min-width: 0; }
|
||||
.sl-card-label { font-size: .6rem; font-weight: 600; letter-spacing: .1em; text-transform: uppercase; color: var(--sl-muted); margin-bottom: .3rem; }
|
||||
.sl-card-val { font-family: 'Barlow Condensed', sans-serif; font-size: 2rem; font-weight: 700; line-height: 1; font-variant-numeric: tabular-nums; }
|
||||
.sl-card-unit { font-size: .68rem; color: var(--sl-muted); margin-top: .2rem; }
|
||||
|
||||
/* Wind */
|
||||
.sl-wind { background: var(--sl-card); border: 1px solid var(--sl-border); border-radius: 14px; padding: 1.2rem; display: flex; align-items: center; gap: 1.5rem; }
|
||||
.sl-compass { flex-shrink: 0; display: block; }
|
||||
.sl-wind-grid { flex: 1; display: grid; grid-template-columns: 1fr 1fr; gap: .75rem 1.25rem; }
|
||||
.sl-wind-label { font-size: .6rem; font-weight: 600; letter-spacing: .1em; text-transform: uppercase; color: var(--sl-muted); margin-bottom: .2rem; }
|
||||
.sl-wind-val { font-family: 'Barlow Condensed', sans-serif; font-size: 1.75rem; font-weight: 700; line-height: 1; }
|
||||
.sl-wind-unit { font-size: .65rem; color: var(--sl-muted); }
|
||||
@media (max-width: 460px) { .sl-wind { flex-direction: column; } }
|
||||
|
||||
.sl-foot { text-align: center; font-size: .7rem; color: var(--sl-muted); margin-top: 1.5rem; }
|
||||
.sl-empty { text-align: center; padding: 3rem 1.25rem; background: var(--sl-surface); border: 1px solid var(--sl-border); border-radius: 18px; color: var(--sl-muted); }
|
||||
.sl-empty b { display: block; font-family: 'Barlow Condensed', sans-serif; font-size: 1.6rem; color: var(--sl-text); margin-bottom: .4rem; }
|
||||
.sl-empty button { margin-top: 1rem; font: inherit; font-weight: 600; padding: .6rem 1.2rem; min-height: 44px; border-radius: 10px; border: 1px solid var(--sl-accent); background: transparent; color: var(--sl-accent); cursor: pointer; }
|
||||
.sl-link { display: inline-flex; align-items: center; gap: .3rem; font-size: .78rem; font-weight: 600; color: var(--sl-muted); text-decoration: none; padding: .32rem .75rem; border: 1px solid var(--sl-border); border-radius: 8px; }
|
||||
.sl-link:hover { color: var(--sl-accent); border-color: var(--sl-accent); }
|
||||
@@ -0,0 +1,243 @@
|
||||
/* 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>';
|
||||
})();
|
||||
@@ -2532,20 +2532,6 @@ body.map-fs #unified-map {
|
||||
|
||||
|
||||
/* ── My Station (Ambient Weather) ──────────────────────────────────────────── */
|
||||
.station-drawer { margin-top: 1rem; }
|
||||
.station-drawer-peek { font-size: 0.8rem; color: var(--ink-light); margin-left: 0.5rem; font-weight: 400; }
|
||||
.station-loading { color: var(--ink-light); font-size: 0.9rem; padding: 0.5rem 0; }
|
||||
.station-error { color: #e57373; font-size: 0.88rem; padding: 0.4rem 0; }
|
||||
.station-name-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.6rem;
|
||||
margin-bottom: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.station-name { font-weight: 600; font-size: 0.95rem; }
|
||||
.station-updated { font-size: 0.75rem; color: var(--ink-light); }
|
||||
|
||||
.station-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
+3
-1
@@ -4,13 +4,15 @@ self.addEventListener('message', event => {
|
||||
if (event.data === 'skipWaiting') self.skipWaiting();
|
||||
});
|
||||
|
||||
const CACHE = 'weather-v7';
|
||||
const CACHE = 'weather-v8';
|
||||
|
||||
// App shell cached on install for offline resilience.
|
||||
const PRECACHE = [
|
||||
'/',
|
||||
'/app.js',
|
||||
'/style.css',
|
||||
'/station-live.css',
|
||||
'/station-live.js',
|
||||
'/manifest.json',
|
||||
'/icons/icon.svg'
|
||||
];
|
||||
|
||||
Reference in new issue
Block a user