使用 echart 绘制图表
This commit is contained in:
53
package-lock.json
generated
53
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}`]),
|
||||
}));
|
||||
}
|
||||
} 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,
|
||||
},
|
||||
}));
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
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,237 +781,15 @@ 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",
|
||||
}}
|
||||
/>
|
||||
</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>
|
||||
<ReactECharts
|
||||
option={option}
|
||||
style={{ height: "100%", width: "100%" }}
|
||||
notMerge={true}
|
||||
lazyUpdate={true}
|
||||
/>
|
||||
</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");
|
||||
}
|
||||
}}
|
||||
|
||||
Reference in New Issue
Block a user