使用 echart 绘制图表

This commit is contained in:
JIANG
2025-11-19 17:46:16 +08:00
parent 1ca2e80645
commit 48716f4876
3 changed files with 200 additions and 272 deletions

53
package-lock.json generated
View File

@@ -29,6 +29,8 @@
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"deck.gl": "^9.1.14",
"echarts": "^6.0.0",
"echarts-for-react": "^3.0.5",
"js-cookie": "^3.0.5",
"next": "^15.2.4",
"next-auth": "^4.24.5",
@@ -10691,6 +10693,36 @@
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
"license": "MIT"
},
"node_modules/echarts": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/echarts/-/echarts-6.0.0.tgz",
"integrity": "sha512-Tte/grDQRiETQP4xz3iZWSvoHrkCQtwqd6hs+mifXcjrCuo2iKWbajFObuLJVBlDIJlOzgQPd1hsaKt/3+OMkQ==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "2.3.0",
"zrender": "6.0.0"
}
},
"node_modules/echarts-for-react": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/echarts-for-react/-/echarts-for-react-3.0.5.tgz",
"integrity": "sha512-YpEI5Ty7O/2nvCfQ7ybNa+S90DwE8KYZWacGvJW4luUqywP7qStQ+pxDlYOmr4jGDu10mhEkiAuMKcUlT4W5vg==",
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
"size-sensor": "^1.0.1"
},
"peerDependencies": {
"echarts": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0",
"react": "^15.0.0 || >=16.0.0"
}
},
"node_modules/echarts/node_modules/tslib": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
"license": "0BSD"
},
"node_modules/ee-first": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
@@ -17374,6 +17406,12 @@
"integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
"license": "ISC"
},
"node_modules/size-sensor": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/size-sensor/-/size-sensor-1.0.2.tgz",
"integrity": "sha512-2NCmWxY7A9pYKGXNBfteo4hy14gWu47rg5692peVMst6lQLPKrVjhY+UTEsPI5ceFRJSl3gVgMYaUi/hKuaiKw==",
"license": "ISC"
},
"node_modules/skin-tone": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/skin-tone/-/skin-tone-2.0.0.tgz",
@@ -19071,6 +19109,21 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/zrender": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/zrender/-/zrender-6.0.0.tgz",
"integrity": "sha512-41dFXEEXuJpNecuUQq6JlbybmnHaqqpGlbH1yxnA5V9MMP4SbohSVZsJIwz+zdjQXSSlR1Vc34EgH1zxyTDvhg==",
"license": "BSD-3-Clause",
"dependencies": {
"tslib": "2.3.0"
}
},
"node_modules/zrender/node_modules/tslib": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
"license": "0BSD"
},
"node_modules/zstd-codec": {
"version": "0.1.5",
"resolved": "https://registry.npmjs.org/zstd-codec/-/zstd-codec-0.1.5.tgz",

View File

@@ -34,6 +34,8 @@
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"deck.gl": "^9.1.14",
"echarts": "^6.0.0",
"echarts-for-react": "^3.0.5",
"js-cookie": "^3.0.5",
"next": "^15.2.4",
"next-auth": "^4.24.5",

View File

@@ -14,7 +14,6 @@ import {
Tooltip,
Typography,
Drawer,
Slider,
} from "@mui/material";
import {
Refresh,
@@ -25,7 +24,8 @@ import {
ChevronRight,
} from "@mui/icons-material";
import { DataGrid, GridColDef } from "@mui/x-data-grid";
import { LineChart } from "@mui/x-charts";
import ReactECharts from "echarts-for-react";
import * as echarts from "echarts";
import "dayjs/locale/zh-cn"; // 引入中文包
import dayjs, { Dayjs } from "dayjs";
import utc from "dayjs/plugin/utc";
@@ -347,10 +347,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
const [isCleaning, setIsCleaning] = useState<boolean>(false);
const [selectedSource, setSelectedSource] = useState<
"raw" | "clean" | "sim" | "all"
>("all");
// 滑块状态:用于图表缩放
const [zoomRange, setZoomRange] = useState<[number, number]>([0, 100]);
>(() => (deviceIds.length === 1 ? "all" : "clean"));
// 获取 SCADA 设备信息,生成 deviceLabels
useEffect(() => {
@@ -396,21 +393,6 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
[timeSeries, deviceIds, fractionDigits, showCleaning]
);
// 根据滑块范围过滤数据集
const filteredDataset = useMemo(() => {
if (dataset.length === 0) return dataset;
const startIndex = Math.floor((zoomRange[0] / 100) * dataset.length);
const endIndex = Math.ceil((zoomRange[1] / 100) * dataset.length);
return dataset.slice(startIndex, endIndex);
}, [dataset, zoomRange]);
// 重置滑块范围当数据变化时
useEffect(() => {
setZoomRange([0, 100]);
}, [timeSeries]);
const handleFetch = useCallback(
async (reason: string) => {
if (!hasDevices) {
@@ -530,10 +512,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
if (deviceIds.length > 1 && selectedSource === "all") {
setSelectedSource("clean");
}
// else if (deviceIds.length === 1 && selectedSource !== "all") {
// setSelectedSource("all");
// }
}, [deviceIds.length]);
}, [deviceIds.length, selectedSource]);
const columns: GridColDef[] = useMemo(() => {
const base: GridColDef[] = [
@@ -680,17 +659,119 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
"#3f51b5", // 靛蓝色
];
// 获取当前显示范围的时间边界
const getTimeRangeLabel = () => {
if (filteredDataset.length === 0) return "";
const firstTime = filteredDataset[0].time;
const lastTime = filteredDataset[filteredDataset.length - 1].time;
if (firstTime instanceof Date && lastTime instanceof Date) {
return `${dayjs(firstTime).format("MM-DD HH:mm")} ~ ${dayjs(
lastTime
).format("MM-DD HH:mm")}`;
const xData = dataset.map((item) => item.label);
const getSeries = () => {
if (showCleaning) {
if (selectedSource === "all") {
return deviceIds.flatMap((id, index) => [
{
name: `${deviceLabels?.[id] ?? id} (原始)`,
type: "line",
symbol: "none",
sampling: "lttb",
itemStyle: { color: colors[index % colors.length] },
data: dataset.map((item) => item[`${id}_raw`]),
},
{
name: `${deviceLabels?.[id] ?? id} (清洗)`,
type: "line",
symbol: "none",
sampling: "lttb",
itemStyle: { color: colors[(index + 3) % colors.length] },
data: dataset.map((item) => item[`${id}_clean`]),
},
{
name: `${deviceLabels?.[id] ?? id} (模拟)`,
type: "line",
symbol: "none",
sampling: "lttb",
itemStyle: { color: colors[(index + 6) % colors.length] },
data: dataset.map((item) => item[`${id}_sim`]),
},
]);
} else {
return deviceIds.map((id, index) => ({
name: deviceLabels?.[id] ?? id,
type: "line",
symbol: "none",
sampling: "lttb",
itemStyle: { color: colors[index % colors.length] },
data: dataset.map((item) => item[`${id}_${selectedSource}`]),
}));
}
return "";
} else {
return deviceIds.map((id, index) => ({
name: deviceLabels?.[id] ?? id,
type: "line",
symbol: "none",
sampling: "lttb",
itemStyle: { color: colors[index % colors.length] },
data: dataset.map((item) => item[id]),
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{
offset: 0,
color: colors[index % colors.length],
},
{
offset: 1,
color: "rgba(255, 255, 255, 0)",
},
]),
opacity: 0.3,
},
}));
}
};
const option = {
tooltip: {
trigger: "axis",
confine: true,
position: function (pt: any[]) {
return [pt[0], "10%"];
},
},
legend: {
top: "top",
},
grid: {
left: "5%",
right: "5%",
bottom: "11%",
containLabel: true,
},
toolbox: {
feature: {
dataZoom: {
yAxisIndex: "none",
},
restore: {},
saveAsImage: {},
},
},
xAxis: {
type: "category",
boundaryGap: false,
data: xData,
},
yAxis: {
type: "value",
scale: true,
},
dataZoom: [
{
type: "inside",
start: 0,
end: 100,
},
{
start: 0,
end: 100,
},
],
series: getSeries(),
};
return (
@@ -700,238 +781,16 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
height: "100%",
display: "flex",
flexDirection: "column",
overflow: "hidden",
}}
>
<Box sx={{ flex: 1 }}>
<LineChart
dataset={filteredDataset}
height={480}
margin={{ left: 70, right: 40, top: 30, bottom: 90 }}
xAxis={[
{
dataKey: "time",
scaleType: "time",
valueFormatter: (value) =>
value instanceof Date
? dayjs(value).format("MM-DD HH:mm")
: String(value),
tickLabelStyle: {
angle: -45,
textAnchor: "end",
fontSize: 11,
fill: "#666",
},
},
]}
yAxis={[
{
label: "压力/流量值",
labelStyle: {
fontSize: 13,
fill: "#333",
fontWeight: 500,
},
tickLabelStyle: {
fontSize: 11,
fill: "#666",
},
},
]}
series={(() => {
if (showCleaning) {
if (selectedSource === "all") {
// 全部模式:显示所有设备的三种数据
return deviceIds.flatMap((id, index) => [
{
dataKey: `${id}_raw`,
label: `${deviceLabels?.[id] ?? id} (原始)`,
showMark: dataset.length < 50,
curve: "catmullRom",
color: colors[index % colors.length],
valueFormatter: (value: number | null) =>
value !== null ? value.toFixed(fractionDigits) : "--",
area: false,
stack: undefined,
},
{
dataKey: `${id}_clean`,
label: `${deviceLabels?.[id] ?? id} (清洗)`,
showMark: dataset.length < 50,
curve: "catmullRom",
color: colors[(index + 3) % colors.length],
valueFormatter: (value: number | null) =>
value !== null ? value.toFixed(fractionDigits) : "--",
area: false,
stack: undefined,
},
{
dataKey: `${id}_sim`,
label: `${deviceLabels?.[id] ?? id} (模拟)`,
showMark: dataset.length < 50,
curve: "catmullRom",
color: colors[(index + 6) % colors.length],
valueFormatter: (value: number | null) =>
value !== null ? value.toFixed(fractionDigits) : "--",
area: false,
stack: undefined,
},
]);
} else {
// 单一数据源模式:只显示选中的数据源
return deviceIds.map((id, index) => ({
dataKey: `${id}_${selectedSource}`,
label: deviceLabels?.[id] ?? id,
showMark: dataset.length < 50,
curve: "catmullRom",
color: colors[index % colors.length],
valueFormatter: (value: number | null) =>
value !== null ? value.toFixed(fractionDigits) : "--",
area: false,
stack: undefined,
}));
}
} else {
return deviceIds.map((id, index) => ({
dataKey: id,
label: deviceLabels?.[id] ?? id,
showMark: dataset.length < 50,
curve: "catmullRom",
color: colors[index % colors.length],
valueFormatter: (value: number | null) =>
value !== null ? value.toFixed(fractionDigits) : "--",
area: false,
stack: undefined,
}));
}
})()}
grid={{ vertical: true, horizontal: true }}
sx={{
"& .MuiLineElement-root": {
strokeWidth: 2.5,
strokeLinecap: "round",
strokeLinejoin: "round",
},
"& .MuiMarkElement-root": {
scale: "0.8",
strokeWidth: 2,
},
"& .MuiChartsAxis-line": {
stroke: "#e0e0e0",
strokeWidth: 1,
},
"& .MuiChartsAxis-tick": {
stroke: "#e0e0e0",
strokeWidth: 1,
},
"& .MuiChartsGrid-line": {
stroke: "#d0d0d0",
strokeWidth: 0.8,
strokeDasharray: "4 4",
},
}}
slotProps={{
legend: {
direction: "row",
position: { horizontal: "middle", vertical: "bottom" },
padding: { bottom: 2, left: 0, right: 0 },
itemMarkWidth: 16,
itemMarkHeight: 3,
markGap: 8,
itemGap: 16,
labelStyle: {
fontSize: 12,
fill: "#333",
fontWeight: 500,
},
},
loadingOverlay: {
style: { backgroundColor: "rgba(255, 255, 255, 0.7)" },
},
}}
tooltip={{
trigger: "axis",
}}
<ReactECharts
option={option}
style={{ height: "100%", width: "100%" }}
notMerge={true}
lazyUpdate={true}
/>
</Box>
{/* 时间范围滑块 */}
<Box sx={{ px: 3, pb: 2, pt: 1 }}>
<Stack direction="row" spacing={2} alignItems="center">
<Typography
variant="body2"
sx={{ minWidth: 60, color: "text.secondary", fontSize: "0.8rem" }}
>
</Typography>
<Slider
value={zoomRange}
onChange={(_, newValue) =>
setZoomRange(newValue as [number, number])
}
valueLabelDisplay="auto"
valueLabelFormat={(value) => {
const index = Math.floor((value / 100) * dataset.length);
if (dataset[index] && dataset[index].time instanceof Date) {
return dayjs(dataset[index].time).format("MM-DD HH:mm");
}
return `${value}%`;
}}
marks={[
{
value: 0,
label:
dataset.length > 0 && dataset[0].time instanceof Date
? dayjs(dataset[0].time).format("MM-DD HH:mm")
: "起始",
},
{
value: 100,
label:
dataset.length > 0 &&
dataset[dataset.length - 1].time instanceof Date
? dayjs(dataset[dataset.length - 1].time).format(
"MM-DD HH:mm"
)
: "结束",
},
]}
sx={{
flex: 1,
"& .MuiSlider-thumb": {
width: 16,
height: 16,
},
"& .MuiSlider-markLabel": {
fontSize: "0.7rem",
color: "text.secondary",
},
}}
/>
<Button
size="small"
variant="outlined"
onClick={() => setZoomRange([0, 100])}
sx={{ minWidth: 60, fontSize: "0.75rem" }}
>
</Button>
</Stack>
{getTimeRangeLabel() && (
<Typography
variant="caption"
sx={{
color: "primary.main",
display: "block",
textAlign: "center",
mt: 0.5,
}}
>
: {getTimeRangeLabel()} ( {filteredDataset.length}{" "}
)
</Typography>
)}
</Box>
</Box>
);
};
@@ -1087,11 +946,18 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
<DateTimePicker
label="开始时间"
value={from}
onChange={(value) =>
value && dayjs.isDayjs(value) && setFrom(value)
onChange={(value) => {
if (value && dayjs.isDayjs(value) && value.isValid()) {
setFrom(value);
}
}}
onAccept={(value) => {
if (value && dayjs.isDayjs(value) && hasDevices) {
if (
value &&
dayjs.isDayjs(value) &&
value.isValid() &&
hasDevices
) {
handleFetch("date-change");
}
}}
@@ -1103,11 +969,18 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
<DateTimePicker
label="结束时间"
value={to}
onChange={(value) =>
value && dayjs.isDayjs(value) && setTo(value)
onChange={(value) => {
if (value && dayjs.isDayjs(value) && value.isValid()) {
setTo(value);
}
}}
onAccept={(value) => {
if (value && dayjs.isDayjs(value) && hasDevices) {
if (
value &&
dayjs.isDayjs(value) &&
value.isValid() &&
hasDevices
) {
handleFetch("date-change");
}
}}