feat(ui): humidity, CO2 pill per room and 24h pressure sparkline

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-09-13 09:35:53 +02:00
co-authored by Claude Fable 5.1
parent dba1875683
commit 768cc7151f
6 changed files with 125 additions and 6 deletions
+16 -2
View File
@@ -1,6 +1,7 @@
import {
f1, fInt, fmm, markerPos, barHeights, wmoIcon, trendLabel,
formatDate, formatTime, dayAbbr, monthName, isOlderThan, msUntil,
co2Level, fPct, fHpa, seriesRange, sparklinePath,
} from './format.js';
import { icon } from './icons.js';
@@ -57,6 +58,7 @@ function renderMeasurement(now) {
function renderOutdoor(o) {
setText('trend', trendLabel(o.trend));
setText('out-hum', o.humidity === null || o.humidity === undefined ? '' : `RV ${fPct(o.humidity)}`);
setText('temp', `${f1(o.temp)}°`);
$('mark').style.left = `${markerPos(o.temp, o.min, o.max).toFixed(1)}%`;
setText('min', `${f1(o.min)}°`);
@@ -68,10 +70,12 @@ function renderOutdoor(o) {
function renderRooms(rooms) {
const html = rooms.map((r) => {
const off = !r.reachable || r.temp === null;
const co2 = co2Level(off ? null : r.co2);
const pill = `<span class="pill ${co2.level} num" title="${co2.label}">${co2.level === 'unknown' ? '–' : r.co2 + ' ppm'}</span>`;
return `<div class="a-room${off ? ' off' : ''}">
<p class="a-rname">${esc(r.name)}</p>
<p class="a-rname"><span>${esc(r.name)}</span>${pill}</p>
<p class="a-rtemp num">${off ? '–' : f1(r.temp) + '°'}</p>
<p class="a-rmm num"><span class="cold">↓ ${off ? '–' : f1(r.min) + '°'}</span><span class="warm">↑ ${off ? '–' : f1(r.max) + '°'}</span></p>
<p class="a-rmm num"><span class="cold">↓ ${off ? '–' : f1(r.min) + '°'}</span><span class="warm">↑ ${off ? '–' : f1(r.max) + '°'}</span><span class="rv">RV ${off ? '–' : fPct(r.humidity)}</span></p>
</div>`;
});
$('rooms').innerHTML = html.join('');
@@ -90,6 +94,15 @@ function renderRain(rain, now) {
</div>`).join('');
}
function renderPressure(p) {
setText('press', fHpa(p.current));
setText('press-trend', trendLabel(p.trend));
const values = p.history.map((h) => h.hpa);
const range = seriesRange(values);
setText('press-range', range ? `${Math.round(range.min)} – ${Math.round(range.max)} hPa in 24 u` : '');
$('press-path').setAttribute('d', sparklinePath(values, 560, 56));
}
function renderForecast(days) {
$('forecast').innerHTML = days.map((d) => `<div class="a-day">
<p class="a-dname">${dayAbbr(d.date, state.tz)}</p>
@@ -131,6 +144,7 @@ function render() {
renderOutdoor(d.outdoor);
renderRooms(d.indoor);
renderRain(d.rain, now);
renderPressure(d.pressure || { current: null, trend: null, history: [] });
renderForecast(d.forecast);
}
renderBanner();
+46
View File
@@ -122,3 +122,49 @@ export function msUntil(now, tz, hour, minute = 0) {
if (diff <= 0) diff += 86400;
return Math.max(1000, diff * 1000 - now.getMilliseconds());
}
/** CO₂-niveau volgens de Netatmo-drempels: tot 1000 goed, tot 1500 matig, daarboven slecht. */
export function co2Level(ppm) {
if (!isNum(ppm)) return { level: 'unknown', label: DASH };
if (ppm <= 1000) return { level: 'good', label: 'goed' };
if (ppm <= 1500) return { level: 'medium', label: 'matig' };
return { level: 'bad', label: 'slecht' };
}
/** 48 -> "48%"; null -> "–" */
export function fPct(v) {
return isNum(v) ? `${Math.round(v)}%` : DASH;
}
/** 1014.2 -> "1014 hPa"; null -> "–" */
export function fHpa(v) {
return isNum(v) ? `${Math.round(v)} hPa` : DASH;
}
/** Laagste en hoogste waarde van een reeks (null-waarden genegeerd), of null zonder geldige waarden. */
export function seriesRange(values) {
const nums = values.filter(isNum);
if (nums.length === 0) return null;
return { min: Math.min(...nums), max: Math.max(...nums) };
}
/**
* SVG-pad voor een sparkline: punten gelijkmatig over de breedte, waarden geschaald op min..max
* met `pad` px marge. Een vlakke reeks wordt een lijn door het midden. Minder dan 2 punten -> "".
*/
export function sparklinePath(values, w, h, pad = 3) {
const range = seriesRange(values);
if (!range || values.length < 2) return '';
const span = range.max - range.min;
const innerW = w - 2 * pad;
const innerH = h - 2 * pad;
const step = innerW / (values.length - 1);
const parts = [];
values.forEach((v, i) => {
if (!isNum(v)) return;
const x = pad + i * step;
const y = span === 0 ? h / 2 : pad + innerH - ((v - range.min) / span) * innerH;
parts.push(`${parts.length === 0 ? 'M' : 'L'}${x.toFixed(1)},${y.toFixed(1)}`);
});
return parts.join(' ');
}
+8 -1
View File
@@ -26,7 +26,7 @@
</header>
<section class="a-hero">
<p class="a-label">Buiten <span class="a-trend" id="trend"></span></p>
<p class="a-label">Buiten <span class="a-trend" id="trend"></span><span class="a-hum" id="out-hum"></span></p>
<p class="a-temp num" id="temp">–</p>
<div class="a-range">
<div class="a-track"><span class="a-mark" id="mark" style="left:50%"></span></div>
@@ -35,6 +35,13 @@
<span><b class="warm" id="max">–</b><span id="max-at">hoogst om –</span></span>
</div>
</div>
<div class="a-pressure">
<p class="a-plabel">Luchtdruk <b class="num" id="press">–</b><span class="a-trend" id="press-trend"></span><span class="a-prange num" id="press-range"></span></p>
<svg class="a-spark" viewBox="0 0 560 56" preserveAspectRatio="none" aria-hidden="true">
<line class="a-spark-base" x1="0" y1="55.5" x2="560" y2="55.5"/>
<path class="a-spark-line" id="press-path" d=""/>
</svg>
</div>
</section>
<section class="a-inside" id="rooms"></section>
+21 -2
View File
@@ -9,6 +9,8 @@
--cold: #3A75A3;
--rain: #2E6A99;
--sun: #D39A2C;
--good: #4E8F5A;
--bad: #B5433B;
--iconfill: #E8EDF1;
--w: 1133px;
--h: 744px;
@@ -78,21 +80,38 @@ html, body {
.a-hero { align-self: center; }
.a-label { font-size: 22px; color: var(--muted); font-weight: 500; display: flex; gap: 14px; align-items: baseline; }
.a-trend { font-size: 17px; font-weight: 400; }
.a-temp { font-size: 200px; font-weight: 200; letter-spacing: -.045em; line-height: .95; margin: 4px 0 24px -8px; }
.a-temp { font-size: 200px; font-weight: 200; letter-spacing: -.045em; line-height: .95; margin: 4px 0 18px -8px; }
.a-hum { font-size: 17px; font-weight: 400; }
.a-hum:not(:empty)::before { content: "·"; margin-right: 14px; }
.a-range { width: 560px; }
.a-track { position: relative; height: 12px; border-radius: 6px; background: linear-gradient(90deg, var(--cold), var(--warm)); }
.a-mark { position: absolute; top: 50%; width: 28px; height: 28px; border-radius: 50%; background: var(--bg); border: 5px solid var(--ink); transform: translate(-50%, -50%); transition: left .6s ease; }
.a-ends { display: flex; justify-content: space-between; margin-top: 14px; font-size: 17px; color: var(--muted); }
.a-ends b { font-size: 24px; font-weight: 600; margin-right: 6px; }
/* Luchtdruk, 24 uur */
.a-pressure { width: 560px; margin-top: 18px; }
.a-plabel { font-size: 17px; color: var(--muted); display: flex; gap: 12px; align-items: baseline; margin-bottom: 6px; }
.a-plabel b { font-size: 20px; font-weight: 600; color: var(--ink); }
.a-prange { margin-left: auto; font-size: 14px; }
.a-spark { display: block; width: 560px; height: 56px; overflow: visible; }
.a-spark-base { stroke: var(--line); stroke-width: 1; }
.a-spark-line { fill: none; stroke: var(--rain); stroke-width: 2.4; stroke-linecap: round; stroke-linejoin: round; vector-effect: non-scaling-stroke; }
/* Binnen */
.a-inside { align-self: center; }
.a-room { padding: 12px 0; border-top: 1px solid var(--line); }
.a-room:first-child { border-top: 0; }
.a-rname { font-size: 19px; color: var(--muted); }
.a-rname { font-size: 19px; color: var(--muted); display: flex; justify-content: space-between; align-items: center; }
.pill { display: inline-block; font-size: 13px; font-weight: 600; line-height: 1; padding: 5px 10px; border-radius: 999px; color: #fff; background: var(--muted); letter-spacing: .01em; white-space: nowrap; }
.pill.good { background: var(--good); }
.pill.medium { background: var(--warm); }
.pill.bad { background: var(--bad); }
.pill.unknown { background: var(--line); color: var(--muted); }
.a-rtemp { font-size: 50px; font-weight: 300; line-height: 1.05; }
.a-room.off .a-rtemp { color: var(--muted); }
.a-rmm { font-size: 17px; display: flex; gap: 18px; }
.a-rmm .rv { color: var(--muted); }
/* Onderste strook */
.a-bottom { grid-column: 1 / -1; border-top: 1px solid var(--line); padding-top: 22px; display: grid; grid-template-columns: 390px 1fr; column-gap: 36px; }
+1 -1
View File
@@ -1,6 +1,6 @@
/* Service worker: app-shell cache-first, API network-first met timeout en fallback op de laatste response. */
const VERSION = 'weerscherm-v1';
const VERSION = 'weerscherm-v2';
const SHELL = [
'./',
'./index.html',