更新历史数据面板显示和设计;更新scada数据全部清洗api数据格式;更新scada数据显示模拟数据;更新scada数据面板,非清洗状态下能显示两条数据;新增注释,未来准备修复矢量瓦片样式更新时闪烁的问题;

This commit is contained in:
JIANG
2025-12-15 17:40:05 +08:00
parent 2fc9075cce
commit fd064f3aa9
5 changed files with 437 additions and 413 deletions

View File

@@ -7,41 +7,29 @@ import {
Chip, Chip,
CircularProgress, CircularProgress,
Divider, Divider,
IconButton,
Stack, Stack,
Tab, Tab,
Tabs, Tabs,
Tooltip, Tooltip,
Typography, Typography,
Drawer, Drawer,
Slider,
} from "@mui/material"; } from "@mui/material";
import { import { Refresh, ShowChart, TableChart } from "@mui/icons-material";
Refresh,
ShowChart,
TableChart,
ChevronLeft,
ChevronRight,
} from "@mui/icons-material";
import { DataGrid, GridColDef } from "@mui/x-data-grid"; 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/locale/zh-cn"; // 引入中文包
import dayjs, { Dayjs } from "dayjs"; import dayjs, { Dayjs } from "dayjs";
import utc from "dayjs/plugin/utc"; import utc from "dayjs/plugin/utc";
import timezone from "dayjs/plugin/timezone"; import timezone from "dayjs/plugin/timezone";
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
import { DateTimePicker, LocalizationProvider } from "@mui/x-date-pickers"; import { DateTimePicker, LocalizationProvider } from "@mui/x-date-pickers";
import config, { NETWORK_NAME } from "@/config/config"; import config from "@/config/config";
import { GeoJSON } from "ol/format"; import { GeoJSON } from "ol/format";
dayjs.extend(utc); dayjs.extend(utc);
dayjs.extend(timezone); dayjs.extend(timezone);
type IUser = {
id: number;
name: string;
};
export interface TimeSeriesPoint { export interface TimeSeriesPoint {
/** ISO8601 时间戳 */ /** ISO8601 时间戳 */
timestamp: string; timestamp: string;
@@ -49,23 +37,14 @@ export interface TimeSeriesPoint {
values: Record<string, number | null | undefined>; values: Record<string, number | null | undefined>;
} }
export interface HistoryDataPanelProps { export interface SCADADataPanelProps {
/** 选中的设备 ID 列表(使用 `ids` 代替,组件不再接收 `deviceIds` */ /** 选中的设备 ID 列表 */
/** 设备列表:每项包含 id 与可选的 type例如 'scada' / 'simulation' */ deviceIds: string[];
devices?: { id: string; type?: string }[];
/** 可选:外部传入的开始时间(若传入会作为默认值) */
starttime?: string | Date;
/** 可选:外部传入的结束时间(若传入会作为默认值) */
endtime?: string | Date;
/** 可选:方案名(用于 scheme 类型的查询) */
schemeName?: string;
/** 自定义数据获取器,默认使用后端 API */ /** 自定义数据获取器,默认使用后端 API */
fetchTimeSeriesData?: ( fetchTimeSeriesData?: (
deviceIds: string[], deviceIds: string[],
range: { from: Date; to: Date } range: { from: Date; to: Date }
) => Promise<TimeSeriesPoint[]>; ) => Promise<TimeSeriesPoint[]>;
/** 可选:控制浮窗显示 */
visible?: boolean;
/** 默认展示的选项卡 */ /** 默认展示的选项卡 */
defaultTab?: "chart" | "table"; defaultTab?: "chart" | "table";
/** Y 轴数值的小数位数 */ /** Y 轴数值的小数位数 */
@@ -87,30 +66,53 @@ const fetchFromBackend = async (
return []; return [];
} }
const ids = deviceIds.join(","); const device_ids = deviceIds.join(",");
const starttime = dayjs(range.from).format("YYYY-MM-DD HH:mm:ss"); const start_time = dayjs(range.from).toISOString();
const endtime = dayjs(range.to).format("YYYY-MM-DD HH:mm:ss"); const end_time = dayjs(range.to).toISOString();
// 原始数据接口(清洗功能已移除) // 清洗数据接口
const rawSCADAUrl = `${config.BACKEND_URL}/queryscadadatabydeviceidandtimerange/?ids=${ids}&starttime=${starttime}&endtime=${endtime}`; const cleaningDataUrl = `${config.BACKEND_URL}/timescaledb/scada/by-ids-field-time-range?device_ids=${device_ids}&field=cleaned_value&start_time=${start_time}&end_time=${end_time}`;
// 模拟数据接口(备用) // 原始数据
const simulationSCADAUrl = `${config.BACKEND_URL}/querysimulationscadadatabydeviceidandtimerange/?ids=${ids}&starttime=${starttime}&endtime=${endtime}`; const rawDataUrl = `${config.BACKEND_URL}/timescaledb/scada/by-ids-field-time-range?device_ids=${device_ids}&field=monitored_value&start_time=${start_time}&end_time=${end_time}`;
// 模拟数据接口
const simulationDataUrl = `${config.BACKEND_URL}/timescaledb/composite/scada-simulation?device_ids=${device_ids}&start_time=${start_time}&end_time=${end_time}`;
try { try {
// 先使用原始数据接口 // 优先查询清洗数据和模拟数据
let response = await fetch(rawSCADAUrl); const [cleaningRes, simulationRes] = await Promise.all([
if (!response.ok) { fetch(cleaningDataUrl)
console.warn( .then((r) => (r.ok ? r.json() : null))
`[SCADADataPanel] 原始数据接口返回非 OK${response.status}, 尝试模拟接口` .catch(() => null),
fetch(simulationDataUrl)
.then((r) => (r.ok ? r.json() : null))
.catch(() => null),
]);
const cleaningData = transformBackendData(cleaningRes, deviceIds);
const simulationData = transformBackendData(simulationRes, deviceIds);
// 如果清洗数据有数据,返回清洗和模拟数据
if (cleaningData.length > 0) {
return mergeTimeSeriesData(
cleaningData,
simulationData,
deviceIds,
"clean",
"sim"
);
} else {
// 如果清洗数据没有数据,查询原始数据,返回模拟和原始数据
const rawRes = await fetch(rawDataUrl)
.then((r) => (r.ok ? r.json() : null))
.catch(() => null);
const rawData = transformBackendData(rawRes, deviceIds);
return mergeTimeSeriesData(
simulationData,
rawData,
deviceIds,
"sim",
"raw"
); );
// 尝试模拟接口
response = await fetch(simulationSCADAUrl);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
} }
const data = await response.json();
const transformedData = transformBackendData(data, deviceIds);
return transformedData;
} catch (error) { } catch (error) {
console.error("[SCADADataPanel] 从后端获取数据失败:", error); console.error("[SCADADataPanel] 从后端获取数据失败:", error);
throw error; throw error;
@@ -172,6 +174,48 @@ const transformBackendData = (
return []; return [];
}; };
/**
* 合并两个时间序列数据,为每个设备添加后缀
*/
const mergeTimeSeriesData = (
data1: TimeSeriesPoint[],
data2: TimeSeriesPoint[],
deviceIds: string[],
suffix1: string,
suffix2: string
): TimeSeriesPoint[] => {
const timeMap = new Map<string, Record<string, number | null>>();
const processData = (data: TimeSeriesPoint[], suffix: string) => {
data.forEach((point) => {
if (!timeMap.has(point.timestamp)) {
timeMap.set(point.timestamp, {});
}
const values = timeMap.get(point.timestamp)!;
deviceIds.forEach((deviceId) => {
const value = point.values[deviceId];
if (value !== undefined) {
values[`${deviceId}_${suffix}`] = value;
}
});
});
};
processData(data1, suffix1);
processData(data2, suffix2);
const result = Array.from(timeMap.entries()).map(([timestamp, values]) => ({
timestamp,
values,
}));
result.sort(
(a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
);
return result;
};
const defaultFetcher = fetchFromBackend; const defaultFetcher = fetchFromBackend;
const formatTimestamp = (timestamp: string) => const formatTimestamp = (timestamp: string) =>
@@ -199,13 +243,18 @@ const buildDataset = (
}; };
deviceIds.forEach((id) => { deviceIds.forEach((id) => {
const value = point.values[id]; ["raw", "clean", "sim"].forEach((suffix) => {
entry[id] = const key = `${id}_${suffix}`;
typeof value === "number" const value = point.values[key];
? Number.isFinite(value) if (value !== undefined && value !== null) {
? parseFloat(value.toFixed(fractionDigits)) entry[key] =
: null typeof value === "number"
: value ?? null; ? Number.isFinite(value)
? parseFloat(value.toFixed(fractionDigits))
: null
: value ?? null;
}
});
}); });
return entry; return entry;
@@ -226,38 +275,26 @@ const emptyStateMessages: Record<
}, },
}; };
const HistoryDataPanel: React.FC<HistoryDataPanelProps> = ({ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
devices = [], deviceIds,
starttime,
endtime,
schemeName,
fetchTimeSeriesData = defaultFetcher, fetchTimeSeriesData = defaultFetcher,
visible = true,
defaultTab = "chart", defaultTab = "chart",
fractionDigits = 3, fractionDigits = 2,
}) => { }) => {
// 从 devices 中提取 id 列表用于渲染与查询 const customFetcher = useMemo(() => {
const deviceIds = devices?.map((d) => d.id) ?? []; return fetchTimeSeriesData;
}, [fetchTimeSeriesData]);
// 清洗功能已移除,直接使用传入的 fetcher const [from, setFrom] = useState<Dayjs>(() => dayjs().subtract(1, "day"));
const customFetcher = fetchTimeSeriesData; const [to, setTo] = useState<Dayjs>(() => dayjs());
const [from, setFrom] = useState<Dayjs>(() =>
starttime ? dayjs(starttime) : dayjs().subtract(1, "day")
);
const [to, setTo] = useState<Dayjs>(() =>
endtime ? dayjs(endtime) : dayjs()
);
const [activeTab, setActiveTab] = useState<PanelTab>(defaultTab); const [activeTab, setActiveTab] = useState<PanelTab>(defaultTab);
const [timeSeries, setTimeSeries] = useState<TimeSeriesPoint[]>([]); const [timeSeries, setTimeSeries] = useState<TimeSeriesPoint[]>([]);
const [loadingState, setLoadingState] = useState<LoadingState>("idle"); const [loadingState, setLoadingState] = useState<LoadingState>("idle");
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [isExpanded, setIsExpanded] = useState<boolean>(true);
const [deviceLabels, setDeviceLabels] = useState<Record<string, string>>({}); const [deviceLabels, setDeviceLabels] = useState<Record<string, string>>({});
// 清洗相关状态移除 const [selectedSource, setSelectedSource] = useState<
"raw" | "clean" | "sim" | "all"
// 滑块状态:用于图表缩放 >(() => (deviceIds.length === 1 ? "all" : "clean"));
const [zoomRange, setZoomRange] = useState<[number, number]>([0, 100]);
// 获取 SCADA 设备信息,生成 deviceLabels // 获取 SCADA 设备信息,生成 deviceLabels
useEffect(() => { useEffect(() => {
@@ -303,21 +340,6 @@ const HistoryDataPanel: React.FC<HistoryDataPanelProps> = ({
[timeSeries, deviceIds, fractionDigits] [timeSeries, deviceIds, fractionDigits]
); );
// 根据滑块范围过滤数据集
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( const handleFetch = useCallback(
async (reason: string) => { async (reason: string) => {
if (!hasDevices) { if (!hasDevices) {
@@ -354,6 +376,13 @@ const HistoryDataPanel: React.FC<HistoryDataPanelProps> = ({
} }
}, [deviceIds.join(",")]); }, [deviceIds.join(",")]);
// 当设备数量变化时,调整数据源选择
useEffect(() => {
if (deviceIds.length > 1 && selectedSource === "all") {
setSelectedSource("clean");
}
}, [deviceIds.length, selectedSource]);
const columns: GridColDef[] = useMemo(() => { const columns: GridColDef[] = useMemo(() => {
const base: GridColDef[] = [ const base: GridColDef[] = [
{ {
@@ -364,22 +393,24 @@ const HistoryDataPanel: React.FC<HistoryDataPanelProps> = ({
}, },
]; ];
const dynamic = deviceIds.map<GridColDef>((id) => ({ const dynamic = (() => {
field: id, return deviceIds.map<GridColDef>((id) => ({
headerName: deviceLabels?.[id] ?? id, field: id,
minWidth: 140, headerName: deviceLabels?.[id] ?? id,
flex: 1, minWidth: 140,
valueFormatter: (value: any) => { flex: 1,
if (value === null || value === undefined) return "--"; valueFormatter: (value: any) => {
if (Number.isFinite(Number(value))) { if (value === null || value === undefined) return "--";
return Number(value).toFixed(fractionDigits); if (Number.isFinite(Number(value))) {
} return Number(value).toFixed(fractionDigits);
return String(value); }
}, return String(value);
})); },
}));
})();
return [...base, ...dynamic]; return [...base, ...dynamic];
}, [deviceIds, deviceLabels, fractionDigits]); }, [deviceIds, deviceLabels, fractionDigits, selectedSource]);
const rows = useMemo( const rows = useMemo(
() => () =>
@@ -435,17 +466,120 @@ const HistoryDataPanel: React.FC<HistoryDataPanelProps> = ({
"#3f51b5", // 靛蓝色 "#3f51b5", // 靛蓝色
]; ];
// 获取当前显示范围的时间边界 const xData = dataset.map((item) => item.label);
const getTimeRangeLabel = () => {
if (filteredDataset.length === 0) return ""; const getSeries = () => {
const firstTime = filteredDataset[0].time; return deviceIds.flatMap((id, index) => {
const lastTime = filteredDataset[filteredDataset.length - 1].time; const series = [];
if (firstTime instanceof Date && lastTime instanceof Date) { ["raw", "clean", "sim"].forEach((suffix, sIndex) => {
return `${dayjs(firstTime).format("MM-DD HH:mm")} ~ ${dayjs( const key = `${id}_${suffix}`;
lastTime const hasData = dataset.some(
).format("MM-DD HH:mm")}`; (item) => item[key] !== null && item[key] !== undefined
} );
return ""; if (hasData) {
series.push({
name: `${deviceLabels?.[id] ?? id} (${
suffix === "raw" ? "原始" : suffix === "clean" ? "清洗" : "模拟"
})`,
type: "line",
symbol: "none",
sampling: "lttb",
itemStyle: {
color: colors[(index * 3 + sIndex) % colors.length],
},
data: dataset.map((item) => item[key]),
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{
offset: 0,
color: colors[(index * 3 + sIndex) % colors.length],
},
{
offset: 1,
color: "rgba(255, 255, 255, 0)",
},
]),
opacity: 0.3,
},
});
}
});
// 如果没有clean/raw/sim数据则使用fallback
if (series.length === 0) {
series.push({
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 series;
});
};
const option = {
// animation: false,
animationDuration: 500,
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 ( return (
@@ -455,181 +589,15 @@ const HistoryDataPanel: React.FC<HistoryDataPanelProps> = ({
height: "100%", height: "100%",
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
overflow: "hidden",
}} }}
> >
<Box sx={{ flex: 1 }}> <ReactECharts
<LineChart option={option}
dataset={filteredDataset} style={{ height: "100%", width: "100%" }}
height={480} notMerge={true}
margin={{ left: 70, right: 40, top: 30, bottom: 90 }} lazyUpdate={true}
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={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>
</Box> </Box>
); );
}; };
@@ -680,31 +648,9 @@ const HistoryDataPanel: React.FC<HistoryDataPanelProps> = ({
return ( return (
<> <>
{/* 收起时的触发按钮 */}
{!isExpanded && hasDevices && (
<Box
className="absolute top-20 right-4 bg-white shadow-2xl rounded-lg cursor-pointer hover:shadow-xl transition-all duration-300 opacity-95 hover:opacity-100"
onClick={() => setIsExpanded(true)}
sx={{ zIndex: 1300 }}
>
<Box className="flex flex-col items-center py-3 px-3 gap-1">
<ShowChart className="text-[#257DD4] w-5 h-5" />
<Typography
variant="caption"
className="text-gray-700 font-semibold my-1 text-xs"
style={{ writingMode: "vertical-rl" }}
>
</Typography>
<ChevronLeft className="text-gray-600 w-4 h-4" />
</Box>
</Box>
)}
{/* 主面板 */} {/* 主面板 */}
<Drawer <Drawer
anchor="right" anchor="right"
open={isExpanded && visible}
variant="persistent" variant="persistent"
hideBackdrop hideBackdrop
sx={{ sx={{
@@ -749,7 +695,7 @@ const HistoryDataPanel: React.FC<HistoryDataPanelProps> = ({
<Stack direction="row" spacing={1} alignItems="center"> <Stack direction="row" spacing={1} alignItems="center">
<ShowChart fontSize="small" /> <ShowChart fontSize="small" />
<Typography variant="h6" sx={{ fontWeight: "bold" }}> <Typography variant="h6" sx={{ fontWeight: "bold" }}>
SCADA
</Typography> </Typography>
<Chip <Chip
size="small" size="small"
@@ -761,17 +707,6 @@ const HistoryDataPanel: React.FC<HistoryDataPanelProps> = ({
}} }}
/> />
</Stack> </Stack>
<Stack direction="row" spacing={1}>
<Tooltip title="收起">
<IconButton
size="small"
onClick={() => setIsExpanded(false)}
sx={{ color: "primary.contrastText" }}
>
<ChevronRight fontSize="small" />
</IconButton>
</Tooltip>
</Stack>
</Stack> </Stack>
</Box> </Box>
@@ -786,11 +721,18 @@ const HistoryDataPanel: React.FC<HistoryDataPanelProps> = ({
<DateTimePicker <DateTimePicker
label="开始时间" label="开始时间"
value={from} value={from}
onChange={(value) => onChange={(value) => {
value && dayjs.isDayjs(value) && setFrom(value) if (value && dayjs.isDayjs(value) && value.isValid()) {
} setFrom(value);
}
}}
onAccept={(value) => { onAccept={(value) => {
if (value && dayjs.isDayjs(value) && hasDevices) { if (
value &&
dayjs.isDayjs(value) &&
value.isValid() &&
hasDevices
) {
handleFetch("date-change"); handleFetch("date-change");
} }
}} }}
@@ -802,11 +744,18 @@ const HistoryDataPanel: React.FC<HistoryDataPanelProps> = ({
<DateTimePicker <DateTimePicker
label="结束时间" label="结束时间"
value={to} value={to}
onChange={(value) => onChange={(value) => {
value && dayjs.isDayjs(value) && setTo(value) if (value && dayjs.isDayjs(value) && value.isValid()) {
} setTo(value);
}
}}
onAccept={(value) => { onAccept={(value) => {
if (value && dayjs.isDayjs(value) && hasDevices) { if (
value &&
dayjs.isDayjs(value) &&
value.isValid() &&
hasDevices
) {
handleFetch("date-change"); handleFetch("date-change");
} }
}} }}
@@ -857,7 +806,6 @@ const HistoryDataPanel: React.FC<HistoryDataPanelProps> = ({
</Tooltip> </Tooltip>
</Stack> </Stack>
</Stack> </Stack>
{/* 清洗功能已移除,数据源选择请通过后端或外部参数控制 */}
</Stack> </Stack>
</LocalizationProvider> </LocalizationProvider>
@@ -909,4 +857,4 @@ const HistoryDataPanel: React.FC<HistoryDataPanelProps> = ({
); );
}; };
export default HistoryDataPanel; export default SCADADataPanel;

View File

@@ -814,6 +814,8 @@ const StyleEditorPanel: React.FC<StyleEditorPanelProps> = ({
const junctionStyleConfigState = layerStyleStates.find( const junctionStyleConfigState = layerStyleStates.find(
(s) => s.layerId === "junctions" (s) => s.layerId === "junctions"
); );
// setStyle() 会清除渲染器缓存,这是闪烁的主要原因 WebGLVectorTile.js:114-118
// 尝试考虑使用 updateStyleVariables() 更新
applyClassificationStyle( applyClassificationStyle(
"junctions", "junctions",
junctionStyleConfigState?.styleConfig junctionStyleConfigState?.styleConfig

View File

@@ -46,6 +46,7 @@ interface DataContextType {
setCurrentJunctionCalData?: React.Dispatch<React.SetStateAction<any[]>>; setCurrentJunctionCalData?: React.Dispatch<React.SetStateAction<any[]>>;
currentPipeCalData?: any[]; // 当前计算结果 currentPipeCalData?: any[]; // 当前计算结果
setCurrentPipeCalData?: React.Dispatch<React.SetStateAction<any[]>>; setCurrentPipeCalData?: React.Dispatch<React.SetStateAction<any[]>>;
pipeData?: any[]; // 管道数据(含坐标)
showJunctionText?: boolean; // 是否显示节点文本 showJunctionText?: boolean; // 是否显示节点文本
showPipeText?: boolean; // 是否显示管道文本 showPipeText?: boolean; // 是否显示管道文本
setShowJunctionTextLayer?: React.Dispatch<React.SetStateAction<boolean>>; setShowJunctionTextLayer?: React.Dispatch<React.SetStateAction<boolean>>;
@@ -67,7 +68,7 @@ interface DataContextType {
const MapContext = createContext<OlMap | undefined>(undefined); const MapContext = createContext<OlMap | undefined>(undefined);
const DataContext = createContext<DataContextType | undefined>(undefined); const DataContext = createContext<DataContextType | undefined>(undefined);
const MAP_EXTENT = config.MAP_EXTENT; const MAP_EXTENT = config.MAP_EXTENT as [number, number, number, number];
const MAP_URL = config.MAP_URL; const MAP_URL = config.MAP_URL;
const MAP_WORKSPACE = config.MAP_WORKSPACE; const MAP_WORKSPACE = config.MAP_WORKSPACE;
const MAP_VIEW_STORAGE_KEY = `${MAP_WORKSPACE}_map_view`; // 持久化 key const MAP_VIEW_STORAGE_KEY = `${MAP_WORKSPACE}_map_view`; // 持久化 key
@@ -101,12 +102,13 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
// const [selectedDate, setSelectedDate] = useState<Date>(new Date("2025-9-17")); // const [selectedDate, setSelectedDate] = useState<Date>(new Date("2025-9-17"));
const [selectedDate, setSelectedDate] = useState<Date>(new Date()); // 默认今天 const [selectedDate, setSelectedDate] = useState<Date>(new Date()); // 默认今天
const [schemeName, setSchemeName] = useState<string>(""); // 当前方案名称 const [schemeName, setSchemeName] = useState<string>(""); // 当前方案名称
// 记录 id、对应属性的计算值
const [currentJunctionCalData, setCurrentJunctionCalData] = useState<any[]>( const [currentJunctionCalData, setCurrentJunctionCalData] = useState<any[]>(
[] []
); );
const [currentPipeCalData, setCurrentPipeCalData] = useState<any[]>([]); const [currentPipeCalData, setCurrentPipeCalData] = useState<any[]>([]);
// junctionData 和 pipeData 分别缓存瓦片解析后节点和管道的数据,用于 deck.gl 定位、标签渲染 // junctionData 和 pipeData 分别缓存瓦片解析后节点和管道的数据,用于 deck.gl 定位、标签渲染
// currentJunctionCalData 和 currentPipeCalData 变化时会新增并更新 junctionData 和 pipeData 的计算属性值
const [junctionData, setJunctionDataState] = useState<any[]>([]); const [junctionData, setJunctionDataState] = useState<any[]>([]);
const [pipeData, setPipeDataState] = useState<any[]>([]); const [pipeData, setPipeDataState] = useState<any[]>([]);
const junctionDataIds = useRef(new Set<string>()); const junctionDataIds = useRef(new Set<string>());
@@ -307,7 +309,7 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
value: "pipes", value: "pipes",
type: "linestring", type: "linestring",
properties: [ properties: [
// { name: "直径", value: "diameter" }, { name: "管径", value: "diameter" },
// { name: "粗糙度", value: "roughness" }, // { name: "粗糙度", value: "roughness" },
// { name: "局部损失", value: "minor_loss" }, // { name: "局部损失", value: "minor_loss" },
{ name: "流量", value: "flow" }, { name: "流量", value: "flow" },
@@ -589,7 +591,9 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
viewState && viewState &&
Array.isArray(viewState.center) && Array.isArray(viewState.center) &&
viewState.center.length === 2 && viewState.center.length === 2 &&
typeof viewState.zoom === "number" typeof viewState.zoom === "number" &&
viewState.zoom >= 11 &&
viewState.zoom <= 24
) { ) {
map.getView().setCenter(viewState.center); map.getView().setCenter(viewState.center);
map.getView().setZoom(viewState.zoom); map.getView().setZoom(viewState.zoom);
@@ -635,7 +639,7 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
const zoom = map.getView().getZoom() || 0; const zoom = map.getView().getZoom() || 0;
setCurrentZoom(zoom); setCurrentZoom(zoom);
persistView(); persistView();
}, 0); }, 250);
}; };
map.getView().on("change", handleViewChange); map.getView().on("change", handleViewChange);
@@ -689,11 +693,22 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
data: junctionData, data: junctionData,
getPosition: (d: any) => d.position, getPosition: (d: any) => d.position,
fontFamily: "Monaco, monospace", fontFamily: "Monaco, monospace",
getText: (d: any) => getText: (d: any) => {
d[junctionText] ? (d[junctionText] as number).toFixed(3) : "", if (!d[junctionText]) return "";
getSize: 18, const value = (d[junctionText] as number).toFixed(3);
// 根据属性类型添加符号前缀
const prefix =
{
pressure: "P:",
head: "H:",
quality: "Q:",
actualdemand: "D:",
}[junctionText] || "";
return `${prefix}${value}`;
},
getSize: 14,
fontWeight: "bold", fontWeight: "bold",
getColor: [0, 0, 0], getColor: [33, 37, 41], // 深灰色,在灰白背景上清晰可见
getAngle: 0, getAngle: 0,
getTextAnchor: "middle", getTextAnchor: "middle",
getAlignmentBaseline: "center", getAlignmentBaseline: "center",
@@ -701,18 +716,18 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
visible: showJunctionTextLayer && currentZoom >= 15 && currentZoom <= 24, visible: showJunctionTextLayer && currentZoom >= 15 && currentZoom <= 24,
extensions: [new CollisionFilterExtension()], extensions: [new CollisionFilterExtension()],
collisionTestProps: { collisionTestProps: {
sizeScale: 3, // 增加碰撞检测的尺寸以提供更大间距 sizeScale: 3,
}, },
// 可读性设置
characterSet: "auto", characterSet: "auto",
fontSettings: { fontSettings: {
sdf: true, sdf: true,
fontSize: 64, fontSize: 64,
buffer: 6, buffer: 6,
}, },
// outlineWidth: 10, // outlineWidth: 3,
// outlineColor: [242, 244, 246, 255], // outlineColor: [255, 255, 255, 220],
}); });
const pipeTextLayer = new TextLayer({ const pipeTextLayer = new TextLayer({
id: "pipeTextLayer", id: "pipeTextLayer",
name: "管道文字", name: "管道文字",
@@ -720,11 +735,23 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
data: pipeData, data: pipeData,
getPosition: (d: any) => d.position, getPosition: (d: any) => d.position,
fontFamily: "Monaco, monospace", fontFamily: "Monaco, monospace",
getText: (d: any) => getText: (d: any) => {
d[pipeText] ? Math.abs(d[pipeText] as number).toFixed(3) : "", if (!d[pipeText]) return "";
getSize: 18, const value = Math.abs(d[pipeText] as number).toFixed(3);
// 根据属性类型添加符号前缀
const prefix =
{
flow: "F:",
velocity: "V:",
headloss: "HL:",
diameter: "D:",
friction: "FR:",
}[pipeText] || "";
return `${prefix}${value}`;
},
getSize: 14,
fontWeight: "bold", fontWeight: "bold",
getColor: [0, 0, 0], getColor: [33, 37, 41], // 深灰色
getAngle: (d: any) => d.angle || 0, getAngle: (d: any) => d.angle || 0,
getPixelOffset: [0, -8], getPixelOffset: [0, -8],
getTextAnchor: "middle", getTextAnchor: "middle",
@@ -732,36 +759,40 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
visible: showPipeTextLayer && currentZoom >= 15 && currentZoom <= 24, visible: showPipeTextLayer && currentZoom >= 15 && currentZoom <= 24,
extensions: [new CollisionFilterExtension()], extensions: [new CollisionFilterExtension()],
collisionTestProps: { collisionTestProps: {
sizeScale: 3, // 增加碰撞检测的尺寸以提供更大间距 sizeScale: 3,
}, },
// 可读性设置
characterSet: "auto", characterSet: "auto",
fontSettings: { fontSettings: {
sdf: true, sdf: true,
fontSize: 64, fontSize: 64,
buffer: 6, buffer: 6,
}, },
// outlineWidth: 10, // outlineWidth: 3,
// outlineColor: [242, 244, 246, 255], // outlineColor: [255, 255, 255, 220],
}); });
const ALPHA = 102;
const contourLayer = new ContourLayer({ const contourLayer = new ContourLayer({
id: "junctionContourLayer", id: "junctionContourLayer",
name: "等值线", name: "等值线",
data: junctionData, data: junctionData,
aggregation: "MEAN", aggregation: "MEAN",
cellSize: 200, cellSize: 600,
strokeWidth: 0,
contours: [ contours: [
{ threshold: [0, 16], color: [255, 0, 0] }, // { threshold: [0, 16], color: [255, 0, 0, ALPHA], strokeWidth: 0 },
{ threshold: [16, 20], color: [255, 127, 0] }, { threshold: [16, 18], color: [255, 0, 0, 0], strokeWidth: 0 },
{ threshold: [20, 22], color: [255, 215, 0] }, { threshold: [18, 20], color: [255, 127, 0, ALPHA], strokeWidth: 0 },
{ threshold: [22, 24], color: [199, 224, 0] }, { threshold: [20, 22], color: [255, 215, 0, ALPHA], strokeWidth: 0 },
{ threshold: [24, 26], color: [76, 175, 80] }, { threshold: [22, 24], color: [199, 224, 0, ALPHA], strokeWidth: 0 },
{ threshold: [24, 26], color: [76, 175, 80, ALPHA], strokeWidth: 0 },
{ threshold: [26, 30], color: [63, 81, 181, ALPHA], strokeWidth: 0 },
], ],
getPosition: (d) => d.position, getPosition: (d) => d.position,
getWeight: (d: any) => getWeight: (d: any) =>
d[junctionText] ? Math.abs(d[junctionText] as number) : -1, (d[junctionText] as number) < 0 ? 0 : (d[junctionText] as number),
opacity: 0.4, opacity: 1,
visible: showContourLayer && currentZoom >= 12 && currentZoom <= 24, visible: showContourLayer && currentZoom >= 11 && currentZoom <= 24,
}); });
if (deckLayer.getDeckLayerById("junctionTextLayer")) { if (deckLayer.getDeckLayerById("junctionTextLayer")) {
// 传入完整 layer 实例以保证 clone/替换时保留 layer 类型和方法 // 传入完整 layer 实例以保证 clone/替换时保留 layer 类型和方法
@@ -835,7 +866,10 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
getColor: [0, 220, 255], getColor: [0, 220, 255],
opacity: 0.8, opacity: 0.8,
visible: visible:
flowAnimation.current && currentZoom >= 12 && currentZoom <= 24, isWaterflowLayerAvailable &&
flowAnimation.current &&
currentZoom >= 12 &&
currentZoom <= 24,
widthMinPixels: 5, widthMinPixels: 5,
jointRounded: true, // 拐角变圆 jointRounded: true, // 拐角变圆
// capRounded: true, // 端点变圆 // capRounded: true, // 端点变圆
@@ -858,7 +892,13 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
cancelAnimationFrame(animationFrameId); cancelAnimationFrame(animationFrameId);
} }
}; };
}, [currentZoom, currentPipeCalData, pipeText, pipeData.length]); }, [
currentZoom,
currentPipeCalData,
pipeText,
pipeData.length,
isWaterflowLayerAvailable,
]);
// 计算值更新时,更新 junctionData 和 pipeData // 计算值更新时,更新 junctionData 和 pipeData
useEffect(() => { useEffect(() => {
@@ -923,6 +963,7 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
setCurrentJunctionCalData, setCurrentJunctionCalData,
currentPipeCalData, currentPipeCalData,
setCurrentPipeCalData, setCurrentPipeCalData,
pipeData,
setShowJunctionTextLayer, setShowJunctionTextLayer,
setShowPipeTextLayer, setShowPipeTextLayer,
setShowContourLayer, setShowContourLayer,

View File

@@ -280,17 +280,18 @@ const buildDataset = (
}); });
} else { } else {
deviceIds.forEach((id) => { deviceIds.forEach((id) => {
const value = ["raw", "clean", "sim"].forEach((suffix) => {
point.values[`${id}_clean`] ?? const key = `${id}_${suffix}`;
point.values[`${id}_raw`] ?? const value = point.values[key];
point.values[`${id}_sim`] ?? if (value !== undefined && value !== null) {
point.values[id]; entry[key] =
entry[id] = typeof value === "number"
typeof value === "number" ? Number.isFinite(value)
? Number.isFinite(value) ? parseFloat(value.toFixed(fractionDigits))
? parseFloat(value.toFixed(fractionDigits)) : null
: null : value ?? null;
: value ?? null; }
});
}); });
} }
@@ -762,27 +763,71 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
})); }));
} }
} else { } else {
return deviceIds.map((id, index) => ({ return deviceIds.flatMap((id, index) => {
name: deviceLabels?.[id] ?? id, const series = [];
type: "line", ["raw", "clean", "sim"].forEach((suffix, sIndex) => {
symbol: "none", const key = `${id}_${suffix}`;
sampling: "lttb", const hasData = dataset.some(
itemStyle: { color: colors[index % colors.length] }, (item) => item[key] !== null && item[key] !== undefined
data: dataset.map((item) => item[id]), );
areaStyle: { if (hasData) {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [ series.push({
{ name: `${deviceLabels?.[id] ?? id} (${
offset: 0, suffix === "raw"
color: colors[index % colors.length], ? "原始"
: suffix === "clean"
? "清洗"
: "模拟"
})`,
type: "line",
symbol: "none",
sampling: "lttb",
itemStyle: {
color: colors[(index * 3 + sIndex) % colors.length],
},
data: dataset.map((item) => item[key]),
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{
offset: 0,
color: colors[(index * 3 + sIndex) % colors.length],
},
{
offset: 1,
color: "rgba(255, 255, 255, 0)",
},
]),
opacity: 0.3,
},
});
}
});
// 如果没有clean/raw/sim数据则使用fallback
if (series.length === 0) {
series.push({
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,
}, },
{ });
offset: 1, }
color: "rgba(255, 255, 255, 0)", return series;
}, });
]),
opacity: 0.3,
},
}));
} }
}; };

View File

@@ -602,26 +602,14 @@ const SCADADeviceList: React.FC<SCADADeviceListProps> = ({
} }
try { try {
const startTime = dayjs(cleanStartTime).format("YYYY-MM-DD HH:mm:ss"); const startTime = dayjs(cleanStartTime).toISOString();
const endTime = dayjs(cleanEndTime).format("YYYY-MM-DD HH:mm:ss"); const endTime = dayjs(cleanEndTime).toISOString();
// 调用后端清洗接口 // 调用后端清洗接口
const response = await axios.post( const response = await axios.post(
`${config.BACKEND_URL}/scadadevicedatacleaning/`, `${config.BACKEND_URL}/timescaledb/composite/clean-scada?device_ids=all&start_time=${startTime}&end_time=${endTime}`
null,
{
params: {
network: NETWORK_NAME,
ids_list: allDeviceIds.join(","), // 将数组转为逗号分隔字符串
start_time: startTime,
end_time: endTime,
user_name: user.name,
},
}
); );
console.log("[SCADADeviceList] 全部清洗响应:", response.data);
// 处理成功响应 // 处理成功响应
if (response.data === "success" || response.data?.success === true) { if (response.data === "success" || response.data?.success === true) {
open?.({ open?.({