Files
feinstaubsensor-visualisierung/feinstaub.html
Joachim Hummel 1986134a13 Multi-Sensor-Unterstützung hinzugefügt
- Sensor-Auswahl-Dropdown in feinstaub.html implementiert
- sensor_name Feld in README und Webhook-Konfiguration dokumentiert
- Automatische Erkennung und Filterung nach Sensoren

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-14 08:45:23 +00:00

628 lines
24 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="author" content="Joachim Hummel">
<meta name="creator" content="Joachim Hummel">
<meta name="publisher" content="Next8AI">
<meta name="copyright" content="© 2025 Joachim Hummel">
<link rel="author" href="https://www.joachimhummel.de/impressum/">
<meta name="description" content="Interaktive Visualisierung von SDS/Feinstaub-Daten. Entwicklung & Umsetzung: Joachim Hummel (Next8AI).">
<meta property="og:type" content="website">
<meta property="og:locale" content="de_DE">
<meta property="og:title" content="SDS Daten Visualisierung Joachim Hummel">
<meta property="og:description" content="Interaktive Visualisierung von SDS/Feinstaub-Daten. Entwicklung & Umsetzung: Joachim Hummel (Next8AI).">
<meta property="og:url" content="https://next8ai.de/sds-visualisierung/">
<meta property="og:site_name" content="Next8AI">
<meta property="og:image" content="https://web.unixweb.home64.de/assets/sds-preview.png">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="SDS Daten Visualisierung Joachim Hummel">
<meta name="twitter:description" content="Interaktive Visualisierung von SDS/Feinstaub-Daten.">
<meta name="twitter:image" content="https://web.unixweb.home64.de/assets/sds-preview.png">
<title>SDS/Feinstaubbelastung Daten Visualisierung</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/3.9.1/chart.min.js"></script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 20px;
}
.container {
max-width: 1400px;
margin: 0 auto;
}
.header {
background: white;
padding: 30px;
border-radius: 10px;
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
margin-bottom: 20px;
}
.header h1 {
color: #333;
margin-bottom: 10px;
}
.controls {
display: flex;
gap: 15px;
flex-wrap: wrap;
align-items: center;
}
.filter-section {
width: 100%;
margin-top: 20px;
padding: 20px;
background: #f7fafc;
border-radius: 5px;
border-left: 4px solid #f6ad55;
}
.filter-controls {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 15px;
margin-top: 15px;
}
.filter-item {
display: flex;
flex-direction: column;
gap: 8px;
}
.filter-item label {
font-size: 13px;
font-weight: 600;
color: #4a5568;
}
.filter-item input[type="range"] {
width: 100%;
}
.filter-value {
font-size: 14px;
color: #2d3748;
font-weight: bold;
}
.toggle-switch {
display: flex;
align-items: center;
gap: 10px;
}
.toggle-switch input[type="checkbox"] {
width: 20px;
height: 20px;
cursor: pointer;
}
.file-input-wrapper {
position: relative;
overflow: hidden;
display: inline-block;
}
.file-input-wrapper input[type=file] {
position: absolute;
left: -9999px;
}
.btn {
padding: 10px 20px;
border: none;
border-radius: 5px;
cursor: pointer;
font-size: 14px;
font-weight: 500;
transition: all 0.3s;
}
.btn-primary {
background: #667eea;
color: white;
}
.btn-primary:hover {
background: #5568d3;
}
.btn-secondary {
background: #48bb78;
color: white;
}
.btn-secondary:hover {
background: #38a169;
}
.chart-container {
background: white;
padding: 30px;
border-radius: 10px;
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
position: relative;
height: 600px;
}
.status {
margin-top: 15px;
padding: 10px 15px;
border-radius: 5px;
font-size: 14px;
}
.status.success {
background: #c6f6d5;
color: #22543d;
}
.status.error {
background: #fed7d7;
color: #742a2a;
}
.stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 15px;
margin-top: 15px;
}
.stat-card {
background: #f7fafc;
padding: 15px;
border-radius: 5px;
border-left: 4px solid #667eea;
}
.stat-label {
font-size: 12px;
color: #718096;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.stat-value {
font-size: 24px;
font-weight: bold;
color: #2d3748;
margin-top: 5px;
}
#apiUrl {
flex: 1;
padding: 10px 15px;
border: 2px solid #e2e8f0;
border-radius: 5px;
font-size: 14px;
}
.sensor-selection {
width: 100%;
margin-top: 20px;
padding: 20px;
background: #f7fafc;
border-radius: 5px;
border-left: 4px solid #48bb78;
display: none;
}
.sensor-selection h3 {
margin-bottom: 10px;
color: #2d3748;
}
.sensor-selection select {
width: 100%;
padding: 10px 15px;
border: 2px solid #e2e8f0;
border-radius: 5px;
font-size: 14px;
background: white;
cursor: pointer;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>SDS-Feinstaubbelastung Datenvisualisierung</h1>
<div class="controls">
<div class="file-input-wrapper">
<label class="btn btn-primary" for="jsonFile">JSON Datei laden</label>
<input type="file" id="jsonFile" accept=".json">
</div>
<input type="text" id="apiUrl" placeholder="Oder API-Endpunkt eingeben (z.B. https://api.example.com/data)">
<button class="btn btn-secondary" id="loadApi">Von API laden</button>
</div>
<div id="status"></div>
<div class="filter-section">
<h3 style="margin-bottom: 10px; color: #2d3748;">Ausreißer-Filter</h3>
<div class="toggle-switch">
<input type="checkbox" id="enableFilter" checked>
<label for="enableFilter" style="margin: 0;">Automatische Ausreißer-Filterung aktivieren</label>
</div>
<div class="filter-controls" id="filterControls">
<div class="filter-item">
<label for="p1MaxSlider">SDS_P1 Maximum</label>
<input type="range" id="p1MaxSlider" min="0" max="1000" value="100" step="10">
<span class="filter-value" id="p1MaxValue">100</span>
</div>
<div class="filter-item">
<label for="p2MaxSlider">SDS_P2 Maximum</label>
<input type="range" id="p2MaxSlider" min="0" max="1000" value="100" step="10">
<span class="filter-value" id="p2MaxValue">100</span>
</div>
<div class="filter-item">
<label>Gefilterte Werte</label>
<span class="filter-value" id="filteredCount">0 Datenpunkte entfernt</span>
</div>
</div>
</div>
<div class="sensor-selection" id="sensorSelection">
<h3>Sensor-Auswahl</h3>
<select id="sensorSelect">
<option value="all">Alle Sensoren</option>
</select>
</div>
<div id="status"></div>
<div class="stats" id="stats" style="display: none;">
<div class="stat-card">
<div class="stat-label">Datenpunkte</div>
<div class="stat-value" id="statCount">0</div>
</div>
<div class="stat-card">
<div class="stat-label">SDS_P1 Durchschnitt</div>
<div class="stat-value" id="statP1Avg">0</div>
</div>
<div class="stat-card">
<div class="stat-label">SDS_P2 Durchschnitt</div>
<div class="stat-value" id="statP2Avg">0</div>
</div>
<div class="stat-card">
<div class="stat-label">Zeitraum</div>
<div class="stat-value" id="statTimeRange">-</div>
</div>
</div>
</div>
<div class="chart-container">
<canvas id="sdsChart"></canvas>
</div>
</div>
<script>
let chart = null;
let rawData = null;
let filterSettings = {
enabled: true,
p1Max: 100,
p2Max: 100,
selectedSensor: 'all'
};
function detectAndPopulateSensors(data) {
// Prüfen, ob sensor_name Feld vorhanden ist
const hasSensorName = data.length > 0 && data.some(item => item.sensor_name);
if (!hasSensorName) {
// Sensor-Auswahl ausblenden, wenn kein sensor_name vorhanden
document.getElementById('sensorSelection').style.display = 'none';
filterSettings.selectedSensor = 'all';
return;
}
// Unique Sensoren extrahieren
const sensors = [...new Set(data.map(item => item.sensor_name).filter(Boolean))];
if (sensors.length > 1) {
// Sensor-Auswahl nur anzeigen, wenn mehrere Sensoren vorhanden sind
document.getElementById('sensorSelection').style.display = 'block';
// Dropdown befüllen
const select = document.getElementById('sensorSelect');
select.innerHTML = '<option value="all">Alle Sensoren</option>';
sensors.forEach(sensor => {
const option = document.createElement('option');
option.value = sensor;
option.textContent = sensor;
select.appendChild(option);
});
} else {
// Nur ein Sensor vorhanden, Auswahl nicht nötig
document.getElementById('sensorSelection').style.display = 'none';
filterSettings.selectedSensor = sensors[0] || 'all';
}
}
function calculateOutlierThreshold(values) {
const sorted = [...values].sort((a, b) => a - b);
const q1Index = Math.floor(sorted.length * 0.25);
const q3Index = Math.floor(sorted.length * 0.75);
const q1 = sorted[q1Index];
const q3 = sorted[q3Index];
const iqr = q3 - q1;
const upperBound = q3 + (1.5 * iqr);
return Math.min(upperBound, sorted[Math.floor(sorted.length * 0.95)]);
}
function applyFilters(data) {
let filtered = data;
// Filter nach Sensor
if (filterSettings.selectedSensor !== 'all') {
filtered = filtered.filter(item => item.sensor_name === filterSettings.selectedSensor);
}
// Filter nach Ausreißern (nur wenn aktiviert)
if (filterSettings.enabled) {
const beforeOutlierFilter = filtered.length;
filtered = filtered.filter(item => {
const p1 = parseFloat(item.SDS_P1) || 0;
const p2 = parseFloat(item.SDS_P2) || 0;
return p1 <= filterSettings.p1Max && p2 <= filterSettings.p2Max;
});
const removed = beforeOutlierFilter - filtered.length;
document.getElementById('filteredCount').textContent = `${removed} Datenpunkte entfernt`;
} else {
document.getElementById('filteredCount').textContent = '0 Datenpunkte entfernt';
}
return filtered;
}
function autoDetectThresholds(data) {
const p1Values = data.map(item => parseFloat(item.SDS_P1) || 0);
const p2Values = data.map(item => parseFloat(item.SDS_P2) || 0);
const p1Threshold = calculateOutlierThreshold(p1Values);
const p2Threshold = calculateOutlierThreshold(p2Values);
filterSettings.p1Max = Math.ceil(p1Threshold);
filterSettings.p2Max = Math.ceil(p2Threshold);
document.getElementById('p1MaxSlider').value = filterSettings.p1Max;
document.getElementById('p2MaxSlider').value = filterSettings.p2Max;
document.getElementById('p1MaxSlider').max = Math.max(1000, filterSettings.p1Max * 2);
document.getElementById('p2MaxSlider').max = Math.max(1000, filterSettings.p2Max * 2);
document.getElementById('p1MaxValue').textContent = filterSettings.p1Max;
document.getElementById('p2MaxValue').textContent = filterSettings.p2Max;
}
function showStatus(message, type) {
const status = document.getElementById('status');
status.textContent = message;
status.className = `status ${type}`;
status.style.display = 'block';
}
function updateStats(data) {
const statsDiv = document.getElementById('stats');
statsDiv.style.display = 'grid';
document.getElementById('statCount').textContent = data.length;
const p1Avg = (data.reduce((sum, item) => sum + (parseFloat(item.SDS_P1) || 0), 0) / data.length).toFixed(2);
const p2Avg = (data.reduce((sum, item) => sum + (parseFloat(item.SDS_P2) || 0), 0) / data.length).toFixed(2);
document.getElementById('statP1Avg').textContent = p1Avg;
document.getElementById('statP2Avg').textContent = p2Avg;
if (data.length > 0) {
const times = data.map(item => item.Uhrzeit || item.timestamp || '');
const minTime = times[0];
const maxTime = times[times.length - 1];
const timeRange = `${minTime} - ${maxTime}`;
document.getElementById('statTimeRange').textContent = timeRange;
}
}
function processData(jsonData) {
let data = Array.isArray(jsonData) ? jsonData : (jsonData.data || jsonData.records || []);
if (data.length === 0) {
showStatus('Keine Daten gefunden. Überprüfen Sie das JSON-Format.', 'error');
return;
}
rawData = data;
detectAndPopulateSensors(rawData);
autoDetectThresholds(rawData);
renderChart();
}
function renderChart() {
if (!rawData) return;
const data = [...rawData];
data.sort((a, b) => {
const timeA = a.Uhrzeit || a.timestamp || a.Zeitstempel || a.created_at || '';
const timeB = b.Uhrzeit || b.timestamp || b.Zeitstempel || b.created_at || '';
return timeA.localeCompare(timeB);
});
const filteredData = applyFilters(data);
const labels = filteredData.map(item => {
return item.Uhrzeit || item.timestamp || item.Zeitstempel || item.created_at || 'Unbekannt';
});
const sds_p1_data = filteredData.map(item => parseFloat(item.SDS_P1) || 0);
const sds_p2_data = filteredData.map(item => parseFloat(item.SDS_P2) || 0);
updateChart(labels, sds_p1_data, sds_p2_data);
updateStats(filteredData);
showStatus(`Erfolgreich ${filteredData.length} von ${data.length} Datenpunkten geladen.`, 'success');
}
function updateChart(labels, p1Data, p2Data) {
const ctx = document.getElementById('sdsChart').getContext('2d');
if (chart) {
chart.destroy();
}
chart = new Chart(ctx, {
type: 'line',
data: {
labels: labels,
datasets: [
{
label: 'SDS_P1',
data: p1Data,
borderColor: '#667eea',
backgroundColor: 'rgba(102, 126, 234, 0.1)',
borderWidth: 3,
tension: 0.4,
pointRadius: 4,
pointHoverRadius: 6,
fill: true
},
{
label: 'SDS_P2',
data: p2Data,
borderColor: '#48bb78',
backgroundColor: 'rgba(72, 187, 120, 0.1)',
borderWidth: 3,
tension: 0.4,
pointRadius: 4,
pointHoverRadius: 6,
fill: true
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: true,
position: 'top',
labels: {
font: {
size: 14
},
padding: 20
}
},
tooltip: {
mode: 'index',
intersect: false,
backgroundColor: 'rgba(0, 0, 0, 0.8)',
padding: 12,
titleFont: {
size: 14
},
bodyFont: {
size: 13
}
}
},
scales: {
x: {
display: true,
title: {
display: true,
text: 'Zeitpunkt',
font: {
size: 14,
weight: 'bold'
}
},
ticks: {
maxRotation: 45,
minRotation: 45
}
},
y: {
display: true,
title: {
display: true,
text: 'Wert',
font: {
size: 14,
weight: 'bold'
}
},
beginAtZero: true
}
},
interaction: {
mode: 'nearest',
axis: 'x',
intersect: false
}
}
});
}
document.getElementById('jsonFile').addEventListener('change', function(e) {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = function(event) {
try {
const jsonData = JSON.parse(event.target.result);
processData(jsonData);
} catch (error) {
showStatus('Fehler beim Lesen der JSON-Datei: ' + error.message, 'error');
}
};
reader.readAsText(file);
});
document.getElementById('loadApi').addEventListener('click', async function() {
const url = document.getElementById('apiUrl').value.trim();
if (!url) {
showStatus('Bitte geben Sie eine API-URL ein.', 'error');
return;
}
try {
showStatus('Daten werden geladen...', 'success');
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP-Fehler: ${response.status}`);
}
const jsonData = await response.json();
processData(jsonData);
} catch (error) {
showStatus('Fehler beim Laden der API-Daten: ' + error.message, 'error');
}
});
// Filter Event Listeners
document.getElementById('enableFilter').addEventListener('change', function(e) {
filterSettings.enabled = e.target.checked;
document.getElementById('filterControls').style.opacity = e.target.checked ? '1' : '0.5';
renderChart();
});
document.getElementById('p1MaxSlider').addEventListener('input', function(e) {
filterSettings.p1Max = parseInt(e.target.value);
document.getElementById('p1MaxValue').textContent = filterSettings.p1Max;
renderChart();
});
document.getElementById('p2MaxSlider').addEventListener('input', function(e) {
filterSettings.p2Max = parseInt(e.target.value);
document.getElementById('p2MaxValue').textContent = filterSettings.p2Max;
renderChart();
});
// Sensor-Auswahl Event Listener
document.getElementById('sensorSelect').addEventListener('change', function(e) {
filterSettings.selectedSensor = e.target.value;
renderChart();
});
// Beispieldaten für Demo
const exampleData = [
{ Uhrzeit: '09:45:36', SDS_P1: 17.53, SDS_P2: 96272.72, sensor_name: 'Sensor-1' },
{ Uhrzeit: '10:00:38', SDS_P1: 9.15, SDS_P2: 6.22, sensor_name: 'Sensor-1' },
{ Uhrzeit: '10:15:36', SDS_P1: 9.75, SDS_P2: 5.97, sensor_name: 'Sensor-2' },
{ Uhrzeit: '10:30:36', SDS_P1: 9.93, SDS_P2: 6.35, sensor_name: 'Sensor-1' },
{ Uhrzeit: '10:45:36', SDS_P1: 9.55, SDS_P2: 5.88, sensor_name: 'Sensor-2' },
{ Uhrzeit: '11:00:36', SDS_P1: 12.15, SDS_P2: 6.93, sensor_name: 'Sensor-2' }
];
// Demo-Daten initial laden
processData(exampleData);
</script>
<footer style="margin:2rem 0; font-size:.9rem; opacity:.8">
Entwickelt von <a href="https://joachimhummel.de/">Joachim Hummel</a> · © 2025 ·
<a href="https://next8ai.de/impressum/">Impressum</a> ·
<a href="https://next8ai.de/datenschutz/">Datenschutz</a>
</footer>
</body>
</html>