mirror of
https://github.com/fankes/komari-theme-purcarte.git
synced 2025-10-20 04:19:22 +08:00
feat: 优化图表显示
This commit is contained in:
@@ -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 {
|
||||||
|
@@ -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)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
@@ -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
77
src/utils/chartHelper.ts
Normal 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 "";
|
||||||
|
};
|
Reference in New Issue
Block a user