Feature: Energie-Dashboard Tab mit SVG-Flussdiagramm (v1.6.0)

Neuer erster Tab „Energie" mit animiertem Energiefluss-Diagramm:
- SVG-Knoten: Solar, Netz, Haus, Batterie, Wallbox
- Animierte gestrichelte Linien zeigen Flussrichtung + Leistung
- Farben: PV=gelb, Bezug=rot, Einspeisung=grün, Batterie=lila, EV=blau
- kWh-Karten: PV Heute, Netzbezug/Einspeisung, Batterie Laden/Entladen
- Batterie-Knoten nur sichtbar wenn bat_soc vorhanden
- Wallbox-Leistung via invertersList (KATHREIN_WALLBOX model)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
retr0
2026-04-28 16:41:57 +02:00
parent 07b476fffa
commit 919919d5d8
+123 -4
View File
@@ -133,6 +133,19 @@
font-size: 12px; color: var(--text-dim); } font-size: 12px; color: var(--text-dim); }
.info-chip span { color: var(--text); font-weight: 600; } .info-chip span { color: var(--text); font-weight: 600; }
/* Energy Dashboard */
.energy-wrap { max-width: 580px; margin: 0 auto; }
.energy-svg-wrap { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); margin-bottom: 16px; overflow: hidden; }
.energy-kwh { display: grid; grid-template-columns: repeat(auto-fit, minmax(95px, 1fr)); gap: 10px; }
.kwh-card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 14px 8px; text-align: center; }
.kwh-card .kv { font-size: 18px; font-weight: 700; line-height: 1.2; font-variant-numeric: tabular-nums; }
.kwh-card .kl { font-size: 10px; color: var(--text-dim); text-transform: uppercase; letter-spacing: .05em; margin-top: 4px; }
@keyframes flowFwd { to { stroke-dashoffset: -16; } }
@keyframes flowBwd { to { stroke-dashoffset: 16; } }
.flow-line { stroke-dasharray: 6 6; animation: flowFwd .7s linear infinite; }
.flow-line.reverse { animation: flowBwd .7s linear infinite; }
.flow-line.inactive { animation: none; opacity: .18; }
/* Toast */ /* Toast */
.toast { position: fixed; bottom: 24px; right: 24px; padding: 12px 20px; .toast { position: fixed; bottom: 24px; right: 24px; padding: 12px 20px;
background: var(--surface2); border: 1px solid var(--border); background: var(--surface2); border: 1px solid var(--border);
@@ -162,13 +175,19 @@
<main> <main>
<div class="tabs"> <div class="tabs">
<div class="tab active" onclick="switchTab('live')">Live-Daten</div> <div class="tab active" onclick="switchTab('energy')">Energie</div>
<div class="tab" onclick="switchTab('live')">Live-Daten</div>
<div class="tab" onclick="switchTab('inverters')">Geräte</div> <div class="tab" onclick="switchTab('inverters')">Geräte</div>
<div class="tab" onclick="switchTab('settings')">Einstellungen</div> <div class="tab" onclick="switchTab('settings')">Einstellungen</div>
</div> </div>
<!-- Energy Panel -->
<div class="panel active" id="panel-energy">
<div id="energy-content"><div class="no-data">Warte auf erste Messung…</div></div>
</div>
<!-- Live Panel --> <!-- Live Panel -->
<div class="panel active" id="panel-live"> <div class="panel" id="panel-live">
<div id="live-content"><div class="no-data">Warte auf erste Messung...</div></div> <div id="live-content"><div class="no-data">Warte auf erste Messung...</div></div>
</div> </div>
@@ -330,11 +349,110 @@ function showToast(msg, type) {
} }
function switchTab(name) { function switchTab(name) {
["live","inverters","settings"].forEach((t, i) => { ["energy","live","inverters","settings"].forEach((t, i) => {
document.querySelectorAll(".tab")[i].classList.toggle("active", t === name); document.querySelectorAll(".tab")[i].classList.toggle("active", t === name);
document.querySelectorAll(".panel")[i].classList.toggle("active", t === name); document.querySelectorAll(".panel")[i].classList.toggle("active", t === name);
}); });
if (name === "live") startRefresh(); else stopRefresh(); if (name === "energy" || name === "live") startRefresh(); else stopRefresh();
}
// ── Energy Dashboard ──────────────────────────────────────────
function renderEnergy(inverters, aggregates) {
const el = document.getElementById("energy-content");
if (!aggregates || !Object.keys(aggregates).length) {
el.innerHTML = '<div class="no-data">Warte auf erste Messung…</div>';
return;
}
const pvW = aggregates.total_pv_power || 0;
const gridW = aggregates.grid_power || 0; // + = Bezug, - = Einspeisung
const batChW = aggregates.bat_charge_power || 0;
const batDchW = aggregates.bat_discharge_power|| 0;
const batSoc = aggregates.bat_soc;
let evW = 0;
Object.entries(inverters).forEach(([id, inv]) => {
const cfg = invertersList.find(c => c.id === id);
if (cfg && cfg.inverter_model === 'KATHREIN_WALLBOX')
evW += (inv.values && inv.values.total_power) || 0;
});
const gridImport = Math.max(0, gridW);
const gridExport = Math.max(0, -gridW);
const houseW = Math.max(0, pvW + gridImport + batDchW - batChW - evW);
function fW(w) {
if (Math.abs(w) < 1) return '0 W';
if (Math.abs(w) >= 1000) return (w/1000).toFixed(2) + ' kW';
return Math.round(w) + ' W';
}
function node(cx, cy, icon, label, value, col, extra) {
return `<g>
<circle cx="${cx}" cy="${cy}" r="40" fill="var(--surface2)" stroke="${col}" stroke-width="1.5"/>
<text x="${cx}" y="${cy-11}" text-anchor="middle" font-size="19" dominant-baseline="middle">${icon}</text>
<text x="${cx}" y="${cy+8}" text-anchor="middle" font-size="11" font-weight="700" fill="${col}" dominant-baseline="middle">${fW(value)}</text>
<text x="${cx}" y="${cy+24}" text-anchor="middle" font-size="9" fill="var(--text-dim)" dominant-baseline="middle">${label}</text>
${extra||''}
</g>`;
}
const C = { acc:'var(--accent)', red:'var(--red)', grn:'var(--green)', pur:'var(--purple)', blu:'var(--blue)', dim:'var(--text-dim)', brd:'var(--border)' };
// Flow line helpers
function fline(x1,y1,x2,y2, col, cls) {
return `<line x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}" stroke="${col}" stroke-width="3" class="flow-line ${cls}"/>`;
}
const hasBat = batSoc !== undefined && batSoc !== null;
const svg = `<svg viewBox="0 0 540 ${hasBat?300:240}" width="100%" style="display:block;padding:16px 20px" xmlns="http://www.w3.org/2000/svg">
<!-- PV → House -->
${fline(168,78,237,126, pvW>10?C.acc:C.brd, pvW>10?'':'inactive')}
<!-- Grid ↔ House: line goes grid→house for import, reverse for export -->
${fline(372,78,303,126,
gridImport>10?C.red:gridExport>10?C.grn:C.brd,
gridExport>10?'reverse':gridImport>10?'':'inactive')}
${hasBat ? `<!-- House ↔ Battery -->
${fline(237,170,164,222, batChW>10||batDchW>10?C.pur:C.brd, batDchW>10?'reverse':batChW>10?'':'inactive')}` : ''}
<!-- House → Car -->
${fline(303,170,376,222, evW>10?C.blu:C.brd, evW>10?'':'inactive')}
<!-- Nodes -->
${node(135, 60, '☀️', 'Solar', pvW, C.acc)}
${node(405, 60, '⚡', gridImport>10?'Netzbezug':gridExport>10?'Einspeisung':'Netz',
gridImport>10?gridImport:gridExport, gridImport>10?C.red:gridExport>10?C.grn:C.dim)}
${node(270, 148, '🏠', 'Haus', houseW, 'var(--text)')}
${hasBat ? node(135, 244, '🔋', `Batt. ${Math.round(batSoc)}%`,
batChW>batDchW?batChW:batDchW, C.pur) : ''}
${node(405, 244, '🚗', 'Wallbox', evW, evW>10?C.blu:C.dim)}
</svg>`;
function kwhCard(icon, label, val, col) {
if (val === undefined || val === null) return '';
const d = val >= 100 ? val.toFixed(0) : val >= 10 ? val.toFixed(1) : val.toFixed(2);
return `<div class="kwh-card">
<div class="kv" style="color:${col}">${d}</div>
<div class="kl">${icon} ${label}</div>
</div>`;
}
const cards = [
kwhCard('☀️','PV Heute kWh', aggregates.total_energy_today, C.acc),
kwhCard('📥','Bezug kWh', aggregates.grid_import_kwh, C.red),
kwhCard('📤','Einspeisung kWh', aggregates.grid_export_kwh, C.grn),
kwhCard('⚡','Bat. Laden kWh', aggregates.bat_charge_total, C.pur),
kwhCard('🔋','Bat. Entl. kWh', aggregates.bat_discharge_total, C.pur),
].filter(Boolean).join('');
el.innerHTML = `<div class="energy-wrap">
<div class="energy-svg-wrap">${svg}</div>
${cards ? `<div class="energy-kwh">${cards}</div>` : ''}
</div>`;
} }
// ── Live Data ───────────────────────────────────────────────── // ── Live Data ─────────────────────────────────────────────────
@@ -348,6 +466,7 @@ async function refreshData() {
document.getElementById("subtitle").textContent = document.getElementById("subtitle").textContent =
keys.length ? `${keys.length} Gerät${keys.length !== 1 ? "e" : ""}` : "Keine Geräte"; keys.length ? `${keys.length} Gerät${keys.length !== 1 ? "e" : ""}` : "Keine Geräte";
renderLive(d.inverters || {}, d.aggregates || {}); renderLive(d.inverters || {}, d.aggregates || {});
renderEnergy(d.inverters || {}, d.aggregates || {});
} catch(e) { } catch(e) {
document.getElementById("pill-mqtt").className = "pill err"; document.getElementById("pill-mqtt").className = "pill err";
} }