1
0
cvsa/packages/temp_frontend/app/components/Chart.tsx

879 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<HTMLDivElement> {
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<HTMLDivElement>;
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<SVGTextElement | null>(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<number | null>(null);
const svgRef = useRef<SVGSVGElement>(null);
const containerRef = useRef<HTMLDivElement>(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<NodeJS.Timeout | null>(null);
const animationRef = useRef<number | null>(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 });
const initialEndTime = maxTime;
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.3;
// Align to nice numbers
const alignedRange = alignRangeToNiceNumbers(targetMin - buffer, targetMax + buffer);
// Start animation to new range
startYAxisAnimation(alignedRange.min, alignedRange.max);
}
}, [visibleData, yAxisRange, yAxisAnimation]);
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 * 1, 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);
}
if (currentPosition) return;
// Check for swipe gesture on all devices (not just touch)
if (!lastPointerPositions || !pointerStartPosition) return;
const totalDeltaX = x - pointerStartPosition.x;
const totalTimeDelta = Date.now() - pointerStartPosition.time;
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 (
<div ref={containerRef} className="relative" style={{ width, height }}>
<div className="flex items-center justify-center h-full text-sm"></div>
</div>
);
}
return (
<div {...rest} ref={ref}>
<div
ref={containerRef}
className="relative touch-none select-none h-full"
style={{
width,
}}
>
<svg
ref={svgRef}
width={dimensions.width}
className="block h-full"
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerLeave={handlePointerLeave}
>
<defs>
<clipPath id="pathClip">
<rect
x={yLabelWidth + 5}
y={20}
width={dimensions.width - yLabelWidth - 10}
height={dimensions.height - 60}
/>
</clipPath>
<clipPath id="timeTicksClip">
<rect
x={yLabelWidth + 5}
y={0}
width={dimensions.width - yLabelWidth - 10}
height={dimensions.height}
/>
</clipPath>
</defs>
{/* Y轴刻度 */}
{yTicks &&
yTicks.map((tick, index) => (
<g key={`y-${index}`}>
<line
x1={yLabelWidth + 5}
y1={tick.y}
x2={dimensions.width - 5}
y2={tick.y}
stroke="rgba(200, 200, 200, 0.3)"
strokeWidth="1"
strokeDasharray="2,2"
/>
<text
x={yLabelWidth - 2}
y={tick.y + 4}
textAnchor="end"
className="text-xs fill-neutral-600 dark:fill-neutral-300"
>
{Math.round(tick.value / 1000) / 10}
</text>
</g>
))}
<text
ref={widthProbeRef}
className="text-xs fill-neutral-600 dark:fill-neutral-300 [visibility:hidden]"
>
{Math.round(globalMaxValue / 1000) / 10}
</text>
{/* X轴时间刻度 */}
{timeTicks &&
timeTicks.map((tick, index) => (
<g key={`x-${index}`} clipPath="url(#timeTicksClip)">
<line
x1={tick.x}
y1={20}
x2={tick.x}
y2={dimensions.height - 40}
stroke="rgba(200, 200, 200, 0.3)"
strokeWidth="1"
strokeDasharray="2,2"
/>
<text
x={tick.x}
y={dimensions.height - 8}
textAnchor="middle"
className="text-xs fill-neutral-600 dark:fill-neutral-300"
>
{tick.label}
</text>
</g>
))}
{/* The curve line */}
<path
d={generatePath()}
fill="none"
stroke={accentColor}
strokeWidth="2"
strokeLinecap="round"
clipPath="url(#pathClip)"
/>
{/* 当前光标指示线 */}
{currentPosition && (
<g>
{/* 垂直指示线 */}
<line
x1={currentPosition.x}
y1={0}
x2={currentPosition.x}
y2={dimensions.height - 25}
strokeWidth="1"
className="stroke-neutral-500 dark:stroke-neutral-700"
/>
{/* 数据点 */}
<circle
cx={currentPosition.x}
cy={currentPosition.y}
r="4"
fill={accentColor}
stroke="white"
strokeWidth="2"
/>
</g>
)}
</svg>
</div>
</div>
);
};