Add air quality history charts
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user