diff --git a/src/app/OlMap/Controls/HistoryDataPanel.tsx b/src/app/OlMap/Controls/HistoryDataPanel.tsx index 2949962..31780af 100644 --- a/src/app/OlMap/Controls/HistoryDataPanel.tsx +++ b/src/app/OlMap/Controls/HistoryDataPanel.tsx @@ -7,41 +7,29 @@ import { Chip, CircularProgress, Divider, - IconButton, Stack, Tab, Tabs, Tooltip, Typography, Drawer, - Slider, } from "@mui/material"; -import { - Refresh, - ShowChart, - TableChart, - ChevronLeft, - ChevronRight, -} from "@mui/icons-material"; +import { Refresh, ShowChart, TableChart } 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"; import timezone from "dayjs/plugin/timezone"; import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; 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"; dayjs.extend(utc); dayjs.extend(timezone); -type IUser = { - id: number; - name: string; -}; - export interface TimeSeriesPoint { /** ISO8601 时间戳 */ timestamp: string; @@ -49,23 +37,14 @@ export interface TimeSeriesPoint { values: Record; } -export interface HistoryDataPanelProps { - /** 选中的设备 ID 列表(使用 `ids` 代替,组件不再接收 `deviceIds`) */ - /** 设备列表:每项包含 id 与可选的 type(例如 'scada' / 'simulation') */ - devices?: { id: string; type?: string }[]; - /** 可选:外部传入的开始时间(若传入会作为默认值) */ - starttime?: string | Date; - /** 可选:外部传入的结束时间(若传入会作为默认值) */ - endtime?: string | Date; - /** 可选:方案名(用于 scheme 类型的查询) */ - schemeName?: string; +export interface SCADADataPanelProps { + /** 选中的设备 ID 列表 */ + deviceIds: string[]; /** 自定义数据获取器,默认使用后端 API */ fetchTimeSeriesData?: ( deviceIds: string[], range: { from: Date; to: Date } ) => Promise; - /** 可选:控制浮窗显示 */ - visible?: boolean; /** 默认展示的选项卡 */ defaultTab?: "chart" | "table"; /** Y 轴数值的小数位数 */ @@ -87,30 +66,53 @@ const fetchFromBackend = async ( return []; } - const ids = deviceIds.join(","); - const starttime = dayjs(range.from).format("YYYY-MM-DD HH:mm:ss"); - const endtime = dayjs(range.to).format("YYYY-MM-DD HH:mm:ss"); - // 原始数据接口(清洗功能已移除) - const rawSCADAUrl = `${config.BACKEND_URL}/queryscadadatabydeviceidandtimerange/?ids=${ids}&starttime=${starttime}&endtime=${endtime}`; - // 模拟数据接口(备用) - const simulationSCADAUrl = `${config.BACKEND_URL}/querysimulationscadadatabydeviceidandtimerange/?ids=${ids}&starttime=${starttime}&endtime=${endtime}`; + const device_ids = deviceIds.join(","); + const start_time = dayjs(range.from).toISOString(); + const end_time = dayjs(range.to).toISOString(); + // 清洗数据接口 + 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 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 { - // 先使用原始数据接口 - let response = await fetch(rawSCADAUrl); - if (!response.ok) { - console.warn( - `[SCADADataPanel] 原始数据接口返回非 OK:${response.status}, 尝试模拟接口` + // 优先查询清洗数据和模拟数据 + const [cleaningRes, simulationRes] = await Promise.all([ + fetch(cleaningDataUrl) + .then((r) => (r.ok ? r.json() : null)) + .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) { console.error("[SCADADataPanel] 从后端获取数据失败:", error); throw error; @@ -172,6 +174,48 @@ const transformBackendData = ( return []; }; +/** + * 合并两个时间序列数据,为每个设备添加后缀 + */ +const mergeTimeSeriesData = ( + data1: TimeSeriesPoint[], + data2: TimeSeriesPoint[], + deviceIds: string[], + suffix1: string, + suffix2: string +): TimeSeriesPoint[] => { + const timeMap = new Map>(); + + 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 formatTimestamp = (timestamp: string) => @@ -199,13 +243,18 @@ const buildDataset = ( }; deviceIds.forEach((id) => { - const value = point.values[id]; - entry[id] = - typeof value === "number" - ? Number.isFinite(value) - ? parseFloat(value.toFixed(fractionDigits)) - : null - : value ?? null; + ["raw", "clean", "sim"].forEach((suffix) => { + const key = `${id}_${suffix}`; + const value = point.values[key]; + if (value !== undefined && value !== null) { + entry[key] = + typeof value === "number" + ? Number.isFinite(value) + ? parseFloat(value.toFixed(fractionDigits)) + : null + : value ?? null; + } + }); }); return entry; @@ -226,38 +275,26 @@ const emptyStateMessages: Record< }, }; -const HistoryDataPanel: React.FC = ({ - devices = [], - starttime, - endtime, - schemeName, +const SCADADataPanel: React.FC = ({ + deviceIds, fetchTimeSeriesData = defaultFetcher, - visible = true, defaultTab = "chart", - fractionDigits = 3, + fractionDigits = 2, }) => { - // 从 devices 中提取 id 列表用于渲染与查询 - const deviceIds = devices?.map((d) => d.id) ?? []; + const customFetcher = useMemo(() => { + return fetchTimeSeriesData; + }, [fetchTimeSeriesData]); - // 清洗功能已移除,直接使用传入的 fetcher - const customFetcher = fetchTimeSeriesData; - - const [from, setFrom] = useState(() => - starttime ? dayjs(starttime) : dayjs().subtract(1, "day") - ); - const [to, setTo] = useState(() => - endtime ? dayjs(endtime) : dayjs() - ); + const [from, setFrom] = useState(() => dayjs().subtract(1, "day")); + const [to, setTo] = useState(() => dayjs()); const [activeTab, setActiveTab] = useState(defaultTab); const [timeSeries, setTimeSeries] = useState([]); const [loadingState, setLoadingState] = useState("idle"); const [error, setError] = useState(null); - const [isExpanded, setIsExpanded] = useState(true); const [deviceLabels, setDeviceLabels] = useState>({}); - // 清洗相关状态移除 - - // 滑块状态:用于图表缩放 - const [zoomRange, setZoomRange] = useState<[number, number]>([0, 100]); + const [selectedSource, setSelectedSource] = useState< + "raw" | "clean" | "sim" | "all" + >(() => (deviceIds.length === 1 ? "all" : "clean")); // 获取 SCADA 设备信息,生成 deviceLabels useEffect(() => { @@ -303,21 +340,6 @@ const HistoryDataPanel: React.FC = ({ [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( async (reason: string) => { if (!hasDevices) { @@ -354,6 +376,13 @@ const HistoryDataPanel: React.FC = ({ } }, [deviceIds.join(",")]); + // 当设备数量变化时,调整数据源选择 + useEffect(() => { + if (deviceIds.length > 1 && selectedSource === "all") { + setSelectedSource("clean"); + } + }, [deviceIds.length, selectedSource]); + const columns: GridColDef[] = useMemo(() => { const base: GridColDef[] = [ { @@ -364,22 +393,24 @@ const HistoryDataPanel: React.FC = ({ }, ]; - const dynamic = deviceIds.map((id) => ({ - field: id, - headerName: deviceLabels?.[id] ?? id, - minWidth: 140, - flex: 1, - valueFormatter: (value: any) => { - if (value === null || value === undefined) return "--"; - if (Number.isFinite(Number(value))) { - return Number(value).toFixed(fractionDigits); - } - return String(value); - }, - })); + const dynamic = (() => { + return deviceIds.map((id) => ({ + field: id, + headerName: deviceLabels?.[id] ?? id, + minWidth: 140, + flex: 1, + valueFormatter: (value: any) => { + if (value === null || value === undefined) return "--"; + if (Number.isFinite(Number(value))) { + return Number(value).toFixed(fractionDigits); + } + return String(value); + }, + })); + })(); return [...base, ...dynamic]; - }, [deviceIds, deviceLabels, fractionDigits]); + }, [deviceIds, deviceLabels, fractionDigits, selectedSource]); const rows = useMemo( () => @@ -435,17 +466,120 @@ const HistoryDataPanel: React.FC = ({ "#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")}`; - } - return ""; + const xData = dataset.map((item) => item.label); + + const getSeries = () => { + return deviceIds.flatMap((id, index) => { + const series = []; + ["raw", "clean", "sim"].forEach((suffix, sIndex) => { + const key = `${id}_${suffix}`; + const hasData = dataset.some( + (item) => item[key] !== null && item[key] !== undefined + ); + 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 ( @@ -455,181 +589,15 @@ const HistoryDataPanel: React.FC = ({ height: "100%", display: "flex", flexDirection: "column", + overflow: "hidden", }} > - - - 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", - }} - /> - - - {/* 时间范围滑块 */} - - - - 时间范围 - - - 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", - }, - }} - /> - - - {getTimeRangeLabel() && ( - - 当前显示: {getTimeRangeLabel()} (共 {filteredDataset.length}{" "} - 个数据点) - - )} - + ); }; @@ -680,31 +648,9 @@ const HistoryDataPanel: React.FC = ({ return ( <> - {/* 收起时的触发按钮 */} - {!isExpanded && hasDevices && ( - setIsExpanded(true)} - sx={{ zIndex: 1300 }} - > - - - - 历史数据 - - - - - )} - {/* 主面板 */} = ({ - SCADA 历史数据 + 历史数据 = ({ }} /> - - - setIsExpanded(false)} - sx={{ color: "primary.contrastText" }} - > - - - - @@ -786,11 +721,18 @@ const HistoryDataPanel: React.FC = ({ - 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"); } }} @@ -802,11 +744,18 @@ const HistoryDataPanel: React.FC = ({ - 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"); } }} @@ -857,7 +806,6 @@ const HistoryDataPanel: React.FC = ({ - {/* 清洗功能已移除,数据源选择请通过后端或外部参数控制 */} @@ -909,4 +857,4 @@ const HistoryDataPanel: React.FC = ({ ); }; -export default HistoryDataPanel; +export default SCADADataPanel; diff --git a/src/app/OlMap/Controls/StyleEditorPanel.tsx b/src/app/OlMap/Controls/StyleEditorPanel.tsx index 9dbb508..cbdfa1f 100644 --- a/src/app/OlMap/Controls/StyleEditorPanel.tsx +++ b/src/app/OlMap/Controls/StyleEditorPanel.tsx @@ -814,6 +814,8 @@ const StyleEditorPanel: React.FC = ({ const junctionStyleConfigState = layerStyleStates.find( (s) => s.layerId === "junctions" ); + // setStyle() 会清除渲染器缓存,这是闪烁的主要原因 WebGLVectorTile.js:114-118 + // 尝试考虑使用 updateStyleVariables() 更新 applyClassificationStyle( "junctions", junctionStyleConfigState?.styleConfig diff --git a/src/app/OlMap/MapComponent.tsx b/src/app/OlMap/MapComponent.tsx index 0886919..a70e31f 100644 --- a/src/app/OlMap/MapComponent.tsx +++ b/src/app/OlMap/MapComponent.tsx @@ -46,6 +46,7 @@ interface DataContextType { setCurrentJunctionCalData?: React.Dispatch>; currentPipeCalData?: any[]; // 当前计算结果 setCurrentPipeCalData?: React.Dispatch>; + pipeData?: any[]; // 管道数据(含坐标) showJunctionText?: boolean; // 是否显示节点文本 showPipeText?: boolean; // 是否显示管道文本 setShowJunctionTextLayer?: React.Dispatch>; @@ -67,7 +68,7 @@ interface DataContextType { const MapContext = createContext(undefined); const DataContext = createContext(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_WORKSPACE = config.MAP_WORKSPACE; const MAP_VIEW_STORAGE_KEY = `${MAP_WORKSPACE}_map_view`; // 持久化 key @@ -101,12 +102,13 @@ const MapComponent: React.FC = ({ children }) => { // const [selectedDate, setSelectedDate] = useState(new Date("2025-9-17")); const [selectedDate, setSelectedDate] = useState(new Date()); // 默认今天 const [schemeName, setSchemeName] = useState(""); // 当前方案名称 - + // 记录 id、对应属性的计算值 const [currentJunctionCalData, setCurrentJunctionCalData] = useState( [] ); const [currentPipeCalData, setCurrentPipeCalData] = useState([]); // junctionData 和 pipeData 分别缓存瓦片解析后节点和管道的数据,用于 deck.gl 定位、标签渲染 + // currentJunctionCalData 和 currentPipeCalData 变化时会新增并更新 junctionData 和 pipeData 的计算属性值 const [junctionData, setJunctionDataState] = useState([]); const [pipeData, setPipeDataState] = useState([]); const junctionDataIds = useRef(new Set()); @@ -307,7 +309,7 @@ const MapComponent: React.FC = ({ children }) => { value: "pipes", type: "linestring", properties: [ - // { name: "直径", value: "diameter" }, + { name: "管径", value: "diameter" }, // { name: "粗糙度", value: "roughness" }, // { name: "局部损失", value: "minor_loss" }, { name: "流量", value: "flow" }, @@ -589,7 +591,9 @@ const MapComponent: React.FC = ({ children }) => { viewState && Array.isArray(viewState.center) && 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().setZoom(viewState.zoom); @@ -635,7 +639,7 @@ const MapComponent: React.FC = ({ children }) => { const zoom = map.getView().getZoom() || 0; setCurrentZoom(zoom); persistView(); - }, 0); + }, 250); }; map.getView().on("change", handleViewChange); @@ -689,11 +693,22 @@ const MapComponent: React.FC = ({ children }) => { data: junctionData, getPosition: (d: any) => d.position, fontFamily: "Monaco, monospace", - getText: (d: any) => - d[junctionText] ? (d[junctionText] as number).toFixed(3) : "", - getSize: 18, + getText: (d: any) => { + if (!d[junctionText]) return ""; + 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", - getColor: [0, 0, 0], + getColor: [33, 37, 41], // 深灰色,在灰白背景上清晰可见 getAngle: 0, getTextAnchor: "middle", getAlignmentBaseline: "center", @@ -701,18 +716,18 @@ const MapComponent: React.FC = ({ children }) => { visible: showJunctionTextLayer && currentZoom >= 15 && currentZoom <= 24, extensions: [new CollisionFilterExtension()], collisionTestProps: { - sizeScale: 3, // 增加碰撞检测的尺寸以提供更大间距 + sizeScale: 3, }, - // 可读性设置 characterSet: "auto", fontSettings: { sdf: true, fontSize: 64, buffer: 6, }, - // outlineWidth: 10, - // outlineColor: [242, 244, 246, 255], + // outlineWidth: 3, + // outlineColor: [255, 255, 255, 220], }); + const pipeTextLayer = new TextLayer({ id: "pipeTextLayer", name: "管道文字", @@ -720,11 +735,23 @@ const MapComponent: React.FC = ({ children }) => { data: pipeData, getPosition: (d: any) => d.position, fontFamily: "Monaco, monospace", - getText: (d: any) => - d[pipeText] ? Math.abs(d[pipeText] as number).toFixed(3) : "", - getSize: 18, + getText: (d: any) => { + if (!d[pipeText]) return ""; + 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", - getColor: [0, 0, 0], + getColor: [33, 37, 41], // 深灰色 getAngle: (d: any) => d.angle || 0, getPixelOffset: [0, -8], getTextAnchor: "middle", @@ -732,36 +759,40 @@ const MapComponent: React.FC = ({ children }) => { visible: showPipeTextLayer && currentZoom >= 15 && currentZoom <= 24, extensions: [new CollisionFilterExtension()], collisionTestProps: { - sizeScale: 3, // 增加碰撞检测的尺寸以提供更大间距 + sizeScale: 3, }, - // 可读性设置 characterSet: "auto", fontSettings: { sdf: true, fontSize: 64, buffer: 6, }, - // outlineWidth: 10, - // outlineColor: [242, 244, 246, 255], + // outlineWidth: 3, + // outlineColor: [255, 255, 255, 220], }); + + const ALPHA = 102; const contourLayer = new ContourLayer({ id: "junctionContourLayer", name: "等值线", data: junctionData, aggregation: "MEAN", - cellSize: 200, + cellSize: 600, + strokeWidth: 0, contours: [ - { threshold: [0, 16], color: [255, 0, 0] }, - { threshold: [16, 20], color: [255, 127, 0] }, - { threshold: [20, 22], color: [255, 215, 0] }, - { threshold: [22, 24], color: [199, 224, 0] }, - { threshold: [24, 26], color: [76, 175, 80] }, + // { threshold: [0, 16], color: [255, 0, 0, ALPHA], strokeWidth: 0 }, + { threshold: [16, 18], color: [255, 0, 0, 0], strokeWidth: 0 }, + { threshold: [18, 20], color: [255, 127, 0, ALPHA], strokeWidth: 0 }, + { threshold: [20, 22], color: [255, 215, 0, ALPHA], strokeWidth: 0 }, + { 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, getWeight: (d: any) => - d[junctionText] ? Math.abs(d[junctionText] as number) : -1, - opacity: 0.4, - visible: showContourLayer && currentZoom >= 12 && currentZoom <= 24, + (d[junctionText] as number) < 0 ? 0 : (d[junctionText] as number), + opacity: 1, + visible: showContourLayer && currentZoom >= 11 && currentZoom <= 24, }); if (deckLayer.getDeckLayerById("junctionTextLayer")) { // 传入完整 layer 实例以保证 clone/替换时保留 layer 类型和方法 @@ -835,7 +866,10 @@ const MapComponent: React.FC = ({ children }) => { getColor: [0, 220, 255], opacity: 0.8, visible: - flowAnimation.current && currentZoom >= 12 && currentZoom <= 24, + isWaterflowLayerAvailable && + flowAnimation.current && + currentZoom >= 12 && + currentZoom <= 24, widthMinPixels: 5, jointRounded: true, // 拐角变圆 // capRounded: true, // 端点变圆 @@ -858,7 +892,13 @@ const MapComponent: React.FC = ({ children }) => { cancelAnimationFrame(animationFrameId); } }; - }, [currentZoom, currentPipeCalData, pipeText, pipeData.length]); + }, [ + currentZoom, + currentPipeCalData, + pipeText, + pipeData.length, + isWaterflowLayerAvailable, + ]); // 计算值更新时,更新 junctionData 和 pipeData useEffect(() => { @@ -923,6 +963,7 @@ const MapComponent: React.FC = ({ children }) => { setCurrentJunctionCalData, currentPipeCalData, setCurrentPipeCalData, + pipeData, setShowJunctionTextLayer, setShowPipeTextLayer, setShowContourLayer, diff --git a/src/components/olmap/SCADADataPanel.tsx b/src/components/olmap/SCADADataPanel.tsx index d695194..5440f75 100644 --- a/src/components/olmap/SCADADataPanel.tsx +++ b/src/components/olmap/SCADADataPanel.tsx @@ -280,17 +280,18 @@ const buildDataset = ( }); } else { deviceIds.forEach((id) => { - const value = - point.values[`${id}_clean`] ?? - point.values[`${id}_raw`] ?? - point.values[`${id}_sim`] ?? - point.values[id]; - entry[id] = - typeof value === "number" - ? Number.isFinite(value) - ? parseFloat(value.toFixed(fractionDigits)) - : null - : value ?? null; + ["raw", "clean", "sim"].forEach((suffix) => { + const key = `${id}_${suffix}`; + const value = point.values[key]; + if (value !== undefined && value !== null) { + entry[key] = + typeof value === "number" + ? Number.isFinite(value) + ? parseFloat(value.toFixed(fractionDigits)) + : null + : value ?? null; + } + }); }); } @@ -762,27 +763,71 @@ const SCADADataPanel: React.FC = ({ })); } } 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], + return deviceIds.flatMap((id, index) => { + const series = []; + ["raw", "clean", "sim"].forEach((suffix, sIndex) => { + const key = `${id}_${suffix}`; + const hasData = dataset.some( + (item) => item[key] !== null && item[key] !== undefined + ); + 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, }, - { - offset: 1, - color: "rgba(255, 255, 255, 0)", - }, - ]), - opacity: 0.3, - }, - })); + }); + } + return series; + }); } }; diff --git a/src/components/olmap/SCADADeviceList.tsx b/src/components/olmap/SCADADeviceList.tsx index 55bcfda..87b2552 100644 --- a/src/components/olmap/SCADADeviceList.tsx +++ b/src/components/olmap/SCADADeviceList.tsx @@ -602,26 +602,14 @@ const SCADADeviceList: React.FC = ({ } try { - const startTime = dayjs(cleanStartTime).format("YYYY-MM-DD HH:mm:ss"); - const endTime = dayjs(cleanEndTime).format("YYYY-MM-DD HH:mm:ss"); + const startTime = dayjs(cleanStartTime).toISOString(); + const endTime = dayjs(cleanEndTime).toISOString(); // 调用后端清洗接口 const response = await axios.post( - `${config.BACKEND_URL}/scadadevicedatacleaning/`, - null, - { - params: { - network: NETWORK_NAME, - ids_list: allDeviceIds.join(","), // 将数组转为逗号分隔字符串 - start_time: startTime, - end_time: endTime, - user_name: user.name, - }, - } + `${config.BACKEND_URL}/timescaledb/composite/clean-scada?device_ids=all&start_time=${startTime}&end_time=${endTime}` ); - console.log("[SCADADeviceList] 全部清洗响应:", response.data); - // 处理成功响应 if (response.data === "success" || response.data?.success === true) { open?.({