191 lines
6.3 KiB
TypeScript
191 lines
6.3 KiB
TypeScript
|
|
import { useEffect, useRef } from 'react';
|
||
|
|
import styles from '../styles.module.css';
|
||
|
|
|
||
|
|
interface Props {
|
||
|
|
realtime: any;
|
||
|
|
overview: any;
|
||
|
|
}
|
||
|
|
|
||
|
|
interface Particle {
|
||
|
|
x: number;
|
||
|
|
y: number;
|
||
|
|
progress: number;
|
||
|
|
speed: number;
|
||
|
|
pathIndex: number;
|
||
|
|
}
|
||
|
|
|
||
|
|
export default function EnergyFlowDiagram({ realtime, overview }: Props) {
|
||
|
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
||
|
|
const particlesRef = useRef<Particle[]>([]);
|
||
|
|
const rafRef = useRef<number>(0);
|
||
|
|
|
||
|
|
const gridPower = realtime?.grid_power ?? 0;
|
||
|
|
const pvPower = realtime?.pv_power ?? 0;
|
||
|
|
const totalPower = realtime?.total_power ?? 0;
|
||
|
|
const hpPower = realtime?.heatpump_power ?? 0;
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
const container = containerRef.current;
|
||
|
|
const canvas = canvasRef.current;
|
||
|
|
if (!container || !canvas) return;
|
||
|
|
|
||
|
|
const resizeObserver = new ResizeObserver(() => {
|
||
|
|
const rect = container.getBoundingClientRect();
|
||
|
|
canvas.width = rect.width * window.devicePixelRatio;
|
||
|
|
canvas.height = rect.height * window.devicePixelRatio;
|
||
|
|
canvas.style.width = rect.width + 'px';
|
||
|
|
canvas.style.height = rect.height + 'px';
|
||
|
|
});
|
||
|
|
resizeObserver.observe(container);
|
||
|
|
|
||
|
|
// Initialize particles
|
||
|
|
particlesRef.current = [];
|
||
|
|
for (let i = 0; i < 60; i++) {
|
||
|
|
particlesRef.current.push({
|
||
|
|
x: 0, y: 0,
|
||
|
|
progress: Math.random(),
|
||
|
|
speed: 0.002 + Math.random() * 0.003,
|
||
|
|
pathIndex: Math.floor(Math.random() * 4),
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
const animate = () => {
|
||
|
|
const ctx = canvas.getContext('2d');
|
||
|
|
if (!ctx) return;
|
||
|
|
const dpr = window.devicePixelRatio;
|
||
|
|
const w = canvas.width / dpr;
|
||
|
|
const h = canvas.height / dpr;
|
||
|
|
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||
|
|
ctx.clearRect(0, 0, w, h);
|
||
|
|
|
||
|
|
// Node positions
|
||
|
|
const nodes = {
|
||
|
|
grid: { x: w * 0.12, y: h * 0.32, label: '电网', color: '#ff8c00' },
|
||
|
|
pv: { x: w * 0.12, y: h * 0.68, label: '光伏', color: '#00ff88' },
|
||
|
|
building: { x: w * 0.5, y: h * 0.5, label: '建筑负载', color: '#00d4ff' },
|
||
|
|
heatpump: { x: w * 0.85, y: h * 0.38, label: '热泵', color: '#00d4ff' },
|
||
|
|
heating: { x: w * 0.85, y: h * 0.68, label: '供暖', color: '#ff4757' },
|
||
|
|
};
|
||
|
|
|
||
|
|
// Define paths: [from, to]
|
||
|
|
const paths = [
|
||
|
|
{ from: nodes.grid, to: nodes.building, color: '#ff8c00', value: gridPower },
|
||
|
|
{ from: nodes.pv, to: nodes.building, color: '#00ff88', value: pvPower },
|
||
|
|
{ from: nodes.building, to: nodes.heatpump, color: '#00d4ff', value: hpPower },
|
||
|
|
{ from: nodes.heatpump, to: nodes.heating, color: '#ff4757', value: hpPower * 3.5 },
|
||
|
|
];
|
||
|
|
|
||
|
|
// Draw paths
|
||
|
|
paths.forEach((path) => {
|
||
|
|
ctx.beginPath();
|
||
|
|
ctx.moveTo(path.from.x, path.from.y);
|
||
|
|
// Bezier curve
|
||
|
|
const mx = (path.from.x + path.to.x) / 2;
|
||
|
|
ctx.bezierCurveTo(mx, path.from.y, mx, path.to.y, path.to.x, path.to.y);
|
||
|
|
ctx.strokeStyle = path.color + '30';
|
||
|
|
ctx.lineWidth = 3;
|
||
|
|
ctx.stroke();
|
||
|
|
});
|
||
|
|
|
||
|
|
// Animate particles
|
||
|
|
particlesRef.current.forEach((p) => {
|
||
|
|
p.progress += p.speed;
|
||
|
|
if (p.progress > 1) {
|
||
|
|
p.progress = 0;
|
||
|
|
p.pathIndex = Math.floor(Math.random() * paths.length);
|
||
|
|
}
|
||
|
|
|
||
|
|
const path = paths[p.pathIndex];
|
||
|
|
if (!path) return;
|
||
|
|
const t = p.progress;
|
||
|
|
const mx = (path.from.x + path.to.x) / 2;
|
||
|
|
// Cubic bezier interpolation
|
||
|
|
const u = 1 - t;
|
||
|
|
const x = u * u * u * path.from.x + 3 * u * u * t * mx + 3 * u * t * t * mx + t * t * t * path.to.x;
|
||
|
|
const y = u * u * u * path.from.y + 3 * u * u * t * path.from.y + 3 * u * t * t * path.to.y + t * t * t * path.to.y;
|
||
|
|
|
||
|
|
const alpha = t < 0.1 ? t / 0.1 : t > 0.9 ? (1 - t) / 0.1 : 1;
|
||
|
|
ctx.beginPath();
|
||
|
|
ctx.arc(x, y, 3, 0, Math.PI * 2);
|
||
|
|
ctx.fillStyle = path.color;
|
||
|
|
ctx.globalAlpha = alpha * 0.9;
|
||
|
|
ctx.fill();
|
||
|
|
|
||
|
|
// Glow
|
||
|
|
ctx.beginPath();
|
||
|
|
ctx.arc(x, y, 8, 0, Math.PI * 2);
|
||
|
|
ctx.fillStyle = path.color;
|
||
|
|
ctx.globalAlpha = alpha * 0.2;
|
||
|
|
ctx.fill();
|
||
|
|
|
||
|
|
ctx.globalAlpha = 1;
|
||
|
|
});
|
||
|
|
|
||
|
|
// Draw nodes
|
||
|
|
Object.values(nodes).forEach((node) => {
|
||
|
|
// Node bg
|
||
|
|
ctx.beginPath();
|
||
|
|
const rw = 60, rh = 36, r = 8;
|
||
|
|
const nx = node.x - rw, ny = node.y - rh;
|
||
|
|
const nw = rw * 2, nh = rh * 2;
|
||
|
|
ctx.moveTo(nx + r, ny);
|
||
|
|
ctx.lineTo(nx + nw - r, ny);
|
||
|
|
ctx.quadraticCurveTo(nx + nw, ny, nx + nw, ny + r);
|
||
|
|
ctx.lineTo(nx + nw, ny + nh - r);
|
||
|
|
ctx.quadraticCurveTo(nx + nw, ny + nh, nx + nw - r, ny + nh);
|
||
|
|
ctx.lineTo(nx + r, ny + nh);
|
||
|
|
ctx.quadraticCurveTo(nx, ny + nh, nx, ny + nh - r);
|
||
|
|
ctx.lineTo(nx, ny + r);
|
||
|
|
ctx.quadraticCurveTo(nx, ny, nx + r, ny);
|
||
|
|
ctx.closePath();
|
||
|
|
ctx.fillStyle = 'rgba(6, 30, 62, 0.95)';
|
||
|
|
ctx.fill();
|
||
|
|
ctx.strokeStyle = node.color + '66';
|
||
|
|
ctx.lineWidth = 1.5;
|
||
|
|
ctx.stroke();
|
||
|
|
|
||
|
|
// Shadow glow
|
||
|
|
ctx.shadowColor = node.color;
|
||
|
|
ctx.shadowBlur = 12;
|
||
|
|
ctx.strokeStyle = node.color + '33';
|
||
|
|
ctx.stroke();
|
||
|
|
ctx.shadowBlur = 0;
|
||
|
|
|
||
|
|
// Label
|
||
|
|
ctx.fillStyle = 'rgba(224, 232, 240, 0.7)';
|
||
|
|
ctx.font = '12px system-ui, sans-serif';
|
||
|
|
ctx.textAlign = 'center';
|
||
|
|
ctx.fillText(node.label, node.x, node.y - 8);
|
||
|
|
|
||
|
|
// Value
|
||
|
|
let val = '0 kW';
|
||
|
|
if (node.label === '电网') val = gridPower.toFixed(1) + ' kW';
|
||
|
|
else if (node.label === '光伏') val = pvPower.toFixed(1) + ' kW';
|
||
|
|
else if (node.label === '建筑负载') val = totalPower.toFixed(1) + ' kW';
|
||
|
|
else if (node.label === '热泵') val = hpPower.toFixed(1) + ' kW';
|
||
|
|
else if (node.label === '供暖') val = (hpPower * 3.5).toFixed(1) + ' kW';
|
||
|
|
|
||
|
|
ctx.fillStyle = node.color;
|
||
|
|
ctx.font = 'bold 15px system-ui, sans-serif';
|
||
|
|
ctx.fillText(val, node.x, node.y + 12);
|
||
|
|
});
|
||
|
|
|
||
|
|
rafRef.current = requestAnimationFrame(animate);
|
||
|
|
};
|
||
|
|
|
||
|
|
rafRef.current = requestAnimationFrame(animate);
|
||
|
|
|
||
|
|
return () => {
|
||
|
|
cancelAnimationFrame(rafRef.current);
|
||
|
|
resizeObserver.disconnect();
|
||
|
|
};
|
||
|
|
}, [gridPower, pvPower, totalPower, hpPower]);
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div ref={containerRef} className={styles.energyFlowWrap}>
|
||
|
|
<canvas ref={canvasRef} style={{ display: 'block', width: '100%', height: '100%' }} />
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|