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>
334 lines
12 KiB
TypeScript
334 lines
12 KiB
TypeScript
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>
|
||
);
|
||
}
|