Add air quality history charts

This commit is contained in:
2026-11-09 11:32:05 +01:00
parent d1f3cf2959
commit 7cfa2102ed
4 changed files with 329 additions and 29 deletions
+163 -9
View File
@@ -19,6 +19,10 @@ h2{font-size:1.1rem;margin:0 0 1rem}.value{font-size:clamp(2.2rem,6vw,3.6rem);fo
.unit{font-size:1.1rem;font-weight:400}.hint{min-height:1.5em;margin-bottom:0}
article.good{border-color:#15803d}article.orange{border-color:#c05a00;background:#fff4df}article.red{border-color:#c52b2b;background:#ffeded}
#status{font-weight:600}footer{font-size:.9rem;line-height:1.6}
.history{display:grid;gap:1rem;margin:1rem 0 2rem}.history article{padding:1rem}
.history h3{font-size:1.1rem;margin:0 0 .5rem}.legend{font-size:.9rem;color:#526173;margin:0 0 .5rem}
canvas{display:block;width:100%;height:15rem}#history-status{line-height:1.5}
@media(prefers-color-scheme:dark){.legend{color:#b1c0d1}}
@media(prefers-color-scheme:dark){:root{background:#101923;color:#eef3fa}article{background:#1b2837}.intro,footer{color:#b1c0d1}article.orange{background:#402a13;border-color:#ffae4a}article.red{background:#401b21;border-color:#ff7979}}
</style>
</head>
@@ -31,8 +35,17 @@ article.good{border-color:#15803d}article.orange{border-color:#c05a00;background
<article><h2>Temperatur</h2><div class="value"><span id="temperature">—</span> <span class="unit">°C</span></div><p class="hint" id="temperature-hint">Keine Messdaten</p></article>
<article><h2>Feuchtigkeit</h2><div class="value"><span id="humidity">—</span> <span class="unit">%</span></div><p class="hint" id="humidity-hint">Keine Messdaten</p></article>
</div>
<footer>CO₂: Orange bei mehr als 1200 ppm, Rot bei mehr als 1600 ppm.<br>Nur aktuelle Luftmesswerte – keine Aufzeichnung.</footer>
<noscript>Bitte JavaScript aktivieren, um die aktuellen Messwerte anzuzeigen.</noscript>
<section aria-labelledby="history-title">
<h2 id="history-title">Verlauf · letzte 10 Stunden</h2>
<p id="history-status" role="status" aria-live="polite">Verlauf wird geladen …</p>
<div class="history">
<article><h3>CO₂</h3><p class="legend">CO₂ in ppm · Hilfslinien bei 1200 / 1600 ppm, sofern im Wertebereich</p><canvas id="co2-chart" role="img" aria-label="CO₂ in ppm über die letzten 10 Stunden">CO₂-Verlauf in ppm</canvas></article>
<article><h3>Temperatur</h3><p class="legend">Temperatur in °C</p><canvas id="temperature-chart" role="img" aria-label="Temperatur in Grad Celsius über die letzten 10 Stunden">Temperaturverlauf in °C</canvas></article>
<article><h3>Feuchtigkeit</h3><p class="legend">Relative Feuchtigkeit in %</p><canvas id="humidity-chart" role="img" aria-label="Relative Feuchtigkeit in Prozent über die letzten 10 Stunden">Feuchtigkeitsverlauf in %</canvas></article>
</div>
</section>
<footer>CO₂: Orange bei mehr als 1200 ppm, Rot bei mehr als 1600 ppm.<br>Verlauf: eine Messung pro Minute, maximal 10 Stunden. Speicherung nur im RAM des Geräts; bei einem Neustart geht der Verlauf verloren. Lücken bedeuten fehlende Messdaten. Aktualisierung etwa jede Minute.</footer>
<noscript>Bitte JavaScript aktivieren, um die Messwerte und den Verlauf anzuzeigen.</noscript>
</main>
<script>
'use strict';
@@ -55,25 +68,166 @@ function render(data) {
if (valid) document.getElementById('co2-hint').textContent = co2 > 1600 ? 'Bitte lüften – CO₂ sehr hoch' : co2 > 1200 ? 'Lüften empfohlen – CO₂ erhöht' : 'CO₂ im grünen Bereich';
return available;
}
async function poll() {
const duration = 36000;
const historyStatus = document.getElementById('history-status');
const canvases = keys.map(key => document.getElementById(key + '-chart'));
const units = ['ppm', '°C', '%'];
const axisFormat = new Intl.NumberFormat('de-DE', {maximumFractionDigits:2});
let history = null;
let historyFailed = false;
let nextHistory = 0;
function drawHistory() {
const elapsed = history ? Math.max(0, (performance.now() - history.loadedAt) / 1000) : 0;
if (history) {
const last = 'Letzte Ladung: ' + history.loadedLabel;
historyStatus.textContent = historyFailed || elapsed > 90
? 'Verlauf derzeit nicht aktuell / nicht verfügbar · ' + last + ' · zwischengespeicherte Daten'
: last + ' · ' + (history.samples.length ? 'Zeitfenster: −10 h bis Jetzt' : 'Noch keine Messdaten seit Gerätestart');
} else {
historyStatus.textContent = historyFailed ? 'Verlauf nicht verfügbar · erneuter Versuch in etwa 15 Sekunden' : 'Verlauf wird geladen …';
}
const dark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const ink = dark ? '#b1c0d1' : '#526173';
const grid = dark ? '#415267' : '#d8e0e9';
const colors = dark ? ['#77c9ff', '#ffbd74', '#7edcc5'] : ['#0369a1', '#a64b00', '#087f6b'];
canvases.forEach((canvas, index) => {
const width = canvas.getBoundingClientRect().width;
const height = canvas.getBoundingClientRect().height;
if (!width || !height) return;
const ratio = window.devicePixelRatio || 1;
canvas.width = Math.round(width * ratio);
canvas.height = Math.round(height * ratio);
const ctx = canvas.getContext('2d');
ctx.setTransform(ratio, 0, 0, ratio, 0, 0);
ctx.clearRect(0, 0, width, height);
ctx.font = '12px system-ui, sans-serif';
const samples = history ? history.samples.filter(s => s[0] + elapsed <= duration) : [];
const values = samples.map(s => s[index + 1]).filter(Number.isFinite);
let low = values.length ? Math.min(...values) : 0;
let high = values.length ? Math.max(...values) : 1;
const padding = Math.max((high - low) * .1, Math.abs(low) * .02, [20, .5, 1][index]);
low -= padding;
high += padding;
const ticks = Array.from({length:5}, (_, n) => high - (high - low) * n / 4);
const left = Math.max(48, ...ticks.map(v => ctx.measureText(axisFormat.format(v)).width + 12));
const right = width - 20, top = 20, bottom = height - 34;
const x = age => left + (1 - (age + elapsed) / duration) * (right - left);
const y = value => bottom - (value - low) / (high - low) * (bottom - top);
ctx.lineWidth = 1;
ctx.textAlign = 'right';
ctx.textBaseline = 'middle';
ticks.forEach(value => {
ctx.strokeStyle = grid;
ctx.beginPath(); ctx.moveTo(left, y(value)); ctx.lineTo(right, y(value)); ctx.stroke();
ctx.fillStyle = ink;
ctx.fillText(axisFormat.format(value), left - 8, y(value));
});
ctx.textBaseline = 'top';
for (let hour = 0; hour <= 10; hour += 2) {
const px = left + hour / 10 * (right - left);
ctx.strokeStyle = grid;
ctx.beginPath(); ctx.moveTo(px, top); ctx.lineTo(px, bottom); ctx.stroke();
ctx.fillStyle = ink;
ctx.textAlign = hour === 0 ? 'left' : hour === 10 ? 'right' : 'center';
ctx.fillText(hour === 10 ? 'Jetzt' : '−' + (10 - hour) + ' h', px, bottom + 10);
}
ctx.textAlign = 'left';
ctx.fillStyle = ink;
ctx.fillText(units[index], left, 0);
if (index === 0) {
[1200, 1600].forEach((limit, i) => {
if (limit < low || limit > high) return;
ctx.strokeStyle = i ? '#c52b2b' : '#c05a00';
ctx.setLineDash([5, 4]);
ctx.beginPath(); ctx.moveTo(left, y(limit)); ctx.lineTo(right, y(limit)); ctx.stroke();
});
ctx.setLineDash([]);
}
ctx.strokeStyle = colors[index];
ctx.fillStyle = colors[index];
ctx.lineWidth = 2;
ctx.beginPath();
let previous = null;
samples.forEach(sample => {
const value = sample[index + 1];
if (!Number.isFinite(value)) { previous = null; return; }
if (previous && previous[0] - sample[0] <= 90) ctx.lineTo(x(sample[0]), y(value));
else ctx.moveTo(x(sample[0]), y(value));
previous = sample;
});
ctx.stroke();
// Dots also make isolated samples visible without inventing a line to "now".
ctx.beginPath();
samples.forEach(sample => {
if (!Number.isFinite(sample[index + 1])) return;
const px = x(sample[0]), py = y(sample[index + 1]);
ctx.moveTo(px + 1.8, py); ctx.arc(px, py, 1.8, 0, Math.PI * 2);
});
ctx.fill();
if (!values.length) {
ctx.fillStyle = ink;
ctx.textAlign = 'center';
ctx.fillText(history ? 'Keine Messdaten im Zeitfenster' : 'Verlauf nicht verfügbar', (left + right) / 2, (top + bottom) / 2);
}
});
}
async function getJSON(path) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 4000);
try {
const response = await fetch('/api/air-quality', {cache:'no-store', credentials:'omit', redirect:'error', signal:controller.signal});
const response = await fetch(path, {cache:'no-store', credentials:'omit', redirect:'error', signal:controller.signal});
if (!response.ok) throw new Error('HTTP');
const data = await response.json();
return await response.json();
} finally {
clearTimeout(timeout);
}
}
async function loadLive() {
try {
const data = await getJSON('/api/air-quality');
if (!data || Object.keys(data).length !== 3 || !keys.every(k => Object.hasOwn(data,k) && (data[k] === null || (typeof data[k] === 'number' && Number.isFinite(data[k]))))) throw new Error('Datenformat');
const count = render(data);
status.textContent = count === 3 ? 'Online · Messwerte aktuell' : count === 0 ? 'Online · Noch keine Messdaten' : 'Online · Einzelne Messwerte fehlen';
} catch (_) {
render({co2:null, temperature:null, humidity:null});
status.textContent = 'Offline · Keine Verbindung – erneuter Versuch in 5 Sekunden';
} finally {
clearTimeout(timeout);
// Schedule only after fetch and JSON parsing finish: never overlap requests.
setTimeout(poll, 5000);
}
}
async function loadHistory() {
try {
const data = await getJSON('/api/air-quality/history');
if (!data || data.duration_seconds !== duration || !Array.isArray(data.samples) || data.samples.length > 601) throw new Error('Datenformat');
let previousAge = duration;
const samples = data.samples.map(sample => {
if (!Array.isArray(sample) || sample.length !== 4 || !Number.isFinite(sample[0]) || sample[0] < 0 || sample[0] > previousAge ||
!sample.slice(1).every(v => v === null || typeof v === 'number')) throw new Error('Datenformat');
previousAge = sample[0];
return [sample[0], ...sample.slice(1).map(v => Number.isFinite(v) ? v : null)];
});
history = {samples, loadedAt:performance.now(), loadedLabel:new Date().toLocaleTimeString('de-DE')};
historyFailed = false;
nextHistory = performance.now() + 60000;
} catch (_) {
// Keep previous samples, but never describe a failed refresh as current.
historyFailed = true;
nextHistory = performance.now() + 15000;
}
}
async function poll() {
await loadLive();
const nextLive = performance.now() + 5000;
// One shared loop serializes both endpoints, each with its own abort timer.
if (performance.now() >= nextHistory) await loadHistory();
drawHistory();
setTimeout(poll, Math.max(0, nextLive - performance.now()));
}
window.addEventListener('resize', drawHistory);
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', drawHistory);
poll();
</script>
</body>