0
点赞
收藏
分享

微信扫一扫

用TypeScript构建一个交互式的图表组件

一个功能完整的交互式图表组件。这个组件将支持多种图表类型、数据交互、响应式设计和丰富的配置选项。

1. 类型定义

// types/chart.ts
export interface ChartDataPoint {
  x: number | string;
  y: number;
  label?: string;
  color?: string;
}

export interface ChartDataset {
  label: string;
  data: ChartDataPoint[];
  color?: string;
  type?: 'line' | 'bar' | 'area';
}

export interface ChartOptions {
  title?: string;
  width?: number;
  height?: number;
  responsive?: boolean;
  showLegend?: boolean;
  showGrid?: boolean;
  showTooltip?: boolean;
  animation?: boolean;
  colors?: string[];
  xAxisLabel?: string;
  yAxisLabel?: string;
  minScale?: number;
  maxScale?: number;
}

export interface ChartConfig {
  datasets: ChartDataset[];
  options?: ChartOptions;
}

2. 核心图表组件

// components/InteractiveChart.tsx
import React, { useState, useEffect, useRef } from 'react';
import { ChartConfig, ChartDataset, ChartDataPoint, ChartOptions } from '../types/chart';

interface InteractiveChartProps {
  config: ChartConfig;
  className?: string;
}

const DEFAULT_COLORS = [
  '#3B82F6', // blue-500
  '#10B981', // emerald-500
  '#F59E0B', // amber-500
  '#EF4444', // red-500
  '#8B5CF6', // violet-500
  '#06B6D4', // cyan-500
];

const InteractiveChart: React.FC<InteractiveChartProps> = ({ 
  config, 
  className = '' 
}) => {
  const [hoveredPoint, setHoveredPoint] = useState<{ 
    datasetIndex: number; 
    pointIndex: number; 
    x: number; 
    y: number 
  } | null>(null);
  const [tooltipPosition, setTooltipPosition] = useState({ x: 0, y: 0 });
  const [dimensions, setDimensions] = useState({ width: 0, height: 0 });
  const containerRef = useRef<HTMLDivElement>(null);

  // 合并默认选项
  const options: ChartOptions = {
    width: 600,
    height: 400,
    responsive: true,
    showLegend: true,
    showGrid: true,
    showTooltip: true,
    animation: true,
    colors: DEFAULT_COLORS,
    ...config.options,
  };

  // 处理响应式尺寸
  useEffect(() => {
    const updateDimensions = () => {
      if (containerRef.current) {
        const { width, height } = containerRef.current.getBoundingClientRect();
        setDimensions({
          width: options.responsive ? width : options.width!,
          height: options.responsive ? height : options.height!,
        });
      }
    };

    updateDimensions();
    window.addEventListener('resize', updateDimensions);
    return () => window.removeEventListener('resize', updateDimensions);
  }, [options.responsive, options.width, options.height]);

  // 数据处理
  const processedData = config.datasets.map((dataset, datasetIndex) => ({
    ...dataset,
    color: dataset.color || options.colors![datasetIndex % options.colors!.length],
    data: dataset.data.map((point, pointIndex) => ({
      ...point,
      color: point.color || dataset.color || options.colors![datasetIndex % options.colors!.length],
    })),
  }));

  // 获取数值范围
  const getValueRange = () => {
    const allValues = processedData.flatMap(dataset => 
      dataset.data.map(point => point.y)
    );
    
    if (allValues.length === 0) return { min: 0, max: 1 };
    
    const min = options.minScale !== undefined ? options.minScale : Math.min(...allValues);
    const max = options.maxScale !== undefined ? options.maxScale : Math.max(...allValues);
    
    // 添加一些边距
    const range = max - min;
    return {
      min: min - range * 0.1,
      max: max + range * 0.1,
    };
  };

  const valueRange = getValueRange();
  const { width, height } = dimensions;
  const margin = { top: 40, right: 80, bottom: 60, left: 60 };
  const chartWidth = width - margin.left - margin.right;
  const chartHeight = height - margin.top - margin.bottom;

  // 坐标转换函数
  const xScale = (value: number | string, index: number): number => {
    if (typeof value === 'number') {
      const allXValues = processedData.flatMap(dataset => 
        dataset.data.map(point => point.x)
      ).filter(x => typeof x === 'number') as number[];
      
      if (allXValues.length === 0) return (index / (processedData[0]?.data.length - 1 || 1)) * chartWidth;
      
      const min = Math.min(...allXValues);
      const max = Math.max(...allXValues);
      return chartWidth * ((value - min) / (max - min));
    }
    
    return (index / (processedData[0]?.data.length - 1 || 1)) * chartWidth;
  };

  const yScale = (value: number): number => {
    return chartHeight - ((value - valueRange.min) / (valueRange.max - valueRange.min)) * chartHeight;
  };

  // 处理鼠标事件
  const handleMouseMove = (e: React.MouseEvent<SVGSVGElement>) => {
    if (!options.showTooltip) return;
    
    const rect = e.currentTarget.getBoundingClientRect();
    const x = e.clientX - rect.left - margin.left;
    const y = e.clientY - rect.top - margin.top;

    // 查找最近的数据点
    let closestPoint: { datasetIndex: number; pointIndex: number; distance: number } | null = null;
    
    processedData.forEach((dataset, datasetIndex) => {
      dataset.data.forEach((point, pointIndex) => {
        const pointX = xScale(point.x, pointIndex);
        const pointY = yScale(point.y);
        const distance = Math.sqrt(Math.pow(x - pointX, 2) + Math.pow(y - pointY, 2));
        
        if (distance < 20 && (!closestPoint || distance < closestPoint.distance)) {
          closestPoint = { datasetIndex, pointIndex, distance };
        }
      });
    });

    if (closestPoint) {
      const dataset = processedData[closestPoint.datasetIndex];
      const point = dataset.data[closestPoint.pointIndex];
      setHoveredPoint({
        datasetIndex: closestPoint.datasetIndex,
        pointIndex: closestPoint.pointIndex,
        x: xScale(point.x, closestPoint.pointIndex),
        y: yScale(point.y),
      });
      setTooltipPosition({
        x: e.clientX - rect.left,
        y: e.clientY - rect.top,
      });
    } else {
      setHoveredPoint(null);
    }
  };

  const handleMouseLeave = () => {
    setHoveredPoint(null);
  };

  // 渲染折线图
  const renderLineChart = (dataset: ChartDataset, datasetIndex: number) => {
    const points = dataset.data.map((point, index) => 
      `${xScale(point.x, index)},${yScale(point.y)}`
    ).join(' ');

    return (
      <polyline
        key={`line-${datasetIndex}`}
        points={points}
        fill="none"
        stroke={dataset.color}
        strokeWidth="2"
        strokeLinecap="round"
        strokeLinejoin="round"
      />
    );
  };

  // 渲染面积图
  const renderAreaChart = (dataset: ChartDataset, datasetIndex: number) => {
    const points = dataset.data.map((point, index) => 
      `${xScale(point.x, index)},${yScale(point.y)}`
    );
    const areaPoints = [
      ...points,
      `${xScale(dataset.data[dataset.data.length - 1].x, dataset.data.length - 1)},${chartHeight}`,
      `${xScale(dataset.data[0].x, 0)},${chartHeight}`,
    ].join(' ');

    return (
      <polygon
        key={`area-${datasetIndex}`}
        points={areaPoints}
        fill={dataset.color}
        fillOpacity="0.3"
        stroke={dataset.color}
        strokeWidth="2"
      />
    );
  };

  // 渲染柱状图
  const renderBarChart = (dataset: ChartDataset, datasetIndex: number) => {
    const barWidth = chartWidth / dataset.data.length / (processedData.length * 1.5);
    const barOffset = datasetIndex * barWidth * 1.2;
    
    return dataset.data.map((point, pointIndex) => {
      const x = xScale(point.x, pointIndex) + barOffset - (barWidth * processedData.length) / 2;
      const y = yScale(point.y);
      const barHeight = chartHeight - y;
      
      return (
        <rect
          key={`bar-${datasetIndex}-${pointIndex}`}
          x={x}
          y={y}
          width={barWidth}
          height={barHeight}
          fill={point.color}
          rx="2"
          className="transition-all duration-200 hover:opacity-80 cursor-pointer"
        />
      );
    });
  };

  // 渲染数据点
  const renderDataPoints = () => {
    return processedData.flatMap((dataset, datasetIndex) =>
      dataset.data.map((point, pointIndex) => {
        const isHovered = hoveredPoint?.datasetIndex === datasetIndex && 
                         hoveredPoint?.pointIndex === pointIndex;
        
        return (
          <circle
            key={`point-${datasetIndex}-${pointIndex}`}
            cx={xScale(point.x, pointIndex)}
            cy={yScale(point.y)}
            r={isHovered ? 6 : 4}
            fill={point.color}
            stroke={isHovered ? '#ffffff' : 'none'}
            strokeWidth="2"
            className="cursor-pointer transition-all duration-200"
          />
        );
      })
    );
  };

  // 渲染网格线
  const renderGrid = () => {
    if (!options.showGrid) return null;
    
    const gridLines = [];
    const gridCount = 5;
    
    // 水平网格线
    for (let i = 0; i <= gridCount; i++) {
      const y = (i / gridCount) * chartHeight;
      const value = valueRange.max - (i / gridCount) * (valueRange.max - valueRange.min);
      
      gridLines.push(
        <g key={`grid-h-${i}`}>
          <line
            x1="0"
            y1={y}
            x2={chartWidth}
            y2={y}
            stroke="#e5e7eb"
            strokeWidth="1"
            strokeDasharray="2,2"
          />
          <text
            x={-10}
            y={y + 4}
            textAnchor="end"
            fontSize="12"
            fill="#6b7280"
          >
            {value.toFixed(1)}
          </text>
        </g>
      );
    }
    
    // 垂直网格线
    const xDataPoints = processedData[0]?.data || [];
    xDataPoints.forEach((point, index) => {
      const x = xScale(point.x, index);
      gridLines.push(
        <g key={`grid-v-${index}`}>
          <line
            x1={x}
            y1="0"
            x2={x}
            y2={chartHeight}
            stroke="#e5e7eb"
            strokeWidth="1"
            strokeDasharray="2,2"
          />
          <text
            x={x}
            y={chartHeight + 20}
            textAnchor="middle"
            fontSize="12"
            fill="#6b7280"
          >
            {typeof point.x === 'string' ? point.x : point.x.toFixed(0)}
          </text>
        </g>
      );
    });
    
    return gridLines;
  };

  // 渲染图例
  const renderLegend = () => {
    if (!options.showLegend) return null;
    
    return (
      <div className="flex flex-wrap gap-4 justify-center mt-4">
        {processedData.map((dataset, index) => (
          <div key={index} className="flex items-center gap-2">
            <div 
              className="w-4 h-4 rounded" 
              style={{ backgroundColor: dataset.color }}
            />
            <span className="text-sm font-medium text-gray-700">{dataset.label}</span>
          </div>
        ))}
      </div>
    );
  };

  // 渲染工具提示
  const renderTooltip = () => {
    if (!hoveredPoint || !options.showTooltip) return null;
    
    const dataset = processedData[hoveredPoint.datasetIndex];
    const point = dataset.data[hoveredPoint.pointIndex];
    
    return (
      <div
        className="absolute bg-white p-3 rounded-lg shadow-lg border border-gray-200 z-10 pointer-events-none"
        style={{
          left: tooltipPosition.x + 10,
          top: tooltipPosition.y - 10,
          transform: 'translateY(-100%)',
        }}
      >
        <div className="font-semibold text-gray-900">{dataset.label}</div>
        <div className="text-sm text-gray-600">
          {point.label || `X: ${point.x}, Y: ${point.y.toFixed(2)}`}
        </div>
      </div>
    );
  };

  if (width === 0 || height === 0) {
    return <div ref={containerRef} className={`w-full ${className}`} />;
  }

  return (
    <div 
      ref={containerRef} 
      className={`relative ${className}`}
      style={{ 
        width: options.responsive ? '100%' : `${options.width}px`,
        height: options.responsive ? '100%' : `${options.height}px`,
      }}
    >
      <svg
        width={width}
        height={height}
        onMouseMove={handleMouseMove}
        onMouseLeave={handleMouseLeave}
        className="block"
      >
        {/* 标题 */}
        {options.title && (
          <text
            x={width / 2}
            y={20}
            textAnchor="middle"
            fontSize="16"
            fontWeight="bold"
            fill="#1f2937"
          >
            {options.title}
          </text>
        )}

        {/* 图表区域 */}
        <g transform={`translate(${margin.left}, ${margin.top})`}>
          {/* 网格 */}
          {renderGrid()}
          
          {/* 图表内容 */}
          {processedData.map((dataset, datasetIndex) => {
            const chartType = dataset.type || 'line';
            switch (chartType) {
              case 'area':
                return renderAreaChart(dataset, datasetIndex);
              case 'bar':
                return renderBarChart(dataset, datasetIndex);
              case 'line':
              default:
                return renderLineChart(dataset, datasetIndex);
            }
          })}
          
          {/* 数据点 */}
          {renderDataPoints()}
        </g>
      </svg>
      
      {/* 工具提示 */}
      {renderTooltip()}
      
      {/* 图例 */}
      {renderLegend()}
    </div>
  );
};

export default InteractiveChart;

3. 使用示例

// App.tsx
import React from 'react';
import InteractiveChart from './components/InteractiveChart';
import { ChartConfig } from './types/chart';

const App: React.FC = () => {
  // 示例数据配置
  const chartConfig: ChartConfig = {
    datasets: [
      {
        label: '销售额',
        data: [
          { x: '1月', y: 120 },
          { x: '2月', y: 190 },
          { x: '3月', y: 300 },
          { x: '4月', y: 500 },
          { x: '5月', y: 230 },
          { x: '6月', y: 340 },
        ],
        type: 'line',
        color: '#3B82F6',
      },
      {
        label: '利润',
        data: [
          { x: '1月', y: 80 },
          { x: '2月', y: 120 },
          { x: '3月', y: 200 },
          { x: '4月', y: 320 },
          { x: '5月', y: 180 },
          { x: '6月', y: 250 },
        ],
        type: 'area',
        color: '#10B981',
      },
      {
        label: '成本',
        data: [
          { x: '1月', y: 40 },
          { x: '2月', y: 70 },
          { x: '3月', y: 100 },
          { x: '4月', y: 180 },
          { x: '5月', y: 50 },
          { x: '6月', y: 90 },
        ],
        type: 'bar',
        color: '#F59E0B',
      },
    ],
    options: {
      title: '2024年销售数据',
      responsive: true,
      showLegend: true,
      showGrid: true,
      showTooltip: true,
      animation: true,
      xAxisLabel: '月份',
      yAxisLabel: '金额 (万元)',
    },
  };

  return (
    <div className="p-8">
      交互式图表组件
      <div className="max-w-4xl mx-auto">
        <InteractiveChart 
          config={chartConfig} 
          className="border rounded-lg shadow-sm"
        />
      </div>
    </div>
  );
};

export default App;

4. 样式文件 (可选)

/* styles/chart.css */
.interactive-chart {
  @apply relative;
}

.interactive-chart svg {
  @apply w-full h-full;
}

.interactive-chart .tooltip {
  @apply absolute bg-white p-3 rounded-lg shadow-lg border border-gray-200 z-10 pointer-events-none;
}

.interactive-chart .legend {
  @apply flex flex-wrap gap-4 justify-center mt-4;
}

.interactive-chart .legend-item {
  @apply flex items-center gap-2;
}

.interactive-chart .legend-color {
  @apply w-4 h-4 rounded;
}

主要特性

  1. 多种图表类型: 支持折线图、面积图、柱状图
  2. 交互功能:
    • 悬停显示工具提示
    • 数据点高亮
    • 响应式设计
  3. 自定义选项:
    • 颜色主题
    • 网格显示
    • 图例控制
    • 动画效果
  4. 类型安全: 完整的 TypeScript 类型定义
  5. 响应式: 自动适应容器大小
  6. 可扩展: 易于添加新的图表类型和功能

这个组件提供了良好的用户体验和开发体验,您可以根据具体需求进一步定制和扩展功能。

举报

相关推荐

0 条评论