| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193 |
- import React, { useEffect, useRef } from 'react';
- import './style.less';
- interface TimeSeriesChartProps {
- labels: string[];
- barData1: number[];
- barData2: number[];
- }
- const TimeSeriesChart: React.FC<TimeSeriesChartProps> = ({ labels, barData1, barData2 }) => {
- const canvasRef = useRef<HTMLCanvasElement>(null);
- const animationIdRef = useRef<number>();
- const progressRef = useRef(0);
- useEffect(() => {
- const canvas = canvasRef.current;
- if (!canvas) return;
- const ctx = canvas.getContext('2d');
- if (!ctx) return;
- // 防抖函数
- const debounce = (func: () => void, wait: number) => {
- let timeout: NodeJS.Timeout;
- return () => {
- clearTimeout(timeout);
- timeout = setTimeout(() => func(), wait);
- };
- };
- // 数据校验
- if (labels.length === 0 || barData1.length === 0 || barData2.length === 0) {
- console.warn('无数据可绘制');
- return;
- }
- if (barData1.length !== labels.length || barData2.length !== labels.length) {
- console.error('labels、barData1 和 barData2 的长度不一致');
- return;
- }
- // 计算最大值
- const maxDataValue = Math.max(...barData1, ...barData2);
- if (!isFinite(maxDataValue) || maxDataValue < 0) {
- console.error('数据中可能包含非数值或者无效数值');
- return;
- }
- // 如果最大值为0,表示所有数据都是0,此时可以选择不绘制或给出提示
- if (maxDataValue === 0) {
- console.warn('所有数据均为0或无效数据');
- return;
- }
- const yLabels = [0, maxDataValue * 0.2, maxDataValue * 0.4, maxDataValue * 0.6, maxDataValue * 0.8, maxDataValue];
- const resizeCanvas = () => {
- const dpr = window.devicePixelRatio || 1;
- const rect = canvas.getBoundingClientRect();
- canvas.width = rect.width * dpr;
- canvas.height = rect.height * dpr;
- ctx.setTransform(dpr, 0, 0, dpr, 0, 0); // 重置并缩放
- progressRef.current = 0; // 重置动画进度
- draw(); // 调整大小后重新绘制
- };
- const draw = () => {
- // 清除画布
- ctx.clearRect(0, 0, canvas.width, canvas.height);
- const chartWidth = canvas.clientWidth;
- const chartHeight = canvas.clientHeight;
- const leftPadding = 50;
- const rightPadding = 20;
- const bottomPadding = chartHeight * 0.2;
- const topPadding = chartHeight * 0.1;
- const totalBars = labels.length;
- const barGroupWidth = (chartWidth - leftPadding - rightPadding) / totalBars;
-
- // 避免除零
- if (!isFinite(barGroupWidth) || barGroupWidth <= 0) {
- console.error('barGroupWidth 计算不正确,请检查数据和尺寸设置');
- return;
- }
- const barWidth = barGroupWidth / 3.5;
- const maxBarHeight = chartHeight - topPadding - bottomPadding;
- const fontSize = chartWidth * 0.02;
- // 绘制y轴标签和网格线
- yLabels.forEach(value => {
- const y = topPadding + (1 - value / maxDataValue) * maxBarHeight;
- ctx.strokeStyle = '#3e4b5b';
- ctx.lineWidth = 1;
- ctx.beginPath();
- ctx.moveTo(leftPadding, y);
- ctx.lineTo(chartWidth - rightPadding, y);
- ctx.stroke();
- ctx.fillStyle = '#FFFFFF';
- ctx.textAlign = 'right';
- ctx.textBaseline = 'middle';
- ctx.font = `${fontSize * 0.6}px Arial`;
- ctx.fillText(`${Math.round(value * 100) / 100}`, leftPadding - 10, y);
- });
- // 绘制单个柱子的函数
- const drawBar = (
- value: number,
- index: number,
- barOffset: number,
- colorStart: string,
- colorEnd: string
- ) => {
- // 避免除零和无效值
- if (maxDataValue === 0) return;
- const barHeight = (value / maxDataValue) * maxBarHeight * progressRef.current;
- const x = leftPadding + index * barGroupWidth + barWidth * barOffset;
- const y = topPadding + (maxBarHeight - barHeight);
- // 在创建渐变前校验数值
- if (!isFinite(x) || !isFinite(y) || !isFinite(barHeight)) {
- console.error('Invalid value for gradient creation:', { x, y, barHeight });
- return;
- }
- const gradient = ctx.createLinearGradient(x, y, x, y + barHeight);
- gradient.addColorStop(0, colorStart);
- gradient.addColorStop(1, colorEnd);
- ctx.fillStyle = gradient;
- ctx.fillRect(x, y, barWidth, barHeight);
- // 显示柱子值
- ctx.fillStyle = '#FFFFFF';
- ctx.textAlign = 'center';
- ctx.textBaseline = 'bottom';
- ctx.font = `${fontSize * 0.5}px Arial`;
- ctx.fillText(`${value}`, x + barWidth / 2, y - 5);
- };
- // 绘制 barData1 的柱子
- barData1.forEach((value, index) => {
- drawBar(value, index, 0.25, 'rgba(128, 191, 255, 1)', 'rgba(128, 191, 255, 0)');
- });
- // 绘制 barData2 的柱子
- barData2.forEach((value, index) => {
- drawBar(value, index, 1.25, 'rgba(255, 234, 128, 1)', 'rgba(255, 234, 128, 0)');
- });
- // 绘制 x 轴标签
- labels.forEach((label, index) => {
- const x = leftPadding + index * barGroupWidth + barGroupWidth / 2;
- ctx.fillStyle = '#FFFFFF';
- ctx.textAlign = 'center';
- ctx.textBaseline = 'top';
- ctx.font = `${fontSize * 0.7}px Arial`;
- ctx.fillText(label, x, chartHeight - bottomPadding + 10);
- });
- // 动画效果
- if (progressRef.current < 1) {
- progressRef.current += 0.02;
- animationIdRef.current = requestAnimationFrame(draw);
- }
- };
- // 初始化尺寸并首次绘制
- resizeCanvas();
- const handleResize = debounce(() => {
- resizeCanvas();
- }, 100);
- window.addEventListener('resize', handleResize);
- // 启动动画
- animationIdRef.current = requestAnimationFrame(draw);
- return () => {
- window.removeEventListener('resize', handleResize);
- if (animationIdRef.current) {
- cancelAnimationFrame(animationIdRef.current);
- }
- };
- }, [labels, barData1, barData2]);
- return <canvas ref={canvasRef} className="time-series-chart" />;
- };
- export default TimeSeriesChart;
|