237 lines
12 KiB
C++
237 lines
12 KiB
C++
#pragma once
|
||
|
||
namespace esphome::air_quality_web {
|
||
|
||
static constexpr char PAGE[] = R"AQPAGE(<!doctype html>
|
||
<html lang="de">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<meta name="color-scheme" content="light dark">
|
||
<title>Luftqualität im Büro</title>
|
||
<style>
|
||
:root{font-family:system-ui,sans-serif;color-scheme:light dark;background:#f1f5f9;color:#17212f}
|
||
*{box-sizing:border-box}body{margin:0;padding:clamp(1rem,5vw,3rem)}main{max-width:58rem;margin:auto}
|
||
h1{font-size:clamp(1.7rem,5vw,2.6rem);margin-bottom:.5rem}.intro,footer{color:#526173}
|
||
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(14rem,1fr));gap:1rem;margin:2rem 0}
|
||
article{background:#fff;border:2px solid #b4c2d1;border-radius:1rem;padding:1.5rem;min-width:0}
|
||
h2{font-size:1.1rem;margin:0 0 1rem}.value{font-size:clamp(2.2rem,6vw,3.6rem);font-weight:700;font-variant-numeric:tabular-nums}
|
||
.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>
|
||
<body><main>
|
||
<h1>Luftqualität im Büro</h1>
|
||
<p class="intro">Aktuelle Messwerte · Aktualisierung alle 5 Sekunden</p>
|
||
<p id="status" role="status" aria-live="polite">Verbindung wird hergestellt …</p>
|
||
<div class="grid">
|
||
<article id="co2-card"><h2>CO₂</h2><div class="value"><span id="co2">—</span> <span class="unit">ppm</span></div><p class="hint" id="co2-hint">Keine Messdaten</p></article>
|
||
<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>
|
||
<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';
|
||
const keys = ['co2', 'temperature', 'humidity'];
|
||
const status = document.getElementById('status');
|
||
const card = document.getElementById('co2-card');
|
||
const formats = [0, 1, 1].map(d => new Intl.NumberFormat('de-DE', {minimumFractionDigits:d, maximumFractionDigits:d}));
|
||
function render(data) {
|
||
let available = 0;
|
||
keys.forEach((key, i) => {
|
||
const value = data[key];
|
||
const valid = typeof value === 'number' && Number.isFinite(value);
|
||
document.getElementById(key).textContent = valid ? formats[i].format(value) : '—';
|
||
document.getElementById(key + '-hint').textContent = valid ? 'Aktueller Messwert' : 'Keine Messdaten';
|
||
if (valid) available++;
|
||
});
|
||
const co2 = data.co2;
|
||
const valid = typeof co2 === 'number' && Number.isFinite(co2);
|
||
card.className = valid ? (co2 > 1600 ? 'red' : co2 > 1200 ? 'orange' : 'good') : '';
|
||
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;
|
||
}
|
||
const duration = 36000;
|
||
const historyStatus = document.getElementById('history-status');
|
||
const canvases = keys.map(key => document.getElementById(key + '-chart'));
|
||
const units = ['ppm', '°C', '%'];
|
||
|
||
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 = 'Zuletzt aktualisiert: ' + 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(formats[index].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(formats[index].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(path, {cache:'no-store', credentials:'omit', redirect:'error', signal:controller.signal});
|
||
if (!response.ok) throw new Error('HTTP');
|
||
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';
|
||
}
|
||
}
|
||
|
||
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>
|
||
</html>)AQPAGE";
|
||
|
||
} // namespace esphome::air_quality_web
|