Files
zpark-ems/frontend/src/pages/Dashboard/components/EnergyFlow.tsx

334 lines
12 KiB
TypeScript
Raw Normal View History

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>
);
}