feat: 优化图表显示

This commit is contained in:
Montia37
2025-09-09 16:01:56 +08:00
parent de86264262
commit 8b3e7b8f40
4 changed files with 161 additions and 91 deletions

View File

@@ -132,10 +132,12 @@
@layer utilities { @layer utilities {
.dark .radix-themes { .dark .radix-themes {
--theme-text-muted-color: rgb(from var(--accent-4) r g b / 0.8); --theme-text-muted-color: rgb(from var(--accent-4) r g b / 0.8);
--theme-line-muted-color: rgb(from var(--accent-2) r g b / 0.5);
} }
.radix-themes { .radix-themes {
--theme-text-muted-color: rgb(from var(--accent-12) r g b / 0.8); --theme-text-muted-color: rgb(from var(--accent-12) r g b / 0.8);
--theme-line-muted-color: rgb(from var(--accent-10) r g b / 0.5);
} }
.dark .rt-Badge-tag-transparent { .dark .rt-Badge-tag-transparent {

View File

@@ -1,4 +1,4 @@
import { memo, useCallback, useRef } from "react"; import { memo, useRef } from "react";
import { import {
AreaChart, AreaChart,
Area, Area,
@@ -17,6 +17,7 @@ import { Flex } from "@radix-ui/themes";
import Loading from "@/components/loading"; import Loading from "@/components/loading";
import { useLoadCharts } from "@/hooks/useLoadCharts"; import { useLoadCharts } from "@/hooks/useLoadCharts";
import { CustomTooltip } from "@/components/ui/tooltip"; import { CustomTooltip } from "@/components/ui/tooltip";
import { lableFormatter, loadChartTimeFormatter } from "@/utils/chartHelper";
interface LoadChartsProps { interface LoadChartsProps {
node: NodeData; node: NodeData;
@@ -33,38 +34,6 @@ const LoadCharts = memo(({ node, hours, liveData }: LoadChartsProps) => {
const chartDataLengthRef = useRef(0); const chartDataLengthRef = useRef(0);
chartDataLengthRef.current = chartData.length; chartDataLengthRef.current = chartData.length;
// 格式化函数
const timeFormatter = useCallback((value: any, index: number) => {
if (chartDataLengthRef.current === 0) return "";
if (index === 0 || index === chartDataLengthRef.current - 1) {
return new Date(value).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
});
}
return "";
}, []);
const labelFormatter = useCallback(
(value: any) => {
const date = new Date(value);
if (hours === 0) {
return date.toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
}
return date.toLocaleString([], {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
},
[hours]
);
// 样式和颜色 // 样式和颜色
const cn = "flex flex-col w-full overflow-hidden"; const cn = "flex flex-col w-full overflow-hidden";
const chartMargin = { top: 8, right: 16, bottom: 8, left: 16 }; const chartMargin = { top: 8, right: 16, bottom: 8, left: 16 };
@@ -262,7 +231,7 @@ const LoadCharts = memo(({ node, hours, liveData }: LoadChartsProps) => {
{...chartProps}> {...chartProps}>
<CartesianGrid <CartesianGrid
strokeDasharray="2 4" strokeDasharray="2 4"
stroke="var(--muted-foreground)" stroke="var(--theme-line-muted-color)"
vertical={false} vertical={false}
/> />
<XAxis <XAxis
@@ -274,7 +243,13 @@ const LoadCharts = memo(({ node, hours, liveData }: LoadChartsProps) => {
tick={{ tick={{
fill: "var(--theme-text-muted-color)", fill: "var(--theme-text-muted-color)",
}} }}
tickFormatter={timeFormatter} tickFormatter={(value, index) =>
loadChartTimeFormatter(
value,
index,
chartDataLengthRef.current
)
}
interval={0} interval={0}
height={20} height={20}
/> />
@@ -298,7 +273,7 @@ const LoadCharts = memo(({ node, hours, liveData }: LoadChartsProps) => {
<CustomTooltip <CustomTooltip
{...props} {...props}
chartConfig={config} chartConfig={config}
labelFormatter={labelFormatter} labelFormatter={(value) => lableFormatter(value, hours)}
/> />
)} )}
/> />

View File

@@ -1,7 +1,7 @@
import { memo, useState, useMemo, useCallback, useEffect } from "react"; import { memo, useState, useMemo, useEffect } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { useIsMobile } from "@/hooks/useMobile"; import { useIsMobile } from "@/hooks/useMobile";
import { Eye, EyeOff } from "lucide-react"; import { Eye, EyeOff, ArrowRightToLine, RefreshCw } from "lucide-react";
import { import {
LineChart, LineChart,
Line, Line,
@@ -26,6 +26,7 @@ import fillMissingTimePoints, {
import { useConfigItem } from "@/config"; import { useConfigItem } from "@/config";
import { CustomTooltip } from "@/components/ui/tooltip"; import { CustomTooltip } from "@/components/ui/tooltip";
import Tips from "@/components/ui/tips"; import Tips from "@/components/ui/tips";
import { generateColor, lableFormatter } from "@/utils/chartHelper";
interface PingChartProps { interface PingChartProps {
node: NodeData; node: NodeData;
@@ -36,10 +37,15 @@ const PingChart = memo(({ node, hours }: PingChartProps) => {
const { loading, error, pingHistory } = usePingChart(node, hours); const { loading, error, pingHistory } = usePingChart(node, hours);
const [visiblePingTasks, setVisiblePingTasks] = useState<number[]>([]); const [visiblePingTasks, setVisiblePingTasks] = useState<number[]>([]);
const [timeRange, setTimeRange] = useState<[number, number] | null>(null); const [timeRange, setTimeRange] = useState<[number, number] | null>(null);
const [brushIndices, setBrushIndices] = useState<{
startIndex?: number;
endIndex?: number;
}>({});
const [cutPeak, setCutPeak] = useState(false); const [cutPeak, setCutPeak] = useState(false);
const [connectBreaks, setConnectBreaks] = useState( const [connectBreaks, setConnectBreaks] = useState(
useConfigItem("enableConnectBreaks") useConfigItem("enableConnectBreaks")
); );
const [isResetting, setIsResetting] = useState(false);
const maxPointsToRender = useConfigItem("pingChartMaxPoints") || 0; // 0表示不限制 const maxPointsToRender = useConfigItem("pingChartMaxPoints") || 0; // 0表示不限制
const isMobile = useIsMobile(); const isMobile = useIsMobile();
@@ -55,25 +61,13 @@ const PingChart = memo(({ node, hours }: PingChartProps) => {
} }
}, [pingHistory?.tasks]); }, [pingHistory?.tasks]);
const lableFormatter = useCallback( useEffect(() => {
(value: any) => { if (isResetting) {
const date = new Date(value); setTimeRange(null);
if (hours === 0) { setBrushIndices({});
return date.toLocaleTimeString([], { setIsResetting(false);
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
} }
return date.toLocaleString([], { }, [isResetting]);
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
},
[hours]
);
const chartMargin = { top: 8, right: 16, bottom: 8, left: 16 }; const chartMargin = { top: 8, right: 16, bottom: 8, left: 16 };
@@ -170,32 +164,6 @@ const PingChart = memo(({ node, hours }: PingChartProps) => {
return [...pingHistory.tasks].sort((a, b) => a.id - b.id); return [...pingHistory.tasks].sort((a, b) => a.id - b.id);
}, [pingHistory?.tasks]); }, [pingHistory?.tasks]);
const generateColor = useCallback(
(taskName: string, total: number) => {
const index = sortedTasks.findIndex((t) => t.name === taskName);
if (index === -1) return "#000000"; // Fallback color
const hue = (index * (360 / total)) % 360;
// 使用OKLCH色彩空间优化折线图的颜色区分度
// L=0.7 (较高亮度,便于在图表背景上清晰显示)
// C=0.2 (较高饱和度,增强颜色区分度)
const oklchColor = `oklch(0.6 0.2 ${hue} / .8)`;
// 为不支持OKLCH的浏览器提供HSL备用色
// 使用更高的饱和度和适中的亮度来匹配OKLCH的视觉效果
const hslFallback = `hsl(${hue}, 50%, 60%)`;
// 检查浏览器是否支持OKLCH
if (CSS.supports("color", oklchColor)) {
return oklchColor;
} else {
return hslFallback;
}
},
[sortedTasks]
);
const breakPoints = useMemo(() => { const breakPoints = useMemo(() => {
if (!connectBreaks || !chartData || chartData.length < 2) { if (!connectBreaks || !chartData || chartData.length < 2) {
return []; return [];
@@ -219,13 +187,13 @@ const PingChart = memo(({ node, hours }: PingChartProps) => {
if (isBreak) { if (isBreak) {
points.push({ points.push({
x: currentPoint.time, x: currentPoint.time,
color: generateColor(task.name, sortedTasks.length), color: generateColor(task.name, sortedTasks),
}); });
} }
} }
} }
return points; return points;
}, [chartData, sortedTasks, visiblePingTasks, generateColor, connectBreaks]); }, [chartData, sortedTasks, visiblePingTasks, connectBreaks]);
const taskStats = useMemo(() => { const taskStats = useMemo(() => {
if (!pingHistory?.records || !sortedTasks.length) return []; if (!pingHistory?.records || !sortedTasks.length) return [];
@@ -241,10 +209,10 @@ const PingChart = memo(({ node, hours }: PingChartProps) => {
value: latestValue, value: latestValue,
time: latestTime, time: latestTime,
loss: loss, loss: loss,
color: generateColor(task.name, sortedTasks.length), color: generateColor(task.name, sortedTasks),
}; };
}); });
}, [pingHistory?.records, sortedTasks, generateColor, timeRange]); }, [pingHistory?.records, sortedTasks, timeRange]);
return ( return (
<div className="relative space-y-4"> <div className="relative space-y-4">
@@ -342,7 +310,7 @@ const PingChart = memo(({ node, hours }: PingChartProps) => {
</Tips> </Tips>
</div> </div>
</div> </div>
<div className={isMobile ? "w-full mt-2" : ""}> <div className={`flex gap-2 ${isMobile ? "w-full mt-2" : ""}`}>
<Button variant="secondary" onClick={handleToggleAll} size="sm"> <Button variant="secondary" onClick={handleToggleAll} size="sm">
{pingHistory?.tasks && {pingHistory?.tasks &&
visiblePingTasks.length === pingHistory.tasks.length ? ( visiblePingTasks.length === pingHistory.tasks.length ? (
@@ -357,6 +325,38 @@ const PingChart = memo(({ node, hours }: PingChartProps) => {
</> </>
)} )}
</Button> </Button>
<Button
variant="secondary"
onClick={() => {
if (timeRange) {
if (chartData.length > 1) {
const endIndex = chartData.length - 1;
const startIndex = 0;
setTimeRange([
chartData[startIndex].time,
chartData[endIndex].time,
]);
setBrushIndices({ startIndex, endIndex });
setIsResetting(true);
}
} else if (chartData.length > 1) {
const endIndex = chartData.length - 1;
const startIndex = Math.floor(endIndex * 0.75);
setTimeRange([
chartData[startIndex].time,
chartData[endIndex].time,
]);
setBrushIndices({ startIndex, endIndex });
}
}}
size="sm">
{timeRange ? (
<RefreshCw size={16} />
) : (
<ArrowRightToLine size={16} />
)}
{timeRange ? "重置范围" : "四分之一"}
</Button>
</div> </div>
</div> </div>
</CardHeader> </CardHeader>
@@ -366,7 +366,7 @@ const PingChart = memo(({ node, hours }: PingChartProps) => {
<LineChart data={chartData} margin={chartMargin}> <LineChart data={chartData} margin={chartMargin}>
<CartesianGrid <CartesianGrid
strokeDasharray="2 4" strokeDasharray="2 4"
stroke="var(--muted-foreground)" stroke="var(--theme-line-muted-color)"
vertical={false} vertical={false}
/> />
<XAxis <XAxis
@@ -390,16 +390,26 @@ const PingChart = memo(({ node, hours }: PingChartProps) => {
}); });
}} }}
tick={{ fill: "var(--theme-text-muted-color)" }} tick={{ fill: "var(--theme-text-muted-color)" }}
axisLine={{
stroke: "var(--theme-line-muted-color)",
}}
scale="time" scale="time"
/> />
<YAxis <YAxis
mirror={true} mirror={true}
width={30} width={30}
tick={{ fill: "var(--theme-text-muted-color)" }} tick={{ fill: "var(--theme-text-muted-color)" }}
axisLine={{
stroke: "var(--theme-line-muted-color)",
}}
/> />
<Tooltip <Tooltip
cursor={false} cursor={false}
content={<CustomTooltip labelFormatter={lableFormatter} />} content={
<CustomTooltip
labelFormatter={(value) => lableFormatter(value, hours)}
/>
}
/> />
{connectBreaks && {connectBreaks &&
breakPoints.map((point, index) => ( breakPoints.map((point, index) => (
@@ -417,7 +427,7 @@ const PingChart = memo(({ node, hours }: PingChartProps) => {
type={"monotone"} type={"monotone"}
dataKey={String(task.id)} dataKey={String(task.id)}
name={task.name} name={task.name}
stroke={generateColor(task.name, sortedTasks.length)} stroke={generateColor(task.name, sortedTasks)}
strokeWidth={2} strokeWidth={2}
hide={!visiblePingTasks.includes(task.id)} hide={!visiblePingTasks.includes(task.id)}
dot={false} dot={false}
@@ -425,6 +435,7 @@ const PingChart = memo(({ node, hours }: PingChartProps) => {
/> />
))} ))}
<Brush <Brush
{...brushIndices}
dataKey="time" dataKey="time"
height={30} height={30}
stroke="var(--accent-track)" stroke="var(--accent-track)"
@@ -457,8 +468,13 @@ const PingChart = memo(({ node, hours }: PingChartProps) => {
chartData[e.startIndex].time, chartData[e.startIndex].time,
chartData[e.endIndex].time, chartData[e.endIndex].time,
]); ]);
setBrushIndices({
startIndex: e.startIndex,
endIndex: e.endIndex,
});
} else { } else {
setTimeRange(null); setTimeRange(null);
setBrushIndices({});
} }
}} }}
/> />

77
src/utils/chartHelper.ts Normal file
View File

@@ -0,0 +1,77 @@
import type { PingTask } from "@/types/node";
/**
* 根据任务名称和任务列表生成颜色
* @param taskName - 任务名称
* @param sortedTasks - 已排序的任务列表
* @returns CSS 颜色字符串
*/
export const generateColor = (taskName: string, sortedTasks: PingTask[]) => {
const index = sortedTasks.findIndex((t) => t.name === taskName);
if (index === -1) return "#000000"; // Fallback color
const total = sortedTasks.length;
const hue = (index * (360 / total)) % 360;
// 使用OKLCH色彩空间优化折线图的颜色区分度
const oklchColor = `oklch(0.6 0.2 ${hue} / .8)`;
// 为不支持OKLCH的浏览器提供HSL备用色
const hslFallback = `hsl(${hue}, 50%, 60%)`;
// 检查浏览器是否支持OKLCH
if (
typeof window !== "undefined" &&
window.CSS &&
CSS.supports("color", oklchColor)
) {
return oklchColor;
} else {
return hslFallback;
}
};
/**
* 格式化图表X轴的标签
* @param value - 时间戳
* @param hours - 当前选择的时间范围(小时)
* @returns 格式化后的时间字符串
*/
export const lableFormatter = (value: any, hours: number) => {
const date = new Date(value);
if (hours === 0) {
return date.toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
}
return date.toLocaleString([], {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
};
/**
* 格式化负载图表X轴的时间标签
* @param value - 时间戳
* @param index - 索引
* @param dataLength - 数据总长度
* @returns 格式化后的时间字符串 (只显示首尾)
*/
export const loadChartTimeFormatter = (
value: any,
index: number,
dataLength: number
) => {
if (dataLength === 0) return "";
if (index === 0 || index === dataLength - 1) {
return new Date(value).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
});
}
return "";
};