index.tsx 6.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193
  1. import React, { useEffect, useRef } from 'react';
  2. import './style.less';
  3. interface TimeSeriesChartProps {
  4. labels: string[];
  5. barData1: number[];
  6. barData2: number[];
  7. }
  8. const TimeSeriesChart: React.FC<TimeSeriesChartProps> = ({ labels, barData1, barData2 }) => {
  9. const canvasRef = useRef<HTMLCanvasElement>(null);
  10. const animationIdRef = useRef<number>();
  11. const progressRef = useRef(0);
  12. useEffect(() => {
  13. const canvas = canvasRef.current;
  14. if (!canvas) return;
  15. const ctx = canvas.getContext('2d');
  16. if (!ctx) return;
  17. // 防抖函数
  18. const debounce = (func: () => void, wait: number) => {
  19. let timeout: NodeJS.Timeout;
  20. return () => {
  21. clearTimeout(timeout);
  22. timeout = setTimeout(() => func(), wait);
  23. };
  24. };
  25. // 数据校验
  26. if (labels.length === 0 || barData1.length === 0 || barData2.length === 0) {
  27. console.warn('无数据可绘制');
  28. return;
  29. }
  30. if (barData1.length !== labels.length || barData2.length !== labels.length) {
  31. console.error('labels、barData1 和 barData2 的长度不一致');
  32. return;
  33. }
  34. // 计算最大值
  35. const maxDataValue = Math.max(...barData1, ...barData2);
  36. if (!isFinite(maxDataValue) || maxDataValue < 0) {
  37. console.error('数据中可能包含非数值或者无效数值');
  38. return;
  39. }
  40. // 如果最大值为0,表示所有数据都是0,此时可以选择不绘制或给出提示
  41. if (maxDataValue === 0) {
  42. console.warn('所有数据均为0或无效数据');
  43. return;
  44. }
  45. const yLabels = [0, maxDataValue * 0.2, maxDataValue * 0.4, maxDataValue * 0.6, maxDataValue * 0.8, maxDataValue];
  46. const resizeCanvas = () => {
  47. const dpr = window.devicePixelRatio || 1;
  48. const rect = canvas.getBoundingClientRect();
  49. canvas.width = rect.width * dpr;
  50. canvas.height = rect.height * dpr;
  51. ctx.setTransform(dpr, 0, 0, dpr, 0, 0); // 重置并缩放
  52. progressRef.current = 0; // 重置动画进度
  53. draw(); // 调整大小后重新绘制
  54. };
  55. const draw = () => {
  56. // 清除画布
  57. ctx.clearRect(0, 0, canvas.width, canvas.height);
  58. const chartWidth = canvas.clientWidth;
  59. const chartHeight = canvas.clientHeight;
  60. const leftPadding = 50;
  61. const rightPadding = 20;
  62. const bottomPadding = chartHeight * 0.2;
  63. const topPadding = chartHeight * 0.1;
  64. const totalBars = labels.length;
  65. const barGroupWidth = (chartWidth - leftPadding - rightPadding) / totalBars;
  66. // 避免除零
  67. if (!isFinite(barGroupWidth) || barGroupWidth <= 0) {
  68. console.error('barGroupWidth 计算不正确,请检查数据和尺寸设置');
  69. return;
  70. }
  71. const barWidth = barGroupWidth / 3.5;
  72. const maxBarHeight = chartHeight - topPadding - bottomPadding;
  73. const fontSize = chartWidth * 0.02;
  74. // 绘制y轴标签和网格线
  75. yLabels.forEach(value => {
  76. const y = topPadding + (1 - value / maxDataValue) * maxBarHeight;
  77. ctx.strokeStyle = '#3e4b5b';
  78. ctx.lineWidth = 1;
  79. ctx.beginPath();
  80. ctx.moveTo(leftPadding, y);
  81. ctx.lineTo(chartWidth - rightPadding, y);
  82. ctx.stroke();
  83. ctx.fillStyle = '#FFFFFF';
  84. ctx.textAlign = 'right';
  85. ctx.textBaseline = 'middle';
  86. ctx.font = `${fontSize * 0.6}px Arial`;
  87. ctx.fillText(`${Math.round(value * 100) / 100}`, leftPadding - 10, y);
  88. });
  89. // 绘制单个柱子的函数
  90. const drawBar = (
  91. value: number,
  92. index: number,
  93. barOffset: number,
  94. colorStart: string,
  95. colorEnd: string
  96. ) => {
  97. // 避免除零和无效值
  98. if (maxDataValue === 0) return;
  99. const barHeight = (value / maxDataValue) * maxBarHeight * progressRef.current;
  100. const x = leftPadding + index * barGroupWidth + barWidth * barOffset;
  101. const y = topPadding + (maxBarHeight - barHeight);
  102. // 在创建渐变前校验数值
  103. if (!isFinite(x) || !isFinite(y) || !isFinite(barHeight)) {
  104. console.error('Invalid value for gradient creation:', { x, y, barHeight });
  105. return;
  106. }
  107. const gradient = ctx.createLinearGradient(x, y, x, y + barHeight);
  108. gradient.addColorStop(0, colorStart);
  109. gradient.addColorStop(1, colorEnd);
  110. ctx.fillStyle = gradient;
  111. ctx.fillRect(x, y, barWidth, barHeight);
  112. // 显示柱子值
  113. ctx.fillStyle = '#FFFFFF';
  114. ctx.textAlign = 'center';
  115. ctx.textBaseline = 'bottom';
  116. ctx.font = `${fontSize * 0.5}px Arial`;
  117. ctx.fillText(`${value}`, x + barWidth / 2, y - 5);
  118. };
  119. // 绘制 barData1 的柱子
  120. barData1.forEach((value, index) => {
  121. drawBar(value, index, 0.25, 'rgba(128, 191, 255, 1)', 'rgba(128, 191, 255, 0)');
  122. });
  123. // 绘制 barData2 的柱子
  124. barData2.forEach((value, index) => {
  125. drawBar(value, index, 1.25, 'rgba(255, 234, 128, 1)', 'rgba(255, 234, 128, 0)');
  126. });
  127. // 绘制 x 轴标签
  128. labels.forEach((label, index) => {
  129. const x = leftPadding + index * barGroupWidth + barGroupWidth / 2;
  130. ctx.fillStyle = '#FFFFFF';
  131. ctx.textAlign = 'center';
  132. ctx.textBaseline = 'top';
  133. ctx.font = `${fontSize * 0.7}px Arial`;
  134. ctx.fillText(label, x, chartHeight - bottomPadding + 10);
  135. });
  136. // 动画效果
  137. if (progressRef.current < 1) {
  138. progressRef.current += 0.02;
  139. animationIdRef.current = requestAnimationFrame(draw);
  140. }
  141. };
  142. // 初始化尺寸并首次绘制
  143. resizeCanvas();
  144. const handleResize = debounce(() => {
  145. resizeCanvas();
  146. }, 100);
  147. window.addEventListener('resize', handleResize);
  148. // 启动动画
  149. animationIdRef.current = requestAnimationFrame(draw);
  150. return () => {
  151. window.removeEventListener('resize', handleResize);
  152. if (animationIdRef.current) {
  153. cancelAnimationFrame(animationIdRef.current);
  154. }
  155. };
  156. }, [labels, barData1, barData2]);
  157. return <canvas ref={canvasRef} className="time-series-chart" />;
  158. };
  159. export default TimeSeriesChart;