一个功能完整的交互式图表组件。这个组件将支持多种图表类型、数据交互、响应式设计和丰富的配置选项。
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;
}
主要特性
- 多种图表类型: 支持折线图、面积图、柱状图
- 交互功能:
- 悬停显示工具提示
- 数据点高亮
- 响应式设计
- 自定义选项:
- 颜色主题
- 网格显示
- 图例控制
- 动画效果
- 类型安全: 完整的 TypeScript 类型定义
- 响应式: 自动适应容器大小
- 可扩展: 易于添加新的图表类型和功能
这个组件提供了良好的用户体验和开发体验,您可以根据具体需求进一步定制和扩展功能。