import { useState, useRef, useMemo, useCallback, useEffect } from "react"; export const TimeSeriesChart = ({ data = [], width = "100%", height = 300, accentColor = "#007AFF", showGrid = true, smoothInterpolation = true, timeRange = "auto", // '6h', '1d', '7d', '30d', 'auto' }: { data: { timestamp: number; value: number }[]; width?: string; height?: number; accentColor?: string; showGrid?: boolean; smoothInterpolation?: boolean; timeRange?: "6h" | "1d" | "7d" | "30d" | "auto"; }) => { const svgRef = useRef(null); const containerRef = useRef(null); const [currentPosition, setCurrentPosition] = useState<{ x: number; y: number; data: { timestamp: number; value: number } | null; } | null>(null); const [isDragging, setIsDragging] = useState(false); const [dragStartX, setDragStartX] = useState(0); const [viewBox, setViewBox] = useState({ start: 0, end: 1 }); const [dimensions, setDimensions] = useState({ width: 0, height: 0 }); const [isTouchActive, setIsTouchActive] = useState(false); const [longPressTimer, setLongPressTimer] = useState(null); const [touchStartPosition, setTouchStartPosition] = useState<{ x: number; y: number } | null>(null); // 响应式尺寸处理 useEffect(() => { const updateDimensions = () => { if (containerRef.current) { const { width: containerWidth, height: containerHeight } = containerRef.current.getBoundingClientRect(); setDimensions({ width: containerWidth, height: containerHeight }); } }; updateDimensions(); window.addEventListener("resize", updateDimensions); return () => window.removeEventListener("resize", updateDimensions); }, []); // 格式化时间标签 const formatTimeLabel = useCallback((timestamp: number, range: "6h" | "1d" | "7d" | "30d" | "auto") => { const date = new Date(timestamp); const now = new Date(); const isToday = date.toDateString() === now.toDateString(); switch (range) { case "6h": return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); case "1d": return isToday ? "今天" : date.toLocaleDateString([], { month: "short", day: "numeric" }); case "7d": return date.toLocaleDateString([], { weekday: "short" }); case "30d": return date.toLocaleDateString([], { month: "short", day: "numeric" }); default: return date.toLocaleDateString([], { month: "short", day: "numeric" }); } }, []); // 处理数据范围和时间间隔 const { xScale, yScale, visibleData, timeTicks, yTicks } = useMemo(() => { if (!data.length || !dimensions.width) return {}; const visibleDataPoints = data.filter((point, index) => { const progress = index / (data.length - 1); return progress >= viewBox.start && progress <= viewBox.end; }); if (!visibleDataPoints.length) return {}; // 计算Y轴范围(带一些边距) const values = visibleDataPoints.map((d) => d.value); const minValue = Math.min(...values); const maxValue = Math.max(...values); const valueRange = maxValue - minValue; const padding = valueRange * 0.1; const yScale = (value: number) => { const chartHeight = dimensions.height - 60; // 为标签留出空间 return chartHeight - ((value - (minValue - padding)) / (maxValue - minValue + 2 * padding)) * chartHeight + 20; }; // X轴比例尺 const xScale = (index: number) => { const totalPoints = visibleDataPoints.length; return (index / (totalPoints - 1)) * (dimensions.width - 60) + 40; }; // 生成时间刻度 const generateTimeTicks = () => { const tickCount = Math.min(6, Math.floor(dimensions.width / 80)); const ticks = []; for (let i = 0; i < tickCount; i++) { const dataIndex = Math.floor((i / (tickCount - 1)) * (visibleDataPoints.length - 1)); if (visibleDataPoints[dataIndex]) { ticks.push({ x: xScale(dataIndex), timestamp: visibleDataPoints[dataIndex].timestamp, label: formatTimeLabel(visibleDataPoints[dataIndex].timestamp, timeRange), }); } } return ticks; }; // 生成Y轴刻度 const generateYTicks = () => { const tickCount = 4; const ticks = []; for (let i = 0; i <= tickCount; i++) { const value = minValue - padding + (maxValue + padding - (minValue - padding)) * (i / tickCount); const y = yScale(value); ticks.push({ y, value: Math.round(value * 100) / 100, // 保留两位小数 }); } return ticks; }; return { xScale, yScale, visibleData: visibleDataPoints, timeTicks: generateTimeTicks(), yTicks: generateYTicks(), }; }, [data, dimensions, viewBox, timeRange]); // 生成平滑路径 const generatePath = useCallback(() => { if (!visibleData || !xScale || !yScale) return ""; if (!smoothInterpolation || visibleData.length < 3) { // 直线连接 return visibleData .map((point, index) => `${index === 0 ? "M" : "L"} ${xScale(index)} ${yScale(point.value)}`) .join(" "); } // Catmull-Rom 平滑曲线 const points = visibleData.map((point, index) => ({ x: xScale(index), y: yScale(point.value), })); let path = `M ${points[0].x} ${points[0].y}`; for (let i = 0; i < points.length - 1; i++) { const p0 = points[Math.max(0, i - 1)]; const p1 = points[i]; const p2 = points[i + 1]; const p3 = points[Math.min(points.length - 1, i + 2)]; const tension = 0.5; const x1 = p1.x + ((p2.x - p0.x) / 6) * tension; const y1 = p1.y + ((p2.y - p0.y) / 6) * tension; const x2 = p2.x - ((p3.x - p1.x) / 6) * tension; const y2 = p2.y - ((p3.y - p1.y) / 6) * tension; path += ` C ${x1} ${y1} ${x2} ${y2} ${p2.x} ${p2.y}`; } return path; }, [visibleData, xScale, yScale, smoothInterpolation]); // 更新光标位置和对应数据点 const updateCursorPosition = useCallback( (x: number) => { if (!visibleData || !xScale) return; // 找到最近的数据点 let closestIndex = 0; let minDistance = Infinity; visibleData.forEach((point, index) => { const pointX = xScale(index); const distance = Math.abs(pointX - x); if (distance < minDistance) { minDistance = distance; closestIndex = index; } }); if (minDistance < 50) { // 灵敏度阈值 const point = visibleData[closestIndex]; setCurrentPosition({ x: xScale(closestIndex), y: yScale(point.value), data: point, }); } else { setCurrentPosition(null); } }, [visibleData, xScale, yScale], ); // 鼠标事件处理 const handleMouseDown = useCallback( (e: React.MouseEvent) => { console.log("mouse down"); if (!svgRef.current) return; const rect = svgRef.current.getBoundingClientRect(); const x = e.clientX - rect.left; setIsDragging(true); setDragStartX(x); updateCursorPosition(x); }, [updateCursorPosition], ); const handleMouseMove = useCallback( (e: React.MouseEvent) => { console.log("mouse move"); if (!svgRef.current) return; const rect = svgRef.current.getBoundingClientRect(); const x = e.clientX - rect.left; if (isDragging) { // 滚动逻辑 const deltaX = x - dragStartX; if (Math.abs(deltaX) > 10) { const dragSpeed = 0.02; const newStart = Math.max(0, viewBox.start - deltaX * dragSpeed); const newEnd = Math.min(1, viewBox.end - deltaX * dragSpeed); if (newEnd - newStart === viewBox.end - viewBox.start) { setViewBox({ start: newStart, end: newEnd }); } setDragStartX(x); } else { // 光标位置更新 updateCursorPosition(x); } } else { // 悬停时更新光标位置 updateCursorPosition(x); } }, [isDragging, dragStartX, viewBox, updateCursorPosition], ); const handleMouseUp = useCallback(() => { console.log("mouse up"); setIsDragging(false); }, []); const handleMouseLeave = useCallback(() => { console.log("mouse leave"); setIsDragging(false); setCurrentPosition(null); }, []); // 触摸事件处理 const handleTouchStart = useCallback( (e: React.TouchEvent) => { console.log("touch start"); if (!svgRef.current) return; const touch = e.touches[0]; const rect = svgRef.current.getBoundingClientRect(); const x = touch.clientX - rect.left; setIsDragging(true); setDragStartX(x); updateCursorPosition(x); }, [updateCursorPosition], ); const handleTouchMove = useCallback( (e: React.TouchEvent) => { console.log("touch move"); if (!isDragging || !svgRef.current) return; const touch = e.touches[0]; const rect = svgRef.current.getBoundingClientRect(); const x = touch.clientX - rect.left; // 滚动逻辑 const deltaX = x - dragStartX; if (Math.abs(deltaX) > 10) { const dragSpeed = 0.02; const newStart = Math.max(0, viewBox.start - deltaX * dragSpeed); const newEnd = Math.min(1, viewBox.end - deltaX * dragSpeed); if (newEnd - newStart === viewBox.end - viewBox.start) { setViewBox({ start: newStart, end: newEnd }); } setDragStartX(x); } else { // 光标位置更新 updateCursorPosition(x); } }, [isDragging, dragStartX, viewBox, updateCursorPosition], ); const handleTouchEnd = useCallback(() => { console.log("touch end"); setIsDragging(false); setCurrentPosition(null); }, []); if (!data.length) { return (
暂无数据
); } return (
{/* Y轴刻度 */} {yTicks && yTicks.map((tick, index) => ( {tick.value.toLocaleString()} ))} {/* X轴时间刻度 */} {timeTicks && timeTicks.map((tick, index) => ( {tick.label} ))} {/* 折线路径 */} {/* 当前光标指示线 */} {currentPosition && ( {/* 垂直指示线 */} {/* 数据点 */} )} {/* 浮动数据标签 */} {currentPosition && (
{currentPosition.data && new Date(currentPosition.data.timestamp).toLocaleTimeString([], { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", })}
{currentPosition.data && currentPosition.data.value.toLocaleString()}
)}
); };