更新历史数据面板显示和设计;更新scada数据全部清洗api数据格式;更新scada数据显示模拟数据;更新scada数据面板,非清洗状态下能显示两条数据;新增注释,未来准备修复矢量瓦片样式更新时闪烁的问题;
This commit is contained in:
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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?.({
|
||||||
|
|||||||
Reference in New Issue
Block a user