2026-04-07 10:35:50 +08:00
|
|
|
|
import { useMemo } from 'react';
|
2026-04-05 23:43:24 +08:00
|
|
|
|
|
|
|
|
|
|
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;
|
2026-04-07 10:35:50 +08:00
|
|
|
|
const hp = realtime?.heatpump_power || 0;
|
2026-04-05 23:43:24 +08:00
|
|
|
|
|
2026-04-07 10:35:50 +08:00
|
|
|
|
// Calculate flows
|
|
|
|
|
|
const pvToLoad = Math.min(pv, load);
|
2026-04-05 23:43:24 +08:00
|
|
|
|
const pvToGrid = Math.max(0, pv - load);
|
2026-04-07 10:35:50 +08:00
|
|
|
|
const gridToLoad = Math.max(0, grid);
|
|
|
|
|
|
const gridExport = Math.max(0, -grid);
|
2026-04-05 23:43:24 +08:00
|
|
|
|
|
2026-04-07 10:35:50 +08:00
|
|
|
|
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 },
|
|
|
|
|
|
};
|
2026-04-05 23:43:24 +08:00
|
|
|
|
|
|
|
|
|
|
return (
|
2026-04-07 10:35:50 +08:00
|
|
|
|
<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>
|
2026-04-05 23:43:24 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|