From 07861bee0386d437188ba337eba7db862efbf942 Mon Sep 17 00:00:00 2001 From: Huarch Date: Mon, 27 Apr 2026 15:59:49 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E5=AF=B9=E6=AF=94=E6=A8=A1?= =?UTF-8?q?=E5=BC=8F=E5=8A=9F=E8=83=BD=EF=BC=8C=E4=BC=98=E5=8C=96=E5=9C=B0?= =?UTF-8?q?=E5=9B=BE=E7=BB=84=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../burst-simulation/page.tsx | 6 +- .../contaminant-simulation/page.tsx | 6 +- .../olmap/core/Controls/BaseLayers.tsx | 269 +++--- .../olmap/core/Controls/LayerControl.tsx | 15 +- .../olmap/core/Controls/StyleEditorPanel.tsx | 183 +++-- .../olmap/core/Controls/Timeline.tsx | 294 ++++--- .../olmap/core/Controls/Toolbar.tsx | 23 + src/components/olmap/core/MapComponent.tsx | 766 ++++++++++++++---- 8 files changed, 1123 insertions(+), 439 deletions(-) diff --git a/src/app/(main)/hydraulic-simulation/burst-simulation/page.tsx b/src/app/(main)/hydraulic-simulation/burst-simulation/page.tsx index 8078cf7..86f7864 100644 --- a/src/app/(main)/hydraulic-simulation/burst-simulation/page.tsx +++ b/src/app/(main)/hydraulic-simulation/burst-simulation/page.tsx @@ -8,7 +8,11 @@ export default function Home() { return (
- +
diff --git a/src/app/(main)/hydraulic-simulation/contaminant-simulation/page.tsx b/src/app/(main)/hydraulic-simulation/contaminant-simulation/page.tsx index 2c3d499..265c631 100644 --- a/src/app/(main)/hydraulic-simulation/contaminant-simulation/page.tsx +++ b/src/app/(main)/hydraulic-simulation/contaminant-simulation/page.tsx @@ -8,7 +8,11 @@ export default function Home() { return (
- +
diff --git a/src/components/olmap/core/Controls/BaseLayers.tsx b/src/components/olmap/core/Controls/BaseLayers.tsx index 7a6c09a..aa43007 100644 --- a/src/components/olmap/core/Controls/BaseLayers.tsx +++ b/src/components/olmap/core/Controls/BaseLayers.tsx @@ -1,158 +1,174 @@ -import React, { useState, useEffect } from "react"; +"use client"; + +import React, { useState, useEffect, useMemo, useRef } from "react"; import Image from "next/image"; -import { useMap } from "../MapComponent"; +import { useData, useMap } from "../MapComponent"; import TileLayer from "ol/layer/Tile.js"; import XYZ from "ol/source/XYZ.js"; +import Group from "ol/layer/Group"; import mapboxOutdoors from "@assets/map/layers/mapbox-outdoors.png"; import mapboxLight from "@assets/map/layers/mapbox-light.png"; import mapboxSatellite from "@assets/map/layers/mapbox-satellite.png"; import mapboxSatelliteStreet from "@assets/map/layers/mapbox-satellite-streets.png"; import mapboxStreets from "@assets/map/layers/mapbox-streets.png"; import clsx from "clsx"; -import Group from "ol/layer/Group"; -import { MAPBOX_TOKEN } from "@config/config"; -import { TIANDITU_TOKEN } from "@config/config"; +import { MAPBOX_TOKEN, TIANDITU_TOKEN } from "@config/config"; +import type { Map as OlMap } from "ol"; + const INITIAL_LAYER = "mapbox-light"; -const streetsLayer = new TileLayer({ - source: new XYZ({ - url: `https://api.mapbox.com/styles/v1/mapbox/streets-v12/tiles/256/{z}/{x}/{y}@2x?access_token=${MAPBOX_TOKEN}`, - tileSize: 512, - maxZoom: 20, - projection: "EPSG:3857", - attributions: - '数据来源:Mapbox & OpenStreetMap', - }), -}); -const lightMapLayer = new TileLayer({ - source: new XYZ({ - url: `https://api.mapbox.com/styles/v1/mapbox/light-v11/tiles/256/{z}/{x}/{y}@2x?access_token=${MAPBOX_TOKEN}`, - tileSize: 512, - maxZoom: 20, - projection: "EPSG:3857", - attributions: - '数据来源:Mapbox & OpenStreetMap', - }), -}); -const satelliteLayer = new TileLayer({ - source: new XYZ({ - url: `https://api.mapbox.com/styles/v1/mapbox/satellite-v9/tiles/256/{z}/{x}/{y}@2x?access_token=${MAPBOX_TOKEN}`, - tileSize: 512, - maxZoom: 20, - projection: "EPSG:3857", - attributions: - '数据来源:Mapbox & OpenStreetMap', - }), -}); -const satelliteStreetsLayer = new TileLayer({ - source: new XYZ({ - url: `https://api.mapbox.com/styles/v1/mapbox/satellite-streets-v12/tiles/256/{z}/{x}/{y}@2x?access_token=${MAPBOX_TOKEN}`, - tileSize: 512, - maxZoom: 20, - projection: "EPSG:3857", - attributions: - '数据来源:Mapbox & OpenStreetMap', - }), -}); +const createTileLayer = (url: string, attributions: string) => + new TileLayer({ + source: new XYZ({ + url, + tileSize: 512, + maxZoom: 20, + projection: "EPSG:3857", + attributions, + }), + }); -const tiandituVectorLayer = new TileLayer({ - source: new XYZ({ - url: `https://t0.tianditu.gov.cn/vec_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=vec&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=${TIANDITU_TOKEN}`, - projection: "EPSG:3857", - attributions: '数据来源:天地图', - }), -}); -const tiandituVectorAnnotationLayer = new TileLayer({ - source: new XYZ({ - url: `https://t0.tianditu.gov.cn/cva_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=cva&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=${TIANDITU_TOKEN}`, - projection: "EPSG:3857", - attributions: '数据来源:天地图', - }), -}); -const tiandituImageLayer = new TileLayer({ - source: new XYZ({ - url: `https://t0.tianditu.gov.cn/img_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=img&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=${TIANDITU_TOKEN}`, - projection: "EPSG:3857", - attributions: '数据来源:天地图', - }), -}); -const tiandituImageAnnotationLayer = new TileLayer({ - source: new XYZ({ - url: `https://t0.tianditu.gov.cn/cia_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=cia&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=${TIANDITU_TOKEN}`, - projection: "EPSG:3857", - attributions: '数据来源:天地图', - }), -}); -const tiandituVectorLayerGroup = new Group({ - layers: [tiandituVectorLayer, tiandituVectorAnnotationLayer], -}); -const tiandituImageLayerGroup = new Group({ - layers: [tiandituImageLayer, tiandituImageAnnotationLayer], -}); -const baseLayers = [ - { - id: "mapbox-light", - name: "默认地图", - layer: lightMapLayer, - // layer: tiandituVectorLayerGroup, - img: mapboxLight.src, - }, - { - id: "mapbox-satellite", - name: "卫星地图", - layer: satelliteLayer, - // layer: tiandituImageLayerGroup, - img: mapboxSatellite.src, - }, - { - id: "mapbox-satellite-streets", - name: "卫星街道地图", - layer: satelliteStreetsLayer, - img: mapboxSatelliteStreet.src, - }, - { - id: "mapbox-streets", - name: "街道地图", - layer: streetsLayer, - img: mapboxStreets.src, - }, -]; +const createBaseLayerEntries = () => { + const streetsLayer = createTileLayer( + `https://api.mapbox.com/styles/v1/mapbox/streets-v12/tiles/256/{z}/{x}/{y}@2x?access_token=${MAPBOX_TOKEN}`, + '数据来源:Mapbox & OpenStreetMap' + ); + const lightMapLayer = createTileLayer( + `https://api.mapbox.com/styles/v1/mapbox/light-v11/tiles/256/{z}/{x}/{y}@2x?access_token=${MAPBOX_TOKEN}`, + '数据来源:Mapbox & OpenStreetMap' + ); + const satelliteLayer = createTileLayer( + `https://api.mapbox.com/styles/v1/mapbox/satellite-v9/tiles/256/{z}/{x}/{y}@2x?access_token=${MAPBOX_TOKEN}`, + '数据来源:Mapbox & OpenStreetMap' + ); + const satelliteStreetsLayer = createTileLayer( + `https://api.mapbox.com/styles/v1/mapbox/satellite-streets-v12/tiles/256/{z}/{x}/{y}@2x?access_token=${MAPBOX_TOKEN}`, + '数据来源:Mapbox & OpenStreetMap' + ); + + const tiandituVectorLayer = new TileLayer({ + source: new XYZ({ + url: `https://t0.tianditu.gov.cn/vec_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=vec&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=${TIANDITU_TOKEN}`, + projection: "EPSG:3857", + attributions: '数据来源:天地图', + }), + }); + const tiandituVectorAnnotationLayer = new TileLayer({ + source: new XYZ({ + url: `https://t0.tianditu.gov.cn/cva_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=cva&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=${TIANDITU_TOKEN}`, + projection: "EPSG:3857", + attributions: '数据来源:天地图', + }), + }); + const tiandituImageLayer = new TileLayer({ + source: new XYZ({ + url: `https://t0.tianditu.gov.cn/img_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=img&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=${TIANDITU_TOKEN}`, + projection: "EPSG:3857", + attributions: '数据来源:天地图', + }), + }); + const tiandituImageAnnotationLayer = new TileLayer({ + source: new XYZ({ + url: `https://t0.tianditu.gov.cn/cia_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=cia&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=${TIANDITU_TOKEN}`, + projection: "EPSG:3857", + attributions: '数据来源:天地图', + }), + }); + + return [ + { + id: "mapbox-light", + name: "默认地图", + layer: lightMapLayer, + img: mapboxLight.src, + }, + { + id: "mapbox-satellite", + name: "卫星地图", + layer: satelliteLayer, + img: mapboxSatellite.src, + }, + { + id: "mapbox-satellite-streets", + name: "卫星街道地图", + layer: satelliteStreetsLayer, + img: mapboxSatelliteStreet.src, + }, + { + id: "mapbox-streets", + name: "街道地图", + layer: streetsLayer, + img: mapboxStreets.src, + }, + { + id: "tianditu-vector", + name: "天地图矢量", + layer: new Group({ + layers: [tiandituVectorLayer, tiandituVectorAnnotationLayer], + }), + img: mapboxOutdoors.src, + }, + { + id: "tianditu-image", + name: "天地图影像", + layer: new Group({ + layers: [tiandituImageLayer, tiandituImageAnnotationLayer], + }), + img: mapboxSatellite.src, + }, + ]; +}; const BaseLayers: React.FC = () => { const map = useMap(); - // 切换底图选项展开,控制显示和卸载 + const data = useData(); + const maps = useMemo(() => { + if (data?.maps?.length) return data.maps; + return map ? [map] : []; + }, [data?.maps, map]); + const layerSetsRef = useRef(new WeakMap>()); const [isShow, setShow] = useState(false); const [isExpanded, setExpanded] = useState(false); - // 快速切换底图 const [activeId, setActiveId] = useState(INITIAL_LAYER); - // 初始化默认底图 useEffect(() => { - if (!map) return; - // 添加所有底图至地图并根据 activeId 控制可见性 - baseLayers.forEach((layerInfo) => { - const layers = map.getLayers().getArray(); - if (!layers.includes(layerInfo.layer)) { - map.getLayers().insertAt(0, layerInfo.layer); + maps.forEach((targetMap) => { + let layerEntries = layerSetsRef.current.get(targetMap); + if (!layerEntries) { + layerEntries = createBaseLayerEntries(); + layerSetsRef.current.set(targetMap, layerEntries); } - layerInfo.layer.setVisible(layerInfo.id === activeId); + + layerEntries.forEach((layerInfo) => { + const layers = targetMap.getLayers().getArray(); + if (!layers.includes(layerInfo.layer)) { + targetMap.getLayers().insertAt(0, layerInfo.layer); + } + layerInfo.layer.setVisible(layerInfo.id === activeId); + }); }); - }, [map, activeId]); + }, [activeId, maps]); const changeMapLayers = (id: string) => { - if (map) { - // 根据 id 设置每个图层的可见性 - baseLayers.forEach(({ id: lid, layer }) => { - layer.setVisible(lid === id); + maps.forEach((targetMap) => { + const layerEntries = layerSetsRef.current.get(targetMap); + layerEntries?.forEach(({ id: layerId, layer }) => { + layer.setVisible(layerId === id); }); - } + }); }; + const baseLayers = useMemo(() => createBaseLayerEntries().map(({ id, name, img }) => ({ + id, + name, + img, + })), []); + const handleQuickSwitch = () => { const nextId = activeId === baseLayers[0].id ? baseLayers[1].id : baseLayers[0].id; setActiveId(nextId); - handleMapLayers(nextId); + changeMapLayers(nextId); }; const handleMapLayers = (id: string) => { @@ -160,7 +176,6 @@ const BaseLayers: React.FC = () => { changeMapLayers(id); }; - // 记录定时器,避免多次触发 const hideTimer = React.useRef(null); const handleEnter = () => { @@ -217,7 +232,7 @@ const BaseLayers: React.FC = () => { {isExpanded && (
{ {baseLayers.map((item) => (
{showPropertyPanel && } {showDrawPanel && map && } diff --git a/src/components/olmap/core/MapComponent.tsx b/src/components/olmap/core/MapComponent.tsx index 2fa0abb..546ac49 100644 --- a/src/components/olmap/core/MapComponent.tsx +++ b/src/components/olmap/core/MapComponent.tsx @@ -7,6 +7,7 @@ import React, { useState, useEffect, useMemo, + useCallback, useRef, } from "react"; import { Map as OlMap, VectorTile } from "ol"; @@ -49,6 +50,13 @@ interface DataContextType { setCurrentJunctionCalData?: React.Dispatch>; currentPipeCalData?: any[]; // 当前计算结果 setCurrentPipeCalData?: React.Dispatch>; + compareJunctionCalData?: any[]; + setCompareJunctionCalData?: React.Dispatch>; + comparePipeCalData?: any[]; + setComparePipeCalData?: React.Dispatch>; + isCompareMode?: boolean; + setCompareMode?: React.Dispatch>; + toggleCompareMode?: () => void; showJunctionText?: boolean; // 是否显示节点文本 showPipeText?: boolean; // 是否显示管道文本 showJunctionId?: boolean; // 是否显示节点ID @@ -69,6 +77,10 @@ interface DataContextType { setPipeText?: React.Dispatch>; setContours?: React.Dispatch>; deckLayer?: DeckLayer; + compareDeckLayer?: DeckLayer; + deckLayers?: DeckLayer[]; + compareMap?: OlMap; + maps?: OlMap[]; diameterRange?: [number, number]; elevationRange?: [number, number]; } @@ -128,12 +140,18 @@ const MapComponent: React.FC = ({ children }) => { const mapRef = useRef(null); const canvasRef = useRef(null); + const compareMapRef = useRef(null); + const compareCanvasRef = useRef(null); const deckLayerRef = useRef(null); + const compareDeckLayerRef = useRef(null); const isDisposingRef = useRef(false); + const isCompareDisposingRef = useRef(false); const pendingTimeoutsRef = useRef([]); const [map, setMap] = useState(); const [deckLayer, setDeckLayer] = useState(); + const [compareMap, setCompareMap] = useState(); + const [compareDeckLayer, setCompareDeckLayer] = useState(); // currentCalData 用于存储当前计算结果 const [currentTime, setCurrentTime] = useState(-1); // 默认选择当前时间 // const [selectedDate, setSelectedDate] = useState(new Date("2025-9-17")); @@ -144,6 +162,11 @@ const MapComponent: React.FC = ({ children }) => { [], ); const [currentPipeCalData, setCurrentPipeCalData] = useState([]); + const [compareJunctionCalData, setCompareJunctionCalData] = useState( + [], + ); + const [comparePipeCalData, setComparePipeCalData] = useState([]); + const [isCompareMode, setCompareMode] = useState(false); // junctionData 和 pipeData 分别缓存瓦片解析后节点和管道的数据,用于 deck.gl 定位、标签渲染 // currentJunctionCalData 和 currentPipeCalData 变化时会新增并更新 junctionData 和 pipeData 的计算属性值 const [junctionData, setJunctionDataState] = useState([]); @@ -201,6 +224,37 @@ const MapComponent: React.FC = ({ children }) => { }); }, [pipeData, currentPipeCalData, pipeText]); + const mergedCompareJunctionData = useMemo(() => { + const nodeMap = new Map(compareJunctionCalData.map((r: any) => [r.ID, r])); + return junctionData.map((j) => { + const record = nodeMap.get(j.id); + let val = record ? record.value : undefined; + if (val !== undefined && junctionText === "actualdemand") { + val = toM3h(val, "lps"); + } + return record ? { ...j, [junctionText]: val } : j; + }); + }, [junctionData, compareJunctionCalData, junctionText]); + + const mergedComparePipeData = useMemo(() => { + const linkMap = new Map(comparePipeCalData.map((r: any) => [r.ID, r])); + return pipeData.map((p) => { + const record = linkMap.get(p.id); + if (!record) return p; + const isFlow = pipeText === "flow"; + let val = record.value; + if (val !== undefined && isFlow) { + val = toM3h(val, "lps"); + } + return { + ...p, + [pipeText]: isFlow ? Math.abs(val) : val, + flowFlag: isFlow && record.value < 0 ? -1 : 1, + path: isFlow && record.value < 0 ? [...p.path].reverse() : p.path, + }; + }); + }, [pipeData, comparePipeCalData, pipeText]); + const [diameterRange, setDiameterRange] = useState< [number, number] | undefined >(); @@ -208,6 +262,24 @@ const MapComponent: React.FC = ({ children }) => { [number, number] | undefined >(); + const toggleCompareMode = useCallback(() => { + setCompareMode((prev) => !prev); + }, []); + + const maps = useMemo( + () => + [map, isCompareMode ? compareMap : undefined].filter(Boolean) as OlMap[], + [compareMap, isCompareMode, map], + ); + + const deckLayers = useMemo( + () => + [deckLayer, isCompareMode ? compareDeckLayer : undefined].filter( + Boolean, + ) as DeckLayer[], + [compareDeckLayer, deckLayer, isCompareMode], + ); + const setJunctionData = (newData: any[]) => { const uniqueNewData = newData.filter((item) => { if (!item || !item.id) return false; @@ -518,6 +590,178 @@ const MapComponent: React.FC = ({ children }) => { }, }); + const createOperationalLayers = () => { + const nextJunctionSource = new VectorTileSource({ + url: `${MAP_URL}/gwc/service/tms/1.0.0/${MAP_WORKSPACE}:geo_junctions@WebMercatorQuad@pbf/{z}/{x}/{-y}.pbf`, + format: new MVT(), + projection: "EPSG:3857", + }); + const nextPipeSource = new VectorTileSource({ + url: `${MAP_URL}/gwc/service/tms/1.0.0/${MAP_WORKSPACE}:geo_pipes@WebMercatorQuad@pbf/{z}/{x}/{-y}.pbf`, + format: new MVT(), + projection: "EPSG:3857", + }); + const nextJunctionsLayer = new WebGLVectorTileLayer({ + source: nextJunctionSource as any, + style: defaultFlatStyle, + extent: MAP_EXTENT, + maxZoom: 24, + minZoom: 11, + properties: { + name: "节点", + value: "junctions", + type: "point", + properties: [ + { name: "高程", value: "elevation" }, + { name: "实际需水量", value: "actual_demand" }, + { name: "水头", value: "total_head" }, + { name: "压力", value: "pressure" }, + { name: "水质", value: "quality" }, + ], + }, + }); + const nextPipesLayer = new WebGLVectorTileLayer({ + source: nextPipeSource as any, + style: defaultFlatStyle, + extent: MAP_EXTENT, + maxZoom: 24, + minZoom: 11, + properties: { + name: "管道", + value: "pipes", + type: "linestring", + properties: [ + { name: "管径", value: "diameter" }, + { name: "流量", value: "flow" }, + { name: "摩阻系数", value: "friction" }, + { name: "水头损失", value: "headloss" }, + { name: "单位水头损失", value: "unit_headloss" }, + { name: "水质", value: "quality" }, + { name: "反应速率", value: "reaction" }, + { name: "设置值", value: "setting" }, + { name: "状态", value: "status" }, + { name: "流速", value: "velocity" }, + ], + }, + }); + const nextValvesLayer = new WebGLVectorTileLayer({ + source: valveSource as any, + style: valveStyle, + extent: MAP_EXTENT, + maxZoom: 24, + minZoom: 16, + properties: { + name: "阀门", + value: "valves", + type: "linestring", + properties: [], + }, + }); + const nextReservoirsLayer = new VectorLayer({ + source: reservoirSource, + style: reservoirStyle, + extent: MAP_EXTENT, + maxZoom: 24, + minZoom: 11, + properties: { + name: "水库", + value: "reservoirs", + type: "point", + properties: [], + }, + }); + const nextPumpsLayer = new VectorLayer({ + source: pumpSource, + style: pumpStyle, + extent: MAP_EXTENT, + maxZoom: 24, + minZoom: 11, + properties: { + name: "水泵", + value: "pumps", + type: "linestring", + properties: [], + }, + }); + const nextTanksLayer = new VectorLayer({ + source: tankSource, + style: tankStyle, + extent: MAP_EXTENT, + maxZoom: 24, + minZoom: 11, + properties: { + name: "水箱", + value: "tanks", + type: "point", + properties: [], + }, + }); + const nextScadaLayer = new VectorLayer({ + source: scadaSource, + style: scadaStyle, + extent: MAP_EXTENT, + maxZoom: 24, + minZoom: 11, + properties: { + name: "SCADA", + value: "scada", + type: "point", + properties: [], + }, + }); + + const availableLayers: any[] = []; + config.MAP_AVAILABLE_LAYERS.forEach((layerValue) => { + switch (layerValue) { + case "junctions": + availableLayers.push(nextJunctionsLayer); + break; + case "pipes": + availableLayers.push(nextPipesLayer); + break; + case "valves": + availableLayers.push(nextValvesLayer); + break; + case "reservoirs": + availableLayers.push(nextReservoirsLayer); + break; + case "pumps": + availableLayers.push(nextPumpsLayer); + break; + case "tanks": + availableLayers.push(nextTanksLayer); + break; + case "scada": + availableLayers.push(nextScadaLayer); + break; + } + }); + availableLayers.sort((a, b) => { + const order = [ + "valves", + "junctions", + "scada", + "reservoirs", + "pumps", + "tanks", + "pipes", + ].reverse(); + const getValue = (layer: any) => { + const props = layer.get ? layer.get("properties") : undefined; + return (props && props.value) || layer.get?.("value") || ""; + }; + const aVal = getValue(a); + const bVal = getValue(b); + let ia = order.indexOf(aVal); + let ib = order.indexOf(bVal); + if (ia === -1) ia = order.length; + if (ib === -1) ib = order.length; + return ia - ib; + }); + + return availableLayers; + }; + // The map and layer instances are intentionally rebuilt only when workspace or extent changes. useEffect(() => { if (!mapRef.current) return; @@ -857,148 +1101,284 @@ const MapComponent: React.FC = ({ children }) => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [MAP_WORKSPACE, MAP_EXTENT]); + useEffect(() => { + if (!isCompareMode) { + isCompareDisposingRef.current = true; + setCompareJunctionCalData([]); + setComparePipeCalData([]); + return; + } + if (!map || !compareMapRef.current || !compareCanvasRef.current) return; + + isCompareDisposingRef.current = false; + const availableLayers = createOperationalLayers(); + const nextCompareMap = new OlMap({ + target: compareMapRef.current, + view: map.getView(), + layers: availableLayers.slice(), + controls: [], + }); + nextCompareMap.getAllLayers().forEach((layer) => { + const layerId = layer.get("value"); + if (!layerId) return; + const primaryLayer = map + .getAllLayers() + .find((currentLayer) => currentLayer.get("value") === layerId); + if (primaryLayer) { + layer.setVisible(primaryLayer.getVisible()); + } + }); + setCompareMap(nextCompareMap); + + const compareDeck = new Deck({ + initialViewState: { + longitude: 0, + latitude: 0, + zoom: 1, + }, + canvas: compareCanvasRef.current, + controller: false, + layers: [], + }); + const nextCompareDeckLayer = new DeckLayer( + compareDeck, + compareCanvasRef.current, + { + name: "compareDeckLayer", + value: "deckLayer", + }, + ); + compareDeckLayerRef.current = nextCompareDeckLayer; + setCompareDeckLayer(nextCompareDeckLayer); + nextCompareMap.addLayer(nextCompareDeckLayer); + + const resizeTimerId = window.setTimeout(() => { + map.updateSize(); + nextCompareMap.updateSize(); + }, 0); + + return () => { + isCompareDisposingRef.current = true; + window.clearTimeout(resizeTimerId); + if ( + compareDeckLayerRef.current && + !compareDeckLayerRef.current.isDisposedLayer() + ) { + try { + nextCompareMap.removeLayer(compareDeckLayerRef.current); + } catch { + // Layer may have already been removed during teardown. + } + compareDeckLayerRef.current.disposeDeck(); + } + compareDeckLayerRef.current = null; + setCompareDeckLayer(undefined); + setCompareMap(undefined); + nextCompareMap.setTarget(undefined); + nextCompareMap.dispose(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isCompareMode, map]); + + useEffect(() => { + const resizeTimerId = window.setTimeout(() => { + map?.updateSize(); + compareMap?.updateSize(); + }, 0); + + return () => { + window.clearTimeout(resizeTimerId); + }; + }, [compareMap, isCompareMode, map]); + // 当数据变化时,更新 deck.gl 图层 useEffect(() => { - if (isDisposingRef.current) return; - const deckLayer = deckLayerRef.current; - if (!deckLayer) return; // 如果 deck 实例还未创建,则退出 - if (deckLayer.isDisposedLayer()) return; - if (!mergedJunctionData.length) return; - if (!mergedPipeData.length) return; - const junctionTextLayer = new TextLayer({ - id: "junctionTextLayer", - name: "节点文字", - zIndex: 10, - data: mergedJunctionData, - getPosition: (d: any) => d.position, - fontFamily: "Monaco, monospace", - getText: (d: any) => { - let idPart = showJunctionId ? d.id : ""; - let propPart = ""; - if (showJunctionTextLayer && d[junctionText] !== undefined) { - const value = (d[junctionText] as number).toFixed(3); - propPart = `${value}`; - } - if (idPart && propPart) return `${idPart} - ${propPart}`; - return idPart || propPart; - }, - getSize: 14, - fontWeight: "bold", - getColor: [33, 37, 41], // 深灰色,在灰白背景上清晰可见 - getAngle: 0, - getTextAnchor: "middle", - getAlignmentBaseline: "center", - getPixelOffset: [0, -10], - visible: + const syncDeckOverlay = ( + targetDeckLayer: DeckLayer | null, + targetJunctionData: any[], + targetPipeData: any[], + disposing: boolean, + ) => { + if (disposing || !targetDeckLayer || targetDeckLayer.isDisposedLayer()) { + return; + } + const shouldShowJunctionText = (showJunctionTextLayer || showJunctionId) && currentZoom >= 15 && - currentZoom <= 24, - updateTriggers: { - getText: [showJunctionId, showJunctionTextLayer, junctionText], - }, - extensions: [new CollisionFilterExtension()], - collisionTestProps: { - sizeScale: 3, - }, - characterSet: "auto", - fontSettings: { - sdf: true, - fontSize: 64, - buffer: 6, - }, - // outlineWidth: 3, - // outlineColor: [255, 255, 255, 220], - }); - - const pipeTextLayer = new TextLayer({ - id: "pipeTextLayer", - name: "管道文字", - zIndex: 10, - data: mergedPipeData, - getPosition: (d: any) => d.position, - fontFamily: "Monaco, monospace", - getText: (d: any) => { - let idPart = showPipeId ? d.id : ""; - let propPart = ""; - if (showPipeTextLayer && d[pipeText] !== undefined) { - let value; - if (pipeText === "unit_headloss") { - value = ( - (d["unit_headloss"] / (d["length"] / 1000)) as number - ).toFixed(3); - } else { - value = Math.abs(d[pipeText] as number).toFixed(3); - } - propPart = `${value}`; - } - if (idPart && propPart) return `${idPart} - ${propPart}`; - return idPart || propPart; - }, - getSize: 14, - fontWeight: "bold", - getColor: [33, 37, 41], // 深灰色 - getAngle: (d: any) => d.angle || 0, - getPixelOffset: [0, -8], - getTextAnchor: "middle", - getAlignmentBaseline: "bottom", - visible: + currentZoom <= 24 && + targetJunctionData.length > 0; + const shouldShowPipeText = (showPipeTextLayer || showPipeId) && currentZoom >= 15 && - currentZoom <= 24, - updateTriggers: { - getText: [showPipeId, showPipeTextLayer, pipeText], - }, - extensions: [new CollisionFilterExtension()], - collisionTestProps: { - sizeScale: 3, - }, - characterSet: "auto", - fontSettings: { - sdf: true, - fontSize: 64, - buffer: 6, - }, - // outlineWidth: 3, - // outlineColor: [255, 255, 255, 220], - }); + currentZoom <= 24 && + targetPipeData.length > 0; + const shouldShowContour = + showContourLayer && + currentZoom >= 11 && + currentZoom <= 24 && + targetJunctionData.length > 0; - const contourLayer = new ContourLayer({ - id: "junctionContourLayer", - name: "等值线", - data: mergedJunctionData, - aggregation: "MEAN", - cellSize: 600, - strokeWidth: 0, - contours: contours, - getPosition: (d) => d.position, - getWeight: (d: any) => - (d[junctionText] as number) < 0 ? 0 : (d[junctionText] as number), - opacity: 1, - visible: showContourLayer && currentZoom >= 11 && currentZoom <= 24, - updateTriggers: { - // 当 mergedJunctionData 内部数据更新时,通知 getWeight 重新计算 - getWeight: [mergedJunctionData, junctionText], - }, - }); - if (deckLayer.getDeckLayerById("junctionTextLayer")) { - // 传入完整 layer 实例以保证 clone/替换时保留 layer 类型和方法 - deckLayer.updateDeckLayer("junctionTextLayer", junctionTextLayer); - } else { - deckLayer.addDeckLayer(junctionTextLayer); - } - if (deckLayer.getDeckLayerById("pipeTextLayer")) { - deckLayer.updateDeckLayer("pipeTextLayer", pipeTextLayer); - } else { - deckLayer.addDeckLayer(pipeTextLayer); - } - if (deckLayer.getDeckLayerById("junctionContourLayer")) { - deckLayer.updateDeckLayer("junctionContourLayer", contourLayer); - } else { - deckLayer.addDeckLayer(contourLayer); + if (!shouldShowJunctionText) { + targetDeckLayer.removeDeckLayer("junctionTextLayer"); + } + if (!shouldShowPipeText) { + targetDeckLayer.removeDeckLayer("pipeTextLayer"); + } + if (!shouldShowContour) { + targetDeckLayer.removeDeckLayer("junctionContourLayer"); + } + if (!shouldShowJunctionText && !shouldShowPipeText && !shouldShowContour) { + return; + } + + const junctionTextLayer = shouldShowJunctionText + ? new TextLayer({ + id: "junctionTextLayer", + name: "节点文字", + zIndex: 10, + data: targetJunctionData, + getPosition: (d: any) => d.position, + fontFamily: "Monaco, monospace", + getText: (d: any) => { + let idPart = showJunctionId ? d.id : ""; + let propPart = ""; + if (showJunctionTextLayer && d[junctionText] !== undefined) { + const value = (d[junctionText] as number).toFixed(3); + propPart = `${value}`; + } + if (idPart && propPart) return `${idPart} - ${propPart}`; + return idPart || propPart; + }, + getSize: 14, + fontWeight: "bold", + getColor: [33, 37, 41], + getAngle: 0, + getTextAnchor: "middle", + getAlignmentBaseline: "center", + getPixelOffset: [0, -10], + visible: true, + updateTriggers: { + getText: [showJunctionId, showJunctionTextLayer, junctionText], + }, + extensions: [new CollisionFilterExtension()], + collisionTestProps: { + sizeScale: 3, + }, + characterSet: "auto", + fontSettings: { + sdf: true, + fontSize: 64, + buffer: 6, + }, + }) + : null; + + const pipeTextLayer = shouldShowPipeText + ? new TextLayer({ + id: "pipeTextLayer", + name: "管道文字", + zIndex: 10, + data: targetPipeData, + getPosition: (d: any) => d.position, + fontFamily: "Monaco, monospace", + getText: (d: any) => { + let idPart = showPipeId ? d.id : ""; + let propPart = ""; + if (showPipeTextLayer && d[pipeText] !== undefined) { + let value; + if (pipeText === "unit_headloss") { + value = ( + (d["unit_headloss"] / (d["length"] / 1000)) as number + ).toFixed(3); + } else { + value = Math.abs(d[pipeText] as number).toFixed(3); + } + propPart = `${value}`; + } + if (idPart && propPart) return `${idPart} - ${propPart}`; + return idPart || propPart; + }, + getSize: 14, + fontWeight: "bold", + getColor: [33, 37, 41], + getAngle: (d: any) => d.angle || 0, + getPixelOffset: [0, -8], + getTextAnchor: "middle", + getAlignmentBaseline: "bottom", + visible: true, + updateTriggers: { + getText: [showPipeId, showPipeTextLayer, pipeText], + }, + extensions: [new CollisionFilterExtension()], + collisionTestProps: { + sizeScale: 3, + }, + characterSet: "auto", + fontSettings: { + sdf: true, + fontSize: 64, + buffer: 6, + }, + }) + : null; + + const contourLayer = shouldShowContour + ? new ContourLayer({ + id: "junctionContourLayer", + name: "等值线", + data: targetJunctionData, + aggregation: "MEAN", + cellSize: 600, + strokeWidth: 0, + contours: contours, + getPosition: (d) => d.position, + getWeight: (d: any) => + (d[junctionText] as number) < 0 ? 0 : (d[junctionText] as number), + opacity: 1, + visible: true, + updateTriggers: { + getWeight: [targetJunctionData, junctionText], + }, + }) + : null; + + if (junctionTextLayer && targetDeckLayer.getDeckLayerById("junctionTextLayer")) { + targetDeckLayer.updateDeckLayer("junctionTextLayer", junctionTextLayer); + } else if (junctionTextLayer) { + targetDeckLayer.addDeckLayer(junctionTextLayer); + } + if (pipeTextLayer && targetDeckLayer.getDeckLayerById("pipeTextLayer")) { + targetDeckLayer.updateDeckLayer("pipeTextLayer", pipeTextLayer); + } else if (pipeTextLayer) { + targetDeckLayer.addDeckLayer(pipeTextLayer); + } + if (contourLayer && targetDeckLayer.getDeckLayerById("junctionContourLayer")) { + targetDeckLayer.updateDeckLayer("junctionContourLayer", contourLayer); + } else if (contourLayer) { + targetDeckLayer.addDeckLayer(contourLayer); + } + }; + + syncDeckOverlay( + deckLayerRef.current, + mergedJunctionData, + mergedPipeData, + isDisposingRef.current, + ); + if (isCompareMode) { + syncDeckOverlay( + compareDeckLayerRef.current, + mergedCompareJunctionData, + mergedComparePipeData, + isCompareDisposingRef.current, + ); } }, [ mergedJunctionData, mergedPipeData, + mergedCompareJunctionData, + mergedComparePipeData, + isCompareMode, junctionText, pipeText, currentZoom, @@ -1012,57 +1392,69 @@ const MapComponent: React.FC = ({ children }) => { // 控制流动动画开关 useEffect(() => { - if (isDisposingRef.current) return; - if (pipeText === "flow" && currentPipeCalData.length > 0) { - flowAnimation.current = true; - } else { - flowAnimation.current = false; - } - const deckLayer = deckLayerRef.current; - if (!deckLayer) return; // 如果 deck 实例还未创建,则退出 + flowAnimation.current = pipeText === "flow" && currentPipeCalData.length > 0; + const shouldShowWaterflow = + isWaterflowLayerAvailable && + showWaterflowLayer && + flowAnimation.current && + currentZoom >= 12 && + currentZoom <= 24; - let animationFrameId: number; // 保存 requestAnimationFrame 的 ID + let animationFrameId: number; - // 动画循环 - const animate = () => { - if (isDisposingRef.current || deckLayer.isDisposedLayer()) return; - // 动画总时长(秒) + const syncWaterflowLayer = ( + targetDeckLayer: DeckLayer | null, + targetPipeData: any[], + disposing: boolean, + ) => { + if (disposing || !targetDeckLayer || targetDeckLayer.isDisposedLayer()) { + return; + } + if (!shouldShowWaterflow || targetPipeData.length === 0) { + targetDeckLayer.removeDeckLayer("waterflowLayer"); + return; + } const animationDuration = 10; const bufferTime = 2; const loopLength = animationDuration + bufferTime; - const currentTime = (Date.now() / 1000) % loopLength; + const currentFrameTime = (Date.now() / 1000) % loopLength; const waterflowLayer = new TripsLayer({ id: "waterflowLayer", name: "水流", - data: mergedPipeData, + data: targetPipeData, getPath: (d) => d.path, - getTimestamps: (d) => { - return d.timestamps; // 这些应该是与 currentTime 匹配的数值 - }, + getTimestamps: (d) => d.timestamps, getColor: [0, 220, 255], opacity: 0.8, - visible: - isWaterflowLayerAvailable && - showWaterflowLayer && - flowAnimation.current && // 保持动画标志作为可见性的一部分 - currentZoom >= 12 && - currentZoom <= 24, + visible: true, widthMinPixels: 5, - jointRounded: true, // 拐角变圆 - // capRounded: true, // 端点变圆 - trailLength: 2, // 水流尾迹淡出时间 - currentTime: currentTime, + jointRounded: true, + trailLength: 2, + currentTime: currentFrameTime, }); - if (deckLayer.getDeckLayerById("waterflowLayer")) { - deckLayer.updateDeckLayer("waterflowLayer", waterflowLayer); + if (targetDeckLayer.getDeckLayerById("waterflowLayer")) { + targetDeckLayer.updateDeckLayer("waterflowLayer", waterflowLayer); } else { - deckLayer.addDeckLayer(waterflowLayer); + targetDeckLayer.addDeckLayer(waterflowLayer); } + }; - // 只有在需要动画时才请求下一帧,但图层已经添加到了 deckLayer 中 - if (flowAnimation.current) { + const animate = () => { + syncWaterflowLayer( + deckLayerRef.current, + mergedPipeData, + isDisposingRef.current, + ); + if (isCompareMode) { + syncWaterflowLayer( + compareDeckLayerRef.current, + mergedComparePipeData, + isCompareDisposingRef.current, + ); + } + if (shouldShowWaterflow) { animationFrameId = requestAnimationFrame(animate); } }; @@ -1078,6 +1470,8 @@ const MapComponent: React.FC = ({ children }) => { currentPipeCalData, currentZoom, mergedPipeData, + mergedComparePipeData, + isCompareMode, pipeText, isWaterflowLayerAvailable, showWaterflowLayer, @@ -1097,6 +1491,13 @@ const MapComponent: React.FC = ({ children }) => { setCurrentJunctionCalData, currentPipeCalData, setCurrentPipeCalData, + compareJunctionCalData, + setCompareJunctionCalData, + comparePipeCalData, + setComparePipeCalData, + isCompareMode, + setCompareMode, + toggleCompareMode, setShowJunctionTextLayer, setShowPipeTextLayer, setShowJunctionId, @@ -1115,17 +1516,50 @@ const MapComponent: React.FC = ({ children }) => { pipeText, setContours, deckLayer, + compareDeckLayer, + deckLayers, + compareMap, + maps, diameterRange, elevationRange, }} >
-
+
+
+
+ + {isCompareMode && ( +
+ 方案模拟 +
+ )} +
+ {isCompareMode && ( +
+
+ +
+ 实时模拟 +
+
+ )} +
+ {isCompareMode && ( +
+ )} {children}
-