import { useState, useRef, useMemo, useCallback, useEffect } from "react"; import { HOUR, DAY, WEEK } from "@core/lib"; export type TimeRange = "6h" | "1d" | "7d" | "30d" | "3mo" | "6mo" | "1y" | "all"; const TIME_RANGES = { "6h": 6 * HOUR, "1d": DAY, "7d": 7 * DAY, "30d": 30 * DAY, "3mo": 90 * DAY, "6mo": 180 * DAY, "1y": 365 * DAY, all: (maxTime: number, minTime: number) => maxTime - minTime, }; function getWindowSize(timeRange: TimeRange, { maxTime, minTime }: { maxTime: number; minTime: number }) { const val = TIME_RANGES[timeRange]; if (typeof val === "function") return val(maxTime, minTime); return val ?? 7 * DAY; } const leftPad = 10; const rightPad = 10; function calculateXAxisAverageAcceleration(lastPointerPositions: { x: number; time: number }[]) { if (lastPointerPositions.length < 3) { return 0; } const accelerations = []; for (let i = 2; i < lastPointerPositions.length; i++) { const point1 = lastPointerPositions[i - 2]; const point2 = lastPointerPositions[i - 1]; const point3 = lastPointerPositions[i]; const deltaTime1 = (point2.time - point1.time) / 1000; const deltaTime2 = (point3.time - point2.time) / 1000; if (deltaTime1 === 0 || deltaTime2 === 0) { continue; } const velocity1 = (point2.x - point1.x) / deltaTime1; const velocity2 = (point3.x - point2.x) / deltaTime2; const averageDeltaTime = (deltaTime1 + deltaTime2) / 2; const acceleration = (velocity2 - velocity1) / averageDeltaTime; accelerations.push(acceleration); } if (accelerations.length === 0) { return 0; } const sum = accelerations.reduce((acc, val) => acc + val, 0); const averageAcceleration = sum / accelerations.length; return averageAcceleration; } interface CharProps extends React.HTMLAttributes { data: { timestamp: number; value: number }[]; width?: string; height?: number; accentColor?: string; smoothInterpolation?: boolean; timeRange?: "6h" | "1d" | "7d" | "30d" | "3mo" | "6mo" | "1y" | "all"; outside?: boolean; ref?: React.Ref; setCurrentData?: (data: string) => void; setCurrentDate?: (date: string) => void; } export const TimeSeriesChart = ({ data = [], width = "100%", height = 300, accentColor = "#007AFF", smoothInterpolation = true, timeRange = "7d", outside = false, ref = null, setCurrentData, setCurrentDate, ...rest }: CharProps) => { const [yLabelWidth, setYLabelWidth] = useState(0); const widthProbeRef = useRef(null); const sortedData = useMemo(() => [...data].sort((a, b) => a.timestamp - b.timestamp), [data]); const globalMaxValue = useMemo( () => (sortedData.length > 0 ? sortedData[sortedData.length - 1].value : 0), [sortedData], ); // Y-axis range state for lazy adjustment const [yAxisRange, setYAxisRange] = useState({ min: 0, max: 0 }); const [yAxisAnimation, setYAxisAnimation] = useState<{ isAnimating: boolean; startMin: number; startMax: number; targetMin: number; targetMax: number; startTimestamp: number; } | null>(null); const yAxisAnimationRef = useRef(null); 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 [dragStartX, setDragStartX] = useState(0); const [timeWindow, setTimeWindow] = useState({ startTime: 0, endTime: 0 }); const [dimensions, setDimensions] = useState({ width: 0, height: 0 }); const [longPressTimer, setLongPressTimer] = useState(null); const animationRef = useRef(null); const [lastPointerPositions, setLastPointerPositions] = useState<{ x: number; y: number; time: number }[]>([]); const [pointerStartPosition, setPointerStartPosition] = useState<{ x: number; y: number; time: number } | null>( null, ); const visibleData = useMemo(() => { if (data.length === 0 || timeWindow.startTime === 0) return []; let firstIndex = -1; let lastIndex = -1; for (let i = 0; i < sortedData.length; i++) { const t = sortedData[i].timestamp; if (firstIndex === -1 && t >= timeWindow.startTime) firstIndex = i; if (t <= timeWindow.endTime) lastIndex = i; if (t > timeWindow.endTime && lastIndex !== -1) break; } if (firstIndex === -1 && lastIndex === -1) return []; const from = Math.max(0, (firstIndex === -1 ? 0 : firstIndex) - leftPad); const to = Math.min(sortedData.length - 1, (lastIndex === -1 ? sortedData.length - 1 : lastIndex) + rightPad); if (from > to) return []; return sortedData.slice(from, to + 1); }, [data, timeWindow, sortedData]); useEffect(() => { if (!setCurrentData || !setCurrentDate) return; const haveCurrent = currentPosition && currentPosition.data && currentPosition.data.timestamp && currentPosition.data.value; if (haveCurrent) { const { timestamp, value } = currentPosition.data!; const month = new Date(timestamp).getMonth() + 1; const day = new Date(timestamp).getDate(); const hour = new Date(timestamp).getHours().toString().padStart(2, "0"); const minute = new Date(timestamp).getMinutes().toString().padStart(2, "0"); setCurrentData(value.toLocaleString()); setCurrentDate(`${month}月${day}日 ${hour}:${minute}`); return; } if (Array.isArray(sortedData) && sortedData.length > 0) { const oldest = visibleData.length ? visibleData[leftPad - 1] : sortedData[0]; const newest = visibleData.length ? visibleData[visibleData.length - rightPad] : sortedData[sortedData.length - 1]; const increment = newest.value - oldest.value; const year = new Date(oldest.timestamp).getFullYear(); const month = new Date(oldest.timestamp).getMonth() + 1; const day = new Date(oldest.timestamp).getDate(); const hour = new Date(oldest.timestamp).getHours().toString().padStart(2, "0"); const minute = new Date(oldest.timestamp).getMinutes().toString().padStart(2, "0"); const newestYear = new Date(newest.timestamp).getFullYear(); const newestMonth = new Date(newest.timestamp).getMonth() + 1; const newestDay = new Date(newest.timestamp).getDate(); const newestHour = new Date(newest.timestamp).getHours().toString().padStart(2, "0"); const newestMinute = new Date(newest.timestamp).getMinutes().toString().padStart(2, "0"); const timeRange = newest.timestamp - oldest.timestamp; if (year !== newestYear) { setCurrentDate(`${year}年${month}月${day}日–${newestYear}年${newestMonth}月${newestDay}日`); } else if (month !== newestMonth || timeRange > DAY) { setCurrentDate(`${year}年 ${month}月${day}日–${newestMonth}月${newestDay}日`); } else if (day !== newestDay) { setCurrentDate( `${month}月${day}日 ${hour}:${minute}–${newestMonth}月${newestDay}日 ${newestHour}:${newestMinute}`, ); } else { setCurrentDate(`${month}月${day}日 ${hour}:${minute}–${newestHour}:${newestMinute}`); } setCurrentData("+" + increment.toLocaleString()); } }, [ currentPosition, currentPosition?.data?.timestamp, currentPosition?.data?.value, sortedData, setCurrentData, setCurrentDate, visibleData, ]); useEffect(() => { if (!widthProbeRef.current) return; const { width } = widthProbeRef.current.getBoundingClientRect(); setYLabelWidth(width); }, [widthProbeRef.current]); 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); }, []); useEffect(() => { if (svgRef.current) { const { width: svgWidth, height: svgHeight } = svgRef.current.getBoundingClientRect(); setDimensions({ width: svgWidth, height: svgHeight }); } }, [svgRef.current?.getBoundingClientRect().height]); useEffect(() => { if (!outside) return; setCurrentPosition(null); }, [outside]); useEffect(() => { if (data.length === 0) return; const minTime = sortedData[0].timestamp; const maxTime = sortedData[sortedData.length - 1].timestamp; const windowSize = getWindowSize(timeRange, { maxTime, minTime }); // Preserve current endTime if it exists, otherwise use maxTime const currentEndTime = timeWindow.endTime > 0 ? timeWindow.endTime : maxTime; const initialEndTime = Math.min(maxTime, currentEndTime); const initialStartTime = Math.max(minTime, initialEndTime - windowSize); setTimeWindow({ startTime: initialStartTime, endTime: initialEndTime, }); if (visibleValues.length > 0) { const targetMin = Math.max(0, visibleMin); const targetMax = visibleMax; const buffer = (targetMax - targetMin) * 0.3; // Align to nice numbers const alignedRange = alignRangeToNiceNumbers(targetMin - buffer, targetMax + buffer); setYAxisRange({ min: alignedRange.min, max: alignedRange.max, }); } }, [data, timeRange]); // Cleanup animations on unmount useEffect(() => { return () => { if (animationRef.current) { cancelAnimationFrame(animationRef.current); } if (yAxisAnimationRef.current) { cancelAnimationFrame(yAxisAnimationRef.current); } }; }, []); const formatTimeLabel = useCallback((timestamp: number, timeRangeMs: number) => { const date = new Date(timestamp); if (timeRangeMs <= 6 * HOUR) { return date.toLocaleTimeString([], { hourCycle: "h23", hour: "2-digit", minute: "2-digit" }); } else if (timeRangeMs <= DAY) { return date.toLocaleTimeString([], { hourCycle: "h23", hour: "2-digit", minute: "2-digit" }); } else if (timeRangeMs <= 7 * DAY) { return date.toLocaleDateString([], { month: "numeric", day: "numeric" }); } else { return date.toLocaleDateString([], { month: "numeric", day: "numeric" }); } }, []); const visibleValues = useMemo(() => visibleData.map((d) => d.value), [visibleData]); const visibleMax = visibleValues[visibleValues.length - 1]; const visibleMin = visibleValues[0]; const generateNiceTicks = (min: number, max: number, targetTickCount: number = 4) => { const range = max - min; const roughStep = range / (targetTickCount - 1); const magnitude = Math.pow(10, Math.floor(Math.log10(roughStep))); const normalizedStep = roughStep / magnitude; let niceStep; if (normalizedStep <= 1) { niceStep = 1; } else if (normalizedStep <= 2) { niceStep = 2; } else if (normalizedStep <= 5) { niceStep = 5; } else { niceStep = 10; } niceStep *= magnitude; // Generate ticks const ticks = []; const firstTick = Math.floor(min / niceStep) * niceStep; const lastTick = Math.ceil(max / niceStep) * niceStep; for (let value = firstTick; value <= lastTick; value += niceStep) { if (value >= min && value <= max) { ticks.push(value); } } return ticks; }; const { xScale, yScale, timeTicks, yTicks } = useMemo(() => { if (!visibleData.length || !dimensions.width || yAxisRange.min === yAxisRange.max) return {}; const yScale = (value: number) => { const chartHeight = dimensions.height - 60; const normalizedValue = (value - yAxisRange.min) / (yAxisRange.max - yAxisRange.min); return chartHeight - normalizedValue * chartHeight + 20; }; const xScale = (timestamp: number) => { const timeRange = timeWindow.endTime - timeWindow.startTime; const timePosition = timestamp - timeWindow.startTime; const xPosition = (timePosition / timeRange) * (dimensions.width - yLabelWidth - 10) + yLabelWidth + 5; return xPosition; }; const generateTimeTicks = () => { const timeRange = timeWindow.endTime - timeWindow.startTime; let tickInterval: number; if (timeRange <= 6 * HOUR) { tickInterval = HOUR; } else if (timeRange <= DAY) { tickInterval = 4 * HOUR; } else if (timeRange <= 7 * DAY) { tickInterval = DAY; } else if (timeRange <= 30 * DAY) { tickInterval = 7 * DAY; } else if (timeRange <= 90 * DAY) { tickInterval = 3 * WEEK; } else { tickInterval = 30 * DAY; } const ticks = []; let currentTick = Math.ceil(timeWindow.startTime / tickInterval) * tickInterval - tickInterval; while (currentTick <= timeWindow.endTime + tickInterval) { const x = xScale(currentTick); if (x >= -30 && x <= dimensions.width + 30) { ticks.push({ x, timestamp: currentTick, label: formatTimeLabel(currentTick, timeRange), }); } currentTick += tickInterval; } return ticks; }; const generateYTicks = () => { const ticks = generateNiceTicks(yAxisRange.min, yAxisRange.max, 6); return ticks.map((value) => ({ y: yScale(value), value: value, })); }; return { xScale, yScale, timeTicks: generateTimeTicks(), yTicks: generateYTicks(), }; }, [visibleData, dimensions, timeWindow, timeRange, yLabelWidth, yAxisRange]); const generatePath = useCallback(() => { if (!visibleData || !xScale || !yScale) return ""; if (!smoothInterpolation || visibleData.length < 3) { return visibleData .map( (point) => `${point === visibleData[0] ? "M" : "L"} ${xScale(point.timestamp)} ${yScale(point.value)}`, ) .join(" "); } const points = visibleData.map((point) => ({ x: xScale(point.timestamp), 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 = Math.min(0.0005 * (p2.x - p1.x) ** 1.8, 1); 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 closestPoint = visibleData[0]; let minDistance = Infinity; visibleData.forEach((point, idx) => { if ( idx < leftPad || (idx > visibleData.length - rightPad + 1 && visibleData[visibleData.length - 1].timestamp > timeWindow.endTime) ) return; const pointX = xScale(point.timestamp); const distance = Math.abs(pointX - x); if (distance < minDistance) { minDistance = distance; closestPoint = point; } }); setCurrentPosition({ x: xScale(closestPoint.timestamp), y: yScale(closestPoint.value), data: closestPoint, }); }, [visibleData, xScale, yScale], ); const handlePointerDown = useCallback( (e: React.PointerEvent) => { if (!svgRef.current) return; const rect = svgRef.current.getBoundingClientRect(); const x = e.clientX - rect.left; setDragStartX(x); setPointerStartPosition({ x, y: e.clientY - rect.top, time: Date.now() }); if (e.pointerType === "touch") { const timer = setTimeout(() => { updateCursorPosition(x); }, 500); setLongPressTimer(timer); } }, [updateCursorPosition, setLongPressTimer, setDragStartX, setPointerStartPosition], ); // Function to start Y-axis animation const startYAxisAnimation = useCallback( (targetMin: number, targetMax: number) => { if (yAxisAnimationRef.current) { cancelAnimationFrame(yAxisAnimationRef.current); } const animationDuration = 300; // 300ms animation const startTimestamp = performance.now(); const currentMin = yAxisRange.min; const currentMax = yAxisRange.max; setYAxisAnimation({ isAnimating: true, startMin: currentMin, startMax: currentMax, targetMin, targetMax, startTimestamp, }); const animate = (currentTime: number) => { const elapsed = currentTime - startTimestamp; const progress = Math.min(elapsed / animationDuration, 1); // Easing function for smooth animation (ease-out) const easeProgress = 1 - Math.pow(1 - progress, 3); const newMin = currentMin + (targetMin - currentMin) * easeProgress; const newMax = currentMax + (targetMax - currentMax) * easeProgress; setYAxisRange({ min: newMin, max: newMax, }); if (progress < 1) { yAxisAnimationRef.current = requestAnimationFrame(animate); } else { // Animation complete setYAxisRange({ min: targetMin, max: targetMax, }); setYAxisAnimation(null); yAxisAnimationRef.current = null; } }; yAxisAnimationRef.current = requestAnimationFrame(animate); }, [yAxisRange.max, yAxisRange.min], ); const alignRangeToNiceNumbers = (min: number, max: number) => { const range = max - min; const magnitude = Math.pow(10, Math.floor(Math.log10(range))); let alignedMax = Math.ceil(max / magnitude) * magnitude; let alignedMin = Math.floor(min / magnitude) * magnitude; return { min: alignedMin, max: alignedMax }; }; // Lazy adjustment logic for Y-axis range useEffect(() => { if (!visibleData.length || yAxisAnimation?.isAnimating) return; // Check if we need to adjust Y-axis range (when significant data is outside current range) const dataOutsideRange = visibleData.filter((d) => d.value < yAxisRange.min || d.value > yAxisRange.max).length; const threshold = visibleData.length * 0.1; // Lower threshold: 30% of data outside range const vmin = visibleData[leftPad - 1].value; const vmax = visibleData[visibleData.length - rightPad].value; if (dataOutsideRange > threshold) { const targetMin = Math.max(0, vmin); const targetMax = vmax; const buffer = (targetMax - targetMin) * 0.2; // Align to nice numbers const alignedRange = alignRangeToNiceNumbers(targetMin - buffer, targetMax + buffer); // Start animation to new range startYAxisAnimation(alignedRange.min, alignedRange.max); } }, [visibleData, yAxisRange, yAxisAnimation]); useEffect(() => { if (!visibleData.length) return; const vmin = visibleData[leftPad - 1].value; const vmax = visibleData[visibleData.length - rightPad].value; const targetMin = Math.max(0, vmin); const targetMax = vmax; const buffer = (targetMax - targetMin) * 0.04; // Align to nice numbers const alignedRange = alignRangeToNiceNumbers(targetMin - buffer, targetMax + buffer); // Start animation to new range startYAxisAnimation(alignedRange.min, alignedRange.max); }, [timeRange]); const handlePointerMove = useCallback( (e: React.PointerEvent) => { if (!svgRef.current) return; if (longPressTimer) { clearTimeout(longPressTimer); } setLastPointerPositions( [...lastPointerPositions, { x: e.clientX, y: e.clientY, time: Date.now() }].slice(-8), ); const rect = svgRef.current.getBoundingClientRect(); const x = e.clientX - rect.left; if (currentPosition && e.pointerType === "touch") { updateCursorPosition(x); return; } if (e.pointerType === "mouse") { updateCursorPosition(x); } if (!dragStartX) return; const deltaX = x - dragStartX; const windowWidth = dimensions.width - yLabelWidth - 5; const timeRange = timeWindow.endTime - timeWindow.startTime; const timeDelta = (deltaX / windowWidth) * timeRange; const newStartTime = timeWindow.startTime - timeDelta; const newEndTime = timeWindow.endTime - timeDelta; const minTime = sortedData[0].timestamp; const maxTime = sortedData[sortedData.length - 1].timestamp; const adjustedStartTime = Math.max(minTime - timeRange * 0.2, newStartTime); const adjustedEndTime = Math.min(maxTime + timeRange * 0.8, newEndTime); if (adjustedEndTime - adjustedStartTime < timeRange) { return; } setTimeWindow({ startTime: adjustedStartTime, endTime: adjustedEndTime, }); setDragStartX(x); if (longPressTimer) { clearTimeout(longPressTimer); setLongPressTimer(null); } }, [dragStartX, timeWindow, dimensions.width, updateCursorPosition], ); const startAnimation = useCallback( (targetStartTime: number, targetEndTime: number) => { if (animationRef.current) { cancelAnimationFrame(animationRef.current); } const animationDuration = 300; // 300ms animation const startTimestamp = performance.now(); const currentStartTime = timeWindow.startTime; const currentEndTime = timeWindow.endTime; const animate = (currentTime: number) => { const elapsed = currentTime - startTimestamp; const progress = Math.min(elapsed / animationDuration, 1); // Easing function for smooth animation (ease-out) const easeProgress = 1 - Math.pow(1 - progress, 3); const newStartTime = currentStartTime + (targetStartTime - currentStartTime) * easeProgress; const newEndTime = currentEndTime + (targetEndTime - currentEndTime) * easeProgress; setTimeWindow({ startTime: newStartTime, endTime: newEndTime, }); if (progress < 1) { animationRef.current = requestAnimationFrame(animate); } else { // Animation complete setTimeWindow({ startTime: targetStartTime, endTime: targetEndTime, }); animationRef.current = null; } }; animationRef.current = requestAnimationFrame(animate); }, [timeWindow], ); const getWindowShiftForAnimation = (timeRange: number, delta: number, type: "mouse" | "pen" | "touch") => { if (type === "mouse") { return delta > 0 ? -timeRange / 3 : timeRange / 3; } else return delta > 0 ? -timeRange : timeRange; }; const handlePointerUp = useCallback( (e: React.PointerEvent) => { if (!svgRef.current) return; const rect = svgRef.current.getBoundingClientRect(); const x = e.clientX - rect.left; setDragStartX(0); if (longPressTimer) { updateCursorPosition(x); } // Check for swipe gesture on all devices (not just touch) if (!lastPointerPositions || !pointerStartPosition) return; const totalDeltaX = x - pointerStartPosition.x; const avgAcceleration = calculateXAxisAverageAcceleration(lastPointerPositions); const signAcc = avgAcceleration / Math.abs(avgAcceleration); // Only animate if the average acceleration is in the same direction as the swipe if ((avgAcceleration - 500 * signAcc) * totalDeltaX > 0) { const timeRange = timeWindow.endTime - timeWindow.startTime; const windowShift = getWindowShiftForAnimation(timeRange, totalDeltaX, e.pointerType); const newStartTime = Math.max(data[0]?.timestamp || 0, timeWindow.startTime + windowShift); const newEndTime = Math.min( data[data.length - 1]?.timestamp || Infinity, timeWindow.endTime + windowShift, ); if (newEndTime - newStartTime === timeRange) { // Use smooth animation instead of direct set startAnimation(newStartTime, newEndTime); } } setPointerStartPosition(null); setLastPointerPositions([]); }, [longPressTimer, timeWindow, data, startAnimation], ); const handlePointerLeave = useCallback((e: React.PointerEvent) => { if (e.pointerType === "touch") { return; } setCurrentPosition(null); setDragStartX(0); setPointerStartPosition(null); setLastPointerPositions([]); }, []); if (!data.length) { return (
暂无数据
); } return (
{/* Y轴刻度 */} {yTicks && yTicks.map((tick, index) => ( {Math.round(tick.value / 1000) / 10} 万 ))} {Math.round(globalMaxValue / 1000) / 10} 万 {/* X轴时间刻度 */} {timeTicks && timeTicks.map((tick, index) => ( {tick.label} ))} {/* The curve line */} {/* 当前光标指示线 */} {currentPosition && ( {/* 垂直指示线 */} {/* 数据点 */} )}
); };