Redesign: Energie-Dashboard HA-Style — animateMotion Dots + Kreis-Nodes (v1.6.2)

- Kreisförmige Nodes wie im HA Energie-Dashboard (r=44, farbiger Rand)
- Bezier-Kurven mit wandernden Punkten via animateMotion (3 Dots/Pfad)
- Flussrichtung korrekt: reverse für Export, Entladen
- Inaktive Pfade gedimmt, keine Dots
- CSS flowFwd/flowBwd entfernt (nicht mehr benötigt)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
retr0
2026-04-28 21:49:45 +02:00
parent ef9f96e5d2
commit 11050687d0
2 changed files with 83 additions and 66 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
name: ShineBridge name: ShineBridge
version: "1.6.1" version: "1.6.2"
slug: shinebridge slug: shinebridge
description: Growatt Wechselrichter lokal in Home Assistant — Modbus TCP via ShineLAN-X, MQTT Discovery, Web UI description: Growatt Wechselrichter lokal in Home Assistant — Modbus TCP via ShineLAN-X, MQTT Discovery, Web UI
url: https://gitea.bitfire.work/retr0/shinebridge url: https://gitea.bitfire.work/retr0/shinebridge
+76 -59
View File
@@ -140,11 +140,7 @@
.kwh-card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 14px 8px; text-align: center; } .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 .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; } .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; } } .flow-dot { /* dots via animateMotion — no CSS animation needed */ }
@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;
@@ -383,13 +379,13 @@ function renderEnergy(inverters, aggregates) {
const houseW = Math.max(0, pvW + gridImport + batDchW - batChW - evW); const houseW = Math.max(0, pvW + gridImport + batDchW - batChW - evW);
const hasBat = batSoc !== undefined && batSoc !== null; const hasBat = batSoc !== undefined && batSoc !== null;
const THRESH = 20; const T = 20;
const pvOn = pvW > THRESH; const pvOn = pvW > T;
const impOn = gridImport > THRESH; const impOn = gridImport > T;
const expOn = gridExport > THRESH; const expOn = gridExport > T;
const chOn = batChW > THRESH; const chOn = batChW > T;
const dchOn = batDchW > THRESH; const dchOn = batDchW > T;
const evOn = evW > THRESH; const evOn = evW > T;
const C = { const C = {
pv:'#f0c040', imp:'#f85149', exp:'#3fb950', pv:'#f0c040', imp:'#f85149', exp:'#3fb950',
@@ -399,78 +395,99 @@ function renderEnergy(inverters, aggregates) {
function fW(w) { function fW(w) {
const a = Math.abs(w); const a = Math.abs(w);
if (a < 5) return '— W'; if (a < 5) return '—';
if (a >= 1000) return (a/1000).toFixed(2) + ' kW'; if (a >= 1000) return (a / 1000).toFixed(2) + ' kW';
return Math.round(a) + ' W'; return Math.round(a) + ' W';
} }
// SVG path icons (20×20 canvas, all stroke-based) // SVG stroke icons on 20×20 canvas
const ICONS = { const ICONS = {
solar: `<circle cx="10" cy="10" r="3.2" fill="none" stroke-width="1.6"/> solar:`<circle cx="10" cy="10" r="3.2" fill="none" stroke-width="1.7"/>
<path d="M10,2v2.5M10,15.5v2.5M2,10h2.5M15.5,10h2.5M4.3,4.3l1.8,1.8M13.9,13.9l1.8,1.8M15.7,4.3l-1.8,1.8M6.1,13.9l-1.8,1.8" stroke-width="1.6" stroke-linecap="round"/>`, <path d="M10,1.5v3M10,15.5v3M1.5,10h3M15.5,10h3M4.1,4.1l2.1,2.1M13.8,13.8l2.1,2.1M15.9,4.1l-2.1,2.1M6.2,13.8l-2.1,2.1" stroke-width="1.7" stroke-linecap="round"/>`,
grid: `<path d="M10,2 L4,17 M10,2 L16,17 M6,10.5 L14,10.5 M5,14 L15,14" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" fill="none"/>`, grid: `<path d="M10,1.5 L3.5,17 M10,1.5 L16.5,17 M5.5,10.5 L14.5,10.5 M4.5,14 L15.5,14" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" fill="none"/>`,
house: `<path d="M2,9.5 L10,2 L18,9.5" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" fill="none"/> house:`<path d="M1.5,9.5 L10,2 L18.5,9.5" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
<path d="M4,9.5 L4,18 L16,18 L16,9.5" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" fill="none"/> <path d="M3.5,9.5 L3.5,18 L16.5,18 L16.5,9.5" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
<path d="M8,18 L8,13 Q8,11 10,11 Q12,11 12,13 L12,18" stroke-width="1.6" stroke-linecap="round" fill="none"/>`, <path d="M7.5,18 L7.5,13 Q7.5,11 10,11 Q12.5,11 12.5,13 L12.5,18" stroke-width="1.7" stroke-linecap="round" fill="none"/>`,
bat: `<rect x="1.5" y="7" width="15" height="8" rx="2" fill="none" stroke-width="1.6"/> bat: `<rect x="1" y="6.5" width="15.5" height="9" rx="2.5" fill="none" stroke-width="1.7"/>
<path d="M16.5,9.5 L18.5,9.5 L18.5,12.5 L16.5,12.5" stroke-width="1.6" stroke-linecap="round" fill="none"/>`, <path d="M16.5,9 L19,9 L19,13 L16.5,13" stroke-width="1.7" stroke-linecap="round" fill="none"/>`,
ev: `<path d="M11.5,2 L5.5,11.5 L10,11.5 L8,20 L14.5,8.5 L10,8.5 Z" stroke-width="1.6" stroke-linejoin="round" stroke-linecap="round" fill="none"/>`, ev: `<path d="M12,1.5 L5.5,11.5 L10,11.5 L8,20 L14.5,8.5 L10,8.5 Z" stroke-width="1.7" stroke-linejoin="round" stroke-linecap="round" fill="none"/>`,
}; };
function icon(name, cx, cy, col) { function icon(name, cx, cy, col, sz) {
const sz = 18; sz = sz || 22;
return `<g transform="translate(${(cx-sz/2).toFixed(1)},${(cy-sz/2).toFixed(1)}) scale(${(sz/20).toFixed(3)})" stroke="${col}" fill="none">${ICONS[name]}</g>`; const s = (sz / 20).toFixed(4);
return `<g transform="translate(${(cx-sz/2).toFixed(1)},${(cy-sz/2).toFixed(1)}) scale(${s})" stroke="${col}" fill="none">${ICONS[name]}</g>`;
} }
// Node: rounded rect 106×62 // Circle node — HA style
function node(cx, cy, iconName, label, valW, col, active, sublabel) { const R = 44;
function node(cx, cy, iconName, topLabel, valW, col, active, sub) {
const c = active ? col : C.dim; const c = active ? col : C.dim;
const sw = active ? 1.5 : 1; const sw = active ? 2 : 1;
const fi = active ? '0.08' : '0.03';
return `<g> return `<g>
<rect x="${cx-53}" y="${cy-31}" width="106" height="62" rx="11" fill="${C.s2}" stroke="${c}" stroke-width="${sw}"/> <circle cx="${cx}" cy="${cy}" r="${R}" fill="${col}" fill-opacity="${fi}" stroke="${c}" stroke-width="${sw}"/>
${icon(iconName, cx, cy-12, c)} ${icon(iconName, cx, cy - 11, c, 22)}
<text x="${cx}" y="${cy+6}" text-anchor="middle" font-size="12" font-weight="700" fill="${active?col:C.txt}" dominant-baseline="middle" font-family="inherit">${fW(valW)}</text> <text x="${cx}" y="${cy+7}" text-anchor="middle" font-size="12" font-weight="700" fill="${active?col:C.txt}" dominant-baseline="middle">${fW(valW)}</text>
<text x="${cx}" y="${cy+21}" text-anchor="middle" font-size="9" fill="${C.dim}" dominant-baseline="middle" font-family="inherit" letter-spacing=".05em">${label}${sublabel?' '+sublabel:''}</text> <text x="${cx}" y="${cy+21}" text-anchor="middle" font-size="9" fill="${C.dim}" dominant-baseline="middle" letter-spacing=".06em">${topLabel}${sub?' · '+sub:''}</text>
</g>`; </g>`;
} }
// Bezier flow path // Animated dots along a named path (HA-style)
function flow(d, col, active, reverse) { function flowDots(pid, col, on, reverse, spd) {
if (!active) return `<path d="${d}" fill="none" stroke="${C.brd}" stroke-width="2" stroke-linecap="round" opacity=".35"/>`; if (!on) return '';
return `<path d="${d}" fill="none" stroke="${col}" stroke-width="3" stroke-linecap="round" class="flow-line${reverse?' reverse':''}"/>`; spd = spd || 1.6;
const kp = reverse ? '1;0' : '0;1';
return [0, spd/3, 2*spd/3].map(b =>
`<circle r="3.8" fill="${col}" opacity=".9">
<animateMotion dur="${spd.toFixed(2)}s" repeatCount="indefinite" begin="${b.toFixed(3)}s" calcMode="linear" keyPoints="${kp}" keyTimes="0;1">
<mpath href="#${pid}"/>
</animateMotion>
</circle>`
).join('');
} }
// Paths (bezier curves between node edges) // Path segments (bezier from node-edge to node-edge)
const pPV = `M 130,80 C 130,116 260,108 260,132`; // Solar(145,75) bottom→ House(260,180) top; Grid(375,75) bottom→ House top
const pGrid = `M 390,80 C 390,116 260,108 260,132`; // House bottom → Battery(145,292) top; House bottom → EV(375,292) top
const pBat = `M 207,162 C 165,162 130,204 130,236`; const SEG = [
const pEV = `M 313,162 C 355,162 390,204 390,236`; { id:'ep-pv', d:`M 145,119 C 145,155 260,155 260,136`, col:C.pv, on:pvOn, rev:false },
{ id:'ep-grid', d:`M 375,119 C 375,155 260,155 260,136`, col:impOn?C.imp:C.exp, on:impOn||expOn, rev:expOn },
{ id:'ep-bat', d:`M 245,224 C 215,260 145,260 145,248`, col:C.bat, on:hasBat&&(chOn||dchOn), rev:dchOn },
{ id:'ep-ev', d:`M 275,224 C 305,260 375,260 375,248`, col:C.ev, on:evOn, rev:false },
];
const activeSeg = SEG.filter(s => hasBat || s.id !== 'ep-bat');
const defs = activeSeg.map(s => `<path id="${s.id}" d="${s.d}" fill="none"/>`).join('');
const lines = activeSeg.map(s =>
`<use href="#${s.id}" fill="none" stroke="${s.on?s.col:C.brd}" stroke-width="${s.on?2.5:1.5}" stroke-linecap="round" ${s.on?'':'opacity=".35"'}/>`
).join('');
const dotsSvg = activeSeg.map(s => flowDots(s.id, s.col, s.on, s.rev)).join('');
const gridCol = impOn ? C.imp : expOn ? C.exp : C.dim; const gridCol = impOn ? C.imp : expOn ? C.exp : C.dim;
const gridVal = impOn ? gridImport : expOn ? gridExport : 0;
const gridLbl = impOn ? 'NETZBEZUG' : expOn ? 'EINSPEISUNG' : 'NETZ'; const gridLbl = impOn ? 'NETZBEZUG' : expOn ? 'EINSPEISUNG' : 'NETZ';
const gridVal = impOn ? gridImport : gridExport;
const batVal = chOn ? batChW : dchOn ? batDchW : 0; const batVal = chOn ? batChW : dchOn ? batDchW : 0;
const batLbl = chOn ? 'LADEN' : dchOn ? 'ENTLADEN' : 'BATTERIE'; const batLbl = chOn ? 'LADEN' : dchOn ? 'ENTLADEN' : 'BATTERIE';
const batSub = hasBat ? Math.round(batSoc)+'%' : ''; const batSub = hasBat ? Math.round(batSoc) + '%' : '';
const svgH = 310; const svg = `<svg viewBox="0 0 520 360" width="100%" style="display:block;padding:20px 10px" xmlns="http://www.w3.org/2000/svg">
const svg = `<svg viewBox="0 0 520 ${svgH}" width="100%" style="display:block;padding:18px 16px" xmlns="http://www.w3.org/2000/svg"> <defs>${defs}</defs>
${flow(pPV, C.pv, pvOn, false)} ${lines}
${flow(pGrid, gridCol, impOn||expOn, expOn)} ${dotsSvg}
${hasBat ? flow(pBat, C.bat, chOn||dchOn, dchOn) : ''} ${node(145, 75, 'solar', 'SOLAR', pvW, C.pv, pvOn, '')}
${flow(pEV, C.ev, evOn, false)} ${node(375, 75, 'grid', gridLbl, gridVal, gridCol, impOn||expOn, '')}
${node(130, 50, 'solar', 'SOLAR', pvW, C.pv, pvOn, '')} ${node(260, 180, 'house', 'HAUS', houseW, C.txt, true, '')}
${node(390, 50, 'grid', gridLbl, gridVal, gridCol, impOn||expOn, '')} ${hasBat ? node(145, 292, 'bat', batLbl, batVal, C.bat, chOn||dchOn, batSub) : ''}
${node(260, 162, 'house', 'HAUS', houseW, C.txt, true, '')} ${node(375, 292, 'ev', 'WALLBOX', evW, C.ev, evOn, '')}
${hasBat ? node(130, 265, 'bat', batLbl, batVal, C.bat, chOn||dchOn, batSub) : ''}
${node(390, 265, 'ev', 'WALLBOX', evW, C.ev, evOn, '')}
</svg>`; </svg>`;
function kwhCard(label, val, col) { function kwhCard(label, val, col) {
if (val === undefined || val === null) return ''; if (val == null) return '';
const d = val >= 100 ? val.toFixed(0) : val >= 10 ? val.toFixed(1) : val.toFixed(2); const d = val >= 100 ? val.toFixed(0) : val.toFixed(val >= 10 ? 1 : 2);
return `<div class="kwh-card" style="border-top:3px solid ${col}"> return `<div class="kwh-card" style="border-top:3px solid ${col}">
<div class="kv" style="color:${col}">${d} <span style="font-size:11px;font-weight:400;color:${C.dim}">kWh</span></div> <div class="kv" style="color:${col}">${d}<span style="font-size:10px;font-weight:400;color:${C.dim}"> kWh</span></div>
<div class="kl">${label}</div> <div class="kl">${label}</div>
</div>`; </div>`;
} }