Files
zpark-ems/frontend/src/pages/Dashboard/components/EnergyFlow.tsx
Du Wenbo ec3aab28c1 feat: energy flow, weather, comparison, PWA, alarm subs (v1.5.0)
7 new features inspired by iSolarCloud:

1. Animated Energy Flow Diagram — SVG/CSS animated power flow
   between PV, Load, Grid, HeatPump with real-time values
2. Weather Widget — temperature/condition on dashboard header
3. Curve Template Library — save/load Data Query presets
4. Enhanced Device Comparison — multi-device power overlay chart
5. Dispersion Rate Analysis — statistical variation across inverters
   with outlier detection (new Analysis tab)
6. PWA Support — manifest.json + service worker for mobile install
7. Alarm Subscription UI — configurable notification preferences

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 10:35:50 +08:00

334 lines
12 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useMemo } from 'react';
interface Props {
realtime?: {
pv_power: number;
heatpump_power: number;
total_load: number;
grid_power: number;
};
}
export default function EnergyFlow({ realtime }: Props) {
const pv = realtime?.pv_power || 0;
const load = realtime?.total_load || 0;
const grid = realtime?.grid_power || 0;
const hp = realtime?.heatpump_power || 0;
// Calculate flows
const pvToLoad = Math.min(pv, load);
const pvToGrid = Math.max(0, pv - load);
const gridToLoad = Math.max(0, grid);
const gridExport = Math.max(0, -grid);
const selfUseRate = load > 0 ? ((pvToLoad / load) * 100).toFixed(1) : '0.0';
// Determine which flows are active (> 0.1 kW threshold)
const flows = useMemo(() => ({
pvToLoad: pvToLoad > 0.1,
pvToGrid: pvToGrid > 0.1 || gridExport > 0.1,
gridToLoad: gridToLoad > 0.1,
gridToHp: hp > 0.1,
}), [pvToLoad, pvToGrid, gridExport, gridToLoad, hp]);
// SVG layout constants
const W = 560;
const H = 340;
// Node positions (center points)
const nodes = {
pv: { x: W / 2, y: 40 },
load: { x: 100, y: 200 },
grid: { x: W - 100, y: 200 },
heatpump: { x: W / 2, y: 300 },
};
return (
<div style={{ width: '100%', position: 'relative' }}>
<style>{`
@keyframes dashFlow {
to { stroke-dashoffset: -24; }
}
@keyframes dashFlowReverse {
to { stroke-dashoffset: 24; }
}
@keyframes nodeGlow {
0%, 100% { filter: drop-shadow(0 0 4px var(--glow-color)); }
50% { filter: drop-shadow(0 0 12px var(--glow-color)); }
}
@keyframes pulseValue {
0%, 100% { opacity: 1; }
50% { opacity: 0.75; }
}
.ef-flow-line {
fill: none;
stroke-width: 3;
stroke-dasharray: 12 12;
animation: dashFlow 0.8s linear infinite;
}
.ef-flow-line-reverse {
fill: none;
stroke-width: 3;
stroke-dasharray: 12 12;
animation: dashFlowReverse 0.8s linear infinite;
}
.ef-flow-bg {
fill: none;
stroke-width: 6;
opacity: 0.1;
}
.ef-node-rect {
rx: 12;
ry: 12;
stroke-width: 2;
}
.ef-node-group {
animation: nodeGlow 3s ease-in-out infinite;
}
.ef-node-icon {
font-size: 22px;
text-anchor: middle;
dominant-baseline: central;
}
.ef-node-label {
fill: rgba(255,255,255,0.65);
font-size: 11px;
text-anchor: middle;
font-weight: 500;
}
.ef-node-value {
fill: #fff;
font-size: 15px;
text-anchor: middle;
font-weight: 700;
}
.ef-flow-value {
font-size: 11px;
font-weight: 600;
text-anchor: middle;
dominant-baseline: central;
}
.ef-active-value {
animation: pulseValue 2s ease-in-out infinite;
}
.ef-inactive {
opacity: 0.2;
}
`}</style>
<svg
viewBox={`0 0 ${W} ${H}`}
style={{ width: '100%', height: 'auto', maxHeight: 340 }}
xmlns="http://www.w3.org/2000/svg"
>
<defs>
{/* Glow filters */}
<filter id="glowGreen" x="-50%" y="-50%" width="200%" height="200%">
<feDropShadow dx="0" dy="0" stdDeviation="4" floodColor="#52c41a" floodOpacity="0.6" />
</filter>
<filter id="glowBlue" x="-50%" y="-50%" width="200%" height="200%">
<feDropShadow dx="0" dy="0" stdDeviation="4" floodColor="#1890ff" floodOpacity="0.6" />
</filter>
<filter id="glowOrange" x="-50%" y="-50%" width="200%" height="200%">
<feDropShadow dx="0" dy="0" stdDeviation="4" floodColor="#ff8c00" floodOpacity="0.6" />
</filter>
<filter id="glowCyan" x="-50%" y="-50%" width="200%" height="200%">
<feDropShadow dx="0" dy="0" stdDeviation="4" floodColor="#13c2c2" floodOpacity="0.6" />
</filter>
{/* Gradient for PV -> Load (green) */}
<linearGradient id="gradPvLoad" x1={nodes.pv.x} y1={nodes.pv.y} x2={nodes.load.x} y2={nodes.load.y} gradientUnits="userSpaceOnUse">
<stop offset="0%" stopColor="#52c41a" />
<stop offset="100%" stopColor="#1890ff" />
</linearGradient>
{/* Gradient for PV -> Grid (green to orange) */}
<linearGradient id="gradPvGrid" x1={nodes.pv.x} y1={nodes.pv.y} x2={nodes.grid.x} y2={nodes.grid.y} gradientUnits="userSpaceOnUse">
<stop offset="0%" stopColor="#52c41a" />
<stop offset="100%" stopColor="#ff8c00" />
</linearGradient>
{/* Gradient for Grid -> Load (orange to blue) */}
<linearGradient id="gradGridLoad" x1={nodes.grid.x} y1={nodes.grid.y} x2={nodes.load.x} y2={nodes.load.y} gradientUnits="userSpaceOnUse">
<stop offset="0%" stopColor="#ff8c00" />
<stop offset="100%" stopColor="#1890ff" />
</linearGradient>
{/* Gradient for Grid -> HeatPump (orange to cyan) */}
<linearGradient id="gradGridHp" x1={nodes.grid.x} y1={nodes.grid.y} x2={nodes.heatpump.x} y2={nodes.heatpump.y} gradientUnits="userSpaceOnUse">
<stop offset="0%" stopColor="#ff8c00" />
<stop offset="100%" stopColor="#13c2c2" />
</linearGradient>
</defs>
{/* ===== FLOW LINES ===== */}
{/* PV -> Load */}
<path
d={`M ${nodes.pv.x} ${nodes.pv.y + 30} C ${nodes.pv.x} ${nodes.pv.y + 80}, ${nodes.load.x} ${nodes.load.y - 80}, ${nodes.load.x} ${nodes.load.y - 30}`}
className="ef-flow-bg"
stroke="url(#gradPvLoad)"
/>
<path
d={`M ${nodes.pv.x} ${nodes.pv.y + 30} C ${nodes.pv.x} ${nodes.pv.y + 80}, ${nodes.load.x} ${nodes.load.y - 80}, ${nodes.load.x} ${nodes.load.y - 30}`}
className={flows.pvToLoad ? 'ef-flow-line' : 'ef-flow-line ef-inactive'}
stroke="url(#gradPvLoad)"
/>
{flows.pvToLoad && (
<text
x={(nodes.pv.x + nodes.load.x) / 2 - 40}
y={(nodes.pv.y + nodes.load.y) / 2 - 10}
className="ef-flow-value ef-active-value"
fill="#52c41a"
>
{pvToLoad.toFixed(1)} kW
</text>
)}
{/* PV -> Grid (export) */}
<path
d={`M ${nodes.pv.x} ${nodes.pv.y + 30} C ${nodes.pv.x} ${nodes.pv.y + 80}, ${nodes.grid.x} ${nodes.grid.y - 80}, ${nodes.grid.x} ${nodes.grid.y - 30}`}
className="ef-flow-bg"
stroke="url(#gradPvGrid)"
/>
<path
d={`M ${nodes.pv.x} ${nodes.pv.y + 30} C ${nodes.pv.x} ${nodes.pv.y + 80}, ${nodes.grid.x} ${nodes.grid.y - 80}, ${nodes.grid.x} ${nodes.grid.y - 30}`}
className={flows.pvToGrid ? 'ef-flow-line' : 'ef-flow-line ef-inactive'}
stroke="url(#gradPvGrid)"
/>
{flows.pvToGrid && (
<text
x={(nodes.pv.x + nodes.grid.x) / 2 + 40}
y={(nodes.pv.y + nodes.grid.y) / 2 - 10}
className="ef-flow-value ef-active-value"
fill="#ff8c00"
>
{(pvToGrid || gridExport).toFixed(1)} kW
</text>
)}
{/* Grid -> Load (import) */}
<path
d={`M ${nodes.grid.x} ${nodes.grid.y} L ${nodes.load.x + 70} ${nodes.load.y}`}
className="ef-flow-bg"
stroke="url(#gradGridLoad)"
/>
<path
d={`M ${nodes.grid.x} ${nodes.grid.y} L ${nodes.load.x + 70} ${nodes.load.y}`}
className={flows.gridToLoad ? 'ef-flow-line-reverse' : 'ef-flow-line-reverse ef-inactive'}
stroke="url(#gradGridLoad)"
/>
{flows.gridToLoad && (
<text
x={(nodes.grid.x + nodes.load.x + 70) / 2}
y={nodes.load.y - 14}
className="ef-flow-value ef-active-value"
fill="#ff8c00"
>
{gridToLoad.toFixed(1)} kW
</text>
)}
{/* Load -> HeatPump */}
<path
d={`M ${nodes.load.x} ${nodes.load.y + 30} C ${nodes.load.x} ${nodes.load.y + 70}, ${nodes.heatpump.x} ${nodes.heatpump.y - 40}, ${nodes.heatpump.x} ${nodes.heatpump.y - 20}`}
className="ef-flow-bg"
stroke="url(#gradGridHp)"
/>
<path
d={`M ${nodes.load.x} ${nodes.load.y + 30} C ${nodes.load.x} ${nodes.load.y + 70}, ${nodes.heatpump.x} ${nodes.heatpump.y - 40}, ${nodes.heatpump.x} ${nodes.heatpump.y - 20}`}
className={flows.gridToHp ? 'ef-flow-line' : 'ef-flow-line ef-inactive'}
stroke="#13c2c2"
/>
{flows.gridToHp && (
<text
x={(nodes.load.x + nodes.heatpump.x) / 2 - 20}
y={nodes.heatpump.y - 30}
className="ef-flow-value ef-active-value"
fill="#13c2c2"
>
{hp.toFixed(1)} kW
</text>
)}
{/* ===== NODES ===== */}
{/* PV Solar Node */}
<g className="ef-node-group" style={{ '--glow-color': '#52c41a' } as React.CSSProperties}>
<rect
x={nodes.pv.x - 55} y={nodes.pv.y - 28}
width={110} height={56}
className="ef-node-rect"
fill="rgba(82, 196, 26, 0.12)"
stroke="#52c41a"
filter="url(#glowGreen)"
/>
<text x={nodes.pv.x - 30} y={nodes.pv.y - 4} className="ef-node-icon"></text>
<text x={nodes.pv.x + 10} y={nodes.pv.y - 6} className="ef-node-label"></text>
<text x={nodes.pv.x} y={nodes.pv.y + 16} className="ef-node-value" fill="#52c41a">
{pv.toFixed(1)} kW
</text>
</g>
{/* Building Load Node */}
<g className="ef-node-group" style={{ '--glow-color': '#1890ff' } as React.CSSProperties}>
<rect
x={nodes.load.x - 55} y={nodes.load.y - 28}
width={110} height={56}
className="ef-node-rect"
fill="rgba(24, 144, 255, 0.12)"
stroke="#1890ff"
filter="url(#glowBlue)"
/>
<text x={nodes.load.x - 30} y={nodes.load.y - 4} className="ef-node-icon">🏢</text>
<text x={nodes.load.x + 10} y={nodes.load.y - 6} className="ef-node-label"></text>
<text x={nodes.load.x} y={nodes.load.y + 16} className="ef-node-value" fill="#1890ff">
{load.toFixed(1)} kW
</text>
</g>
{/* Grid Node */}
<g className="ef-node-group" style={{ '--glow-color': '#ff8c00' } as React.CSSProperties}>
<rect
x={nodes.grid.x - 55} y={nodes.grid.y - 28}
width={110} height={56}
className="ef-node-rect"
fill="rgba(255, 140, 0, 0.12)"
stroke="#ff8c00"
filter="url(#glowOrange)"
/>
<text x={nodes.grid.x - 30} y={nodes.grid.y - 4} className="ef-node-icon"></text>
<text x={nodes.grid.x + 10} y={nodes.grid.y - 6} className="ef-node-label">
{grid >= 0 ? '电网购入' : '电网输出'}
</text>
<text x={nodes.grid.x} y={nodes.grid.y + 16} className="ef-node-value" fill="#ff8c00">
{Math.abs(grid).toFixed(1)} kW
</text>
</g>
{/* HeatPump Node */}
<g className="ef-node-group" style={{ '--glow-color': '#13c2c2' } as React.CSSProperties}>
<rect
x={nodes.heatpump.x - 55} y={nodes.heatpump.y - 28}
width={110} height={56}
className="ef-node-rect"
fill="rgba(19, 194, 194, 0.12)"
stroke="#13c2c2"
filter="url(#glowCyan)"
/>
<text x={nodes.heatpump.x - 30} y={nodes.heatpump.y - 4} className="ef-node-icon">🔥</text>
<text x={nodes.heatpump.x + 10} y={nodes.heatpump.y - 6} className="ef-node-label"></text>
<text x={nodes.heatpump.x} y={nodes.heatpump.y + 16} className="ef-node-value" fill="#13c2c2">
{hp.toFixed(1)} kW
</text>
</g>
{/* Self-consumption badge */}
<rect x={W / 2 - 60} y={H / 2 - 14} width={120} height={28} rx={14}
fill="rgba(82, 196, 26, 0.15)" stroke="#52c41a" strokeWidth={1} />
<text x={W / 2} y={H / 2 + 4} textAnchor="middle" fill="#52c41a"
fontSize={12} fontWeight={600}>
{selfUseRate}%
</text>
</svg>
</div>
);
}