diff --git a/public/app.js b/public/app.js index 034a30a..5dca169 100644 --- a/public/app.js +++ b/public/app.js @@ -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 = `${co2.level === 'unknown' ? '–' : r.co2 + ' ppm'}`; return `
-

${esc(r.name)}

+

${esc(r.name)}${pill}

${off ? '–' : f1(r.temp) + '°'}

-

↓ ${off ? '–' : f1(r.min) + '°'}↑ ${off ? '–' : f1(r.max) + '°'}

+

↓ ${off ? '–' : f1(r.min) + '°'}↑ ${off ? '–' : f1(r.max) + '°'}RV ${off ? '–' : fPct(r.humidity)}

`; }); $('rooms').innerHTML = html.join(''); @@ -90,6 +94,15 @@ function renderRain(rain, now) { `).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) => `

${dayAbbr(d.date, state.tz)}

@@ -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(); diff --git a/public/format.js b/public/format.js index 2d2780f..28fbc1f 100644 --- a/public/format.js +++ b/public/format.js @@ -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(' '); +} diff --git a/public/index.html b/public/index.html index 1f2c674..150fb52 100644 --- a/public/index.html +++ b/public/index.html @@ -26,7 +26,7 @@
-

Buiten

+

Buiten

–

@@ -35,6 +35,13 @@ –hoogst om –
+
+

Luchtdruk –

+ +
diff --git a/public/styles.css b/public/styles.css index 7b3cca8..647135f 100644 --- a/public/styles.css +++ b/public/styles.css @@ -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; } diff --git a/public/sw.js b/public/sw.js index 952e016..51328f5 100644 --- a/public/sw.js +++ b/public/sw.js @@ -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', diff --git a/tests/js/format.test.js b/tests/js/format.test.js index 4addfe7..6228163 100644 --- a/tests/js/format.test.js +++ b/tests/js/format.test.js @@ -118,3 +118,36 @@ test('msUntil computes the wait until the next local time', () => { assert.equal(msUntil(now, TZ, 15), (28 * 60 - 5) * 1000 - 500); assert.equal(msUntil(new Date('2026-09-11T22:00:00.000Z'), TZ, 0), 86400000, 'precies op het moment zelf: pas morgen weer'); }); + +import { co2Level, fPct, fHpa, seriesRange, sparklinePath } from '../../public/format.js'; + +test('co2Level uses the Netatmo thresholds', () => { + assert.deepEqual(co2Level(612), { level: 'good', label: 'goed' }); + assert.deepEqual(co2Level(1000), { level: 'good', label: 'goed' }); + assert.deepEqual(co2Level(1001), { level: 'medium', label: 'matig' }); + assert.deepEqual(co2Level(1500), { level: 'medium', label: 'matig' }); + assert.deepEqual(co2Level(1501), { level: 'bad', label: 'slecht' }); + assert.deepEqual(co2Level(null), { level: 'unknown', label: '–' }); +}); + +test('fPct and fHpa', () => { + assert.equal(fPct(48), '48%'); + assert.equal(fPct(47.6), '48%'); + assert.equal(fPct(null), '–'); + assert.equal(fHpa(1014.2), '1014 hPa'); + assert.equal(fHpa(null), '–'); +}); + +test('seriesRange ignores nulls', () => { + assert.deepEqual(seriesRange([1010, null, 1015.5, 1008]), { min: 1008, max: 1015.5 }); + assert.equal(seriesRange([null, undefined]), null); +}); + +test('sparklinePath scales points into the box', () => { + assert.equal(sparklinePath([1000, 1010, 1005], 100, 50, 0), 'M0.0,50.0 L50.0,0.0 L100.0,25.0'); + assert.equal(sparklinePath([1000, 1010], 100, 50, 5), 'M5.0,45.0 L95.0,5.0'); + assert.equal(sparklinePath([1010, 1010, 1010], 100, 50, 0), 'M0.0,25.0 L50.0,25.0 L100.0,25.0', 'vlakke reeks in het midden'); + assert.equal(sparklinePath([1000, null, 1010], 100, 50, 0), 'M0.0,50.0 L100.0,0.0', 'null-punt overgeslagen'); + assert.equal(sparklinePath([1000], 100, 50), ''); + assert.equal(sparklinePath([], 100, 50), ''); +});