添加对比模式功能,优化地图组件

This commit is contained in:
2026-04-27 15:59:49 +08:00
parent 60181dba54
commit 07861bee03
8 changed files with 1123 additions and 439 deletions
@@ -8,7 +8,11 @@ export default function Home() {
return (
<div className="relative w-full h-full overflow-hidden">
<MapComponent>
<MapToolbar queryType="scheme" schemeType="burst_analysis" />
<MapToolbar
queryType="scheme"
schemeType="burst_analysis"
enableCompare
/>
<BurstPipeAnalysisPanel />
</MapComponent>
</div>
@@ -8,7 +8,11 @@ export default function Home() {
return (
<div className="relative w-full h-full overflow-hidden">
<MapComponent>
<MapToolbar queryType="scheme" schemeType="contaminant_analysis" />
<MapToolbar
queryType="scheme"
schemeType="contaminant_analysis"
enableCompare
/>
<WaterQualityPanel />
</MapComponent>
</div>
+142 -127
View File
@@ -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:
'数据来源:<a href="https://www.mapbox.com/">Mapbox</a> & <a href="https://www.openstreetmap.org/">OpenStreetMap</a>',
}),
});
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:
'数据来源:<a href="https://www.mapbox.com/">Mapbox</a> & <a href="https://www.openstreetmap.org/">OpenStreetMap</a>',
}),
});
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:
'数据来源:<a href="https://www.mapbox.com/">Mapbox</a> & <a href="https://www.openstreetmap.org/">OpenStreetMap</a>',
}),
});
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:
'数据来源:<a href="https://www.mapbox.com/">Mapbox</a> & <a href="https://www.openstreetmap.org/">OpenStreetMap</a>',
}),
});
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: '数据来源:<a href="https://www.tianditu.gov.cn/">天地图</a>',
}),
});
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: '数据来源:<a href="https://www.tianditu.gov.cn/">天地图</a>',
}),
});
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: '数据来源:<a href="https://www.tianditu.gov.cn/">天地图</a>',
}),
});
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: '数据来源:<a href="https://www.tianditu.gov.cn/">天地图</a>',
}),
});
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}`,
'数据来源:<a href="https://www.mapbox.com/">Mapbox</a> & <a href="https://www.openstreetmap.org/">OpenStreetMap</a>'
);
const lightMapLayer = createTileLayer(
`https://api.mapbox.com/styles/v1/mapbox/light-v11/tiles/256/{z}/{x}/{y}@2x?access_token=${MAPBOX_TOKEN}`,
'数据来源:<a href="https://www.mapbox.com/">Mapbox</a> & <a href="https://www.openstreetmap.org/">OpenStreetMap</a>'
);
const satelliteLayer = createTileLayer(
`https://api.mapbox.com/styles/v1/mapbox/satellite-v9/tiles/256/{z}/{x}/{y}@2x?access_token=${MAPBOX_TOKEN}`,
'数据来源:<a href="https://www.mapbox.com/">Mapbox</a> & <a href="https://www.openstreetmap.org/">OpenStreetMap</a>'
);
const satelliteStreetsLayer = createTileLayer(
`https://api.mapbox.com/styles/v1/mapbox/satellite-streets-v12/tiles/256/{z}/{x}/{y}@2x?access_token=${MAPBOX_TOKEN}`,
'数据来源:<a href="https://www.mapbox.com/">Mapbox</a> & <a href="https://www.openstreetmap.org/">OpenStreetMap</a>'
);
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: '数据来源:<a href="https://www.tianditu.gov.cn/">天地图</a>',
}),
});
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: '数据来源:<a href="https://www.tianditu.gov.cn/">天地图</a>',
}),
});
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: '数据来源:<a href="https://www.tianditu.gov.cn/">天地图</a>',
}),
});
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: '数据来源:<a href="https://www.tianditu.gov.cn/">天地图</a>',
}),
});
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<OlMap, ReturnType<typeof createBaseLayerEntries>>());
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<NodeJS.Timeout | null>(null);
const handleEnter = () => {
@@ -217,7 +232,7 @@ const BaseLayers: React.FC = () => {
{isExpanded && (
<div
className={clsx(
"absolute flex right-24 bottom-0 w-90 h-25 bg-white rounded-xl drop-shadow-xl shadow-black transition-all duration-300",
"absolute flex right-24 bottom-0 w-132 h-25 bg-white rounded-xl drop-shadow-xl shadow-black transition-all duration-300",
isShow ? "opacity-100" : "opacity-0"
)}
onMouseEnter={handleEnter}
@@ -226,7 +241,7 @@ const BaseLayers: React.FC = () => {
{baseLayers.map((item) => (
<button
key={item.id}
className="flex flex-auto flex-col justify-center items-center text-gray-500 text-xs"
className="flex flex-auto flex-col justify-center items-center text-gray-500 text-xs"
onClick={() => handleMapLayers(item.id)}
>
<Image
@@ -5,6 +5,7 @@ import WebGLVectorTileLayer from "ol/layer/WebGLVectorTile";
import VectorLayer from "ol/layer/Vector";
import VectorTileLayer from "ol/layer/VectorTile";
import { DeckLayer } from "@utils/layers";
import type { Map as OlMap } from "ol";
// 定义统一的图层项接口
interface LayerItem {
@@ -30,8 +31,10 @@ const LAYER_ORDER = [
const LayerControl: React.FC = () => {
const map = useMap();
const data = useData();
const maps: OlMap[] = data?.maps?.length ? data.maps : map ? [map] : [];
const [refreshKey, setRefreshKey] = useState(0);
const deckLayer = data?.deckLayer;
const deckLayers = data?.deckLayers ?? (deckLayer ? [deckLayer] : []);
const isContourLayerAvailable = data?.isContourLayerAvailable;
const isWaterflowLayerAvailable = data?.isWaterflowLayerAvailable;
const setShowWaterflowLayer = data?.setShowWaterflowLayer;
@@ -117,8 +120,16 @@ const LayerControl: React.FC = () => {
const handleVisibilityChange = (item: LayerItem, checked: boolean) => {
if (item.type === "ol") {
item.layerRef.setVisible(checked);
} else if (item.type === "deck" && deckLayer) {
maps.forEach((targetMap) => {
targetMap
.getAllLayers()
.filter((layer) => layer.get("value") === item.id)
.forEach((layer) => layer.setVisible(checked));
});
} else if (item.type === "deck" && deckLayers.length > 0) {
deckLayers.forEach((targetDeckLayer) => {
targetDeckLayer.setDeckLayerVisible(item.id, checked);
});
if (item.id === "junctionContourLayer") {
setShowContourLayer && setShowContourLayer(checked);
}
@@ -29,6 +29,7 @@ import { FlatStyleLike } from "ol/style/flat";
import { calculateClassification } from "@utils/breaks_classification";
import { parseColor } from "@utils/parseColor";
import { VectorTile } from "ol";
import type { Map as OlMap } from "ol";
import { useNotification } from "@refinedev/core";
import { config } from "@/config/config";
@@ -182,6 +183,13 @@ const StyleEditorPanel: React.FC<StyleEditorPanelProps> = ({
const data = useData();
const currentJunctionCalData = data?.currentJunctionCalData;
const currentPipeCalData = data?.currentPipeCalData;
const compareJunctionCalData = data?.compareJunctionCalData;
const comparePipeCalData = data?.comparePipeCalData;
const compareMap = data?.compareMap;
const activeMaps = useMemo<OlMap[]>(
() => (data?.maps?.length ? data.maps : map ? [map] : []),
[data?.maps, map]
);
const junctionText = data?.junctionText ?? "";
const pipeText = data?.pipeText ?? "";
const setShowJunctionTextLayer = data?.setShowJunctionTextLayer;
@@ -229,6 +237,45 @@ const StyleEditorPanel: React.FC<StyleEditorPanelProps> = ({
customColors: [],
});
const getRenderLayersById = useCallback(
(layerId: string) =>
activeMaps.flatMap((targetMap) =>
targetMap
.getAllLayers()
.filter((layer) => layer.get("value") === layerId)
.filter((layer): layer is WebGLVectorTileLayer => layer instanceof WebGLVectorTileLayer)
),
[activeMaps]
);
const getMapKey = useCallback((targetMap: OlMap, layerId: string) => {
const mapUid = (targetMap as unknown as { ol_uid?: string }).ol_uid || "map";
return `${mapUid}:${layerId}`;
}, []);
const getDataForMap = useCallback(
(targetMap: OlMap, layerId: string) => {
if (layerId === "junctions") {
return targetMap === compareMap
? compareJunctionCalData || []
: currentJunctionCalData || [];
}
if (layerId === "pipes") {
return targetMap === compareMap
? comparePipeCalData || []
: currentPipeCalData || [];
}
return [];
},
[
compareJunctionCalData,
compareMap,
comparePipeCalData,
currentJunctionCalData,
currentPipeCalData,
]
);
const getDefaultCustomColors = (
segments: number,
existingColors: string[] = []
@@ -613,13 +660,10 @@ const StyleEditorPanel: React.FC<StyleEditorPanelProps> = ({
return;
}
const styleConfig = layerStyleConfig.styleConfig;
const renderLayer = renderLayers.filter((layer) => {
return layer.get("value") === layerStyleConfig.layerId;
})[0];
const targetLayers = getRenderLayersById(layerStyleConfig.layerId);
const renderLayer = targetLayers[0];
if (!renderLayer || !styleConfig?.property) return;
const layerType: string = renderLayer?.get("type");
const source = renderLayer.getSource();
if (!source) return;
const layerType: string = renderLayer.get("type");
const breaksLength = breaks.length;
// 根据 breaks 计算每个分段的颜色,线条粗细
@@ -757,7 +801,9 @@ const StyleEditorPanel: React.FC<StyleEditorPanelProps> = ({
dynamicStyle["circle-stroke-width"] = 2;
}
// 应用样式到图层
renderLayer.setStyle(dynamicStyle);
targetLayers.forEach((targetLayer) => {
targetLayer.setStyle(dynamicStyle);
});
// 用初始化时的样式配置更新图例配置,避免覆盖已有的图例名称和属性
const layerId = renderLayer.get("value");
const initLayerStyleState = layerStyleStates.find(
@@ -844,10 +890,12 @@ const StyleEditorPanel: React.FC<StyleEditorPanelProps> = ({
if (!selectedRenderLayer) return;
// 重置 WebGL 图层样式
const defaultFlatStyle: FlatStyleLike = config.MAP_DEFAULT_STYLE;
selectedRenderLayer.setStyle(defaultFlatStyle);
const layerId = selectedRenderLayer.get("value");
getRenderLayersById(layerId).forEach((targetLayer) => {
targetLayer.setStyle(defaultFlatStyle);
});
// 删除对应图层的样式状态,从而移除图例显示
const layerId = selectedRenderLayer.get("value");
if (layerId !== undefined) {
setLayerStyleStates((prev) =>
prev.filter((state) => state.layerId !== layerId)
@@ -870,11 +918,15 @@ const StyleEditorPanel: React.FC<StyleEditorPanelProps> = ({
}
};
// 更新当前 VectorTileSource 中的所有缓冲要素属性
const updateVectorTileSource = (property: string, data: any[]) => {
if (!map) return;
const vectorTileSources = map
const updateVectorTileSource = (
targetMap: OlMap,
layerId: string,
property: string,
data: any[]
) => {
const vectorTileSources = targetMap
.getAllLayers()
.filter((layer) => layer instanceof WebGLVectorTileLayer)
.filter((layer) => layer.get("value") === layerId)
.map((layer) => layer.getSource() as VectorTileSource)
.filter((source) => source);
@@ -911,16 +963,16 @@ const StyleEditorPanel: React.FC<StyleEditorPanelProps> = ({
};
// 新增事件,监听 VectorTileSource 的 tileloadend 事件,为新增瓦片数据动态更新要素属性
const tileLoadListenersRef = useRef<
Map<VectorTileSource, (event: any) => void>
Map<string, { source: VectorTileSource; listener: (event: any) => void }>
>(new Map());
const attachVectorTileSourceLoadedEvent = (
targetMap: OlMap,
layerId: string,
property: string,
data: any[]
) => {
if (!map) return;
const vectorTileSource = map
const vectorTileSource = targetMap
.getAllLayers()
.filter((layer) => layer.get("value") === layerId)
.map((layer) => layer.getSource() as VectorTileSource)
@@ -956,24 +1008,25 @@ const StyleEditorPanel: React.FC<StyleEditorPanelProps> = ({
}
};
const listenerKey = getMapKey(targetMap, layerId);
vectorTileSource.on("tileloadend", listener);
tileLoadListenersRef.current.set(vectorTileSource, listener);
tileLoadListenersRef.current.set(listenerKey, {
source: vectorTileSource,
listener,
});
};
// 新增函数:取消对应 layerId 已添加的 on 事件
const removeVectorTileSourceLoadedEvent = (layerId: string) => {
if (!map) return;
const vectorTileSource = map
.getAllLayers()
.filter((layer) => layer.get("value") === layerId)
.map((layer) => layer.getSource() as VectorTileSource)
.filter((source) => source)[0];
if (!vectorTileSource) return;
const listener = tileLoadListenersRef.current.get(vectorTileSource);
if (listener) {
vectorTileSource.un("tileloadend", listener);
tileLoadListenersRef.current.delete(vectorTileSource);
}
};
const removeVectorTileSourceLoadedEvent = useCallback(
(targetMap: OlMap, layerId: string) => {
const listenerKey = getMapKey(targetMap, layerId);
const listenerState = tileLoadListenersRef.current.get(listenerKey);
if (listenerState) {
listenerState.source.un("tileloadend", listenerState.listener);
tileLoadListenersRef.current.delete(listenerKey);
}
},
[getMapKey]
);
// 监听数据变化,重新应用样式。由样式应用按钮触发,或由数据变化触发
useEffect(() => {
@@ -998,20 +1051,24 @@ const StyleEditorPanel: React.FC<StyleEditorPanelProps> = ({
);
if (isElevation) {
removeVectorTileSourceLoadedEvent("junctions");
activeMaps.forEach((targetMap) => {
removeVectorTileSourceLoadedEvent(targetMap, "junctions");
});
return;
}
if (!currentJunctionCalData) return;
// 更新现有的 VectorTileSource
updateVectorTileSource(junctionText, currentJunctionCalData);
// 移除旧的监听器,并添加新的监听器
removeVectorTileSourceLoadedEvent("junctions");
attachVectorTileSourceLoadedEvent(
"junctions",
junctionText,
currentJunctionCalData
);
activeMaps.forEach((targetMap) => {
const targetData = getDataForMap(targetMap, "junctions");
if (!targetData || targetData.length === 0) return;
updateVectorTileSource(targetMap, "junctions", junctionText, targetData);
removeVectorTileSourceLoadedEvent(targetMap, "junctions");
attachVectorTileSourceLoadedEvent(
targetMap,
"junctions",
junctionText,
targetData
);
});
};
const updatePipeStyle = () => {
const pipeStyleConfigState = layerStyleStates.find(
@@ -1023,16 +1080,24 @@ const StyleEditorPanel: React.FC<StyleEditorPanelProps> = ({
applyClassificationStyle("pipes", pipeStyleConfigState?.styleConfig);
if (isDiameter) {
removeVectorTileSourceLoadedEvent("pipes");
activeMaps.forEach((targetMap) => {
removeVectorTileSourceLoadedEvent(targetMap, "pipes");
});
return;
}
if (!currentPipeCalData) return;
// 更新现有的 VectorTileSource
updateVectorTileSource(pipeText, currentPipeCalData);
// 移除旧的监听器,并添加新的监听器
removeVectorTileSourceLoadedEvent("pipes");
attachVectorTileSourceLoadedEvent("pipes", pipeText, currentPipeCalData);
activeMaps.forEach((targetMap) => {
const targetData = getDataForMap(targetMap, "pipes");
if (!targetData || targetData.length === 0) return;
updateVectorTileSource(targetMap, "pipes", pipeText, targetData);
removeVectorTileSourceLoadedEvent(targetMap, "pipes");
attachVectorTileSourceLoadedEvent(
targetMap,
"pipes",
pipeText,
targetData
);
});
};
if (isUserTrigger) {
if (selectedRenderLayer?.get("value") === "junctions") {
@@ -1060,10 +1125,14 @@ const StyleEditorPanel: React.FC<StyleEditorPanelProps> = ({
updatePipeStyle();
}
if (!applyJunctionStyle) {
removeVectorTileSourceLoadedEvent("junctions");
activeMaps.forEach((targetMap) => {
removeVectorTileSourceLoadedEvent(targetMap, "junctions");
});
}
if (!applyPipeStyle) {
removeVectorTileSourceLoadedEvent("pipes");
activeMaps.forEach((targetMap) => {
removeVectorTileSourceLoadedEvent(targetMap, "pipes");
});
}
// This effect is intentionally driven by explicit style triggers and data snapshots.
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -1073,8 +1142,20 @@ const StyleEditorPanel: React.FC<StyleEditorPanelProps> = ({
applyPipeStyle,
currentJunctionCalData,
currentPipeCalData,
compareJunctionCalData,
comparePipeCalData,
activeMaps,
]);
useEffect(() => {
return () => {
activeMaps.forEach((targetMap) => {
removeVectorTileSourceLoadedEvent(targetMap, "junctions");
removeVectorTileSourceLoadedEvent(targetMap, "pipes");
});
};
}, [activeMaps, removeVectorTileSourceLoadedEvent]);
// 获取地图中的矢量图层,用于选择图层选项
useEffect(() => {
if (!map) return;
+203 -91
View File
@@ -63,6 +63,9 @@ const Timeline: React.FC<TimelineProps> = ({
const setSelectedDate = data?.setSelectedDate ?? NOOP_SET_SELECTED_DATE;
const setCurrentJunctionCalData = data?.setCurrentJunctionCalData;
const setCurrentPipeCalData = data?.setCurrentPipeCalData;
const setCompareJunctionCalData = data?.setCompareJunctionCalData;
const setComparePipeCalData = data?.setComparePipeCalData;
const isCompareMode = data?.isCompareMode ?? false;
const junctionText = data?.junctionText ?? "";
const pipeText = data?.pipeText ?? "";
const { open } = useNotification();
@@ -94,100 +97,209 @@ const Timeline: React.FC<TimelineProps> = ({
// 添加防抖引用
const debounceRef = useRef<NodeJS.Timeout | null>(null);
const updateDataStates = useCallback((nodeResults: any[], linkResults: any[]) => {
if (setCurrentJunctionCalData) {
setCurrentJunctionCalData(nodeResults);
} else {
console.log("setCurrentJunctionCalData is undefined");
}
if (setCurrentPipeCalData) {
setCurrentPipeCalData(linkResults);
} else {
console.log("setCurrentPipeCalData is undefined");
}
}, [setCurrentJunctionCalData, setCurrentPipeCalData]);
const updateDataStates = useCallback(
(
nodeResults: any[],
linkResults: any[],
target: "primary" | "compare" = "primary"
) => {
const setNodeData =
target === "compare"
? setCompareJunctionCalData
: setCurrentJunctionCalData;
const setLinkData =
target === "compare" ? setComparePipeCalData : setCurrentPipeCalData;
const fetchFrameData = useCallback(async (
queryTime: Date,
junctionProperties: string,
pipeProperties: string,
schemeName: string,
schemeType: string,
) => {
const query_time = queryTime.toISOString();
let nodeRecords: any = { results: [] };
let linkRecords: any = { results: [] };
const requests: Promise<Response>[] = [];
let nodePromise: Promise<any> | null = null;
let linkPromise: Promise<any> | null = null;
// 检查node缓存
if (junctionProperties !== "" && junctionProperties !== "elevation") {
const nodeCacheKey = `${query_time}_${junctionProperties}_${schemeName}_${schemeType}`;
if (nodeCacheRef.current.has(nodeCacheKey)) {
nodeRecords = nodeCacheRef.current.get(nodeCacheKey)!;
} else {
disableDateSelection && schemeName
? (nodePromise = apiFetch(
// `${config.BACKEND_URL}/queryallschemerecordsbytimeproperty/?querytime=${query_time}&type=node&property=${junctionProperties}&schemename=${schemeName}`
`${config.BACKEND_URL}/api/v1/scheme/query/by-scheme-time-property?scheme_type=${schemeType}&scheme_name=${schemeName}&query_time=${query_time}&type=node&property=${junctionProperties}`,
))
: (nodePromise = apiFetch(
// `${config.BACKEND_URL}/queryallrecordsbytimeproperty/?querytime=${query_time}&type=node&property=${junctionProperties}`
`${config.BACKEND_URL}/api/v1/realtime/query/by-time-property?query_time=${query_time}&type=node&property=${junctionProperties}`,
));
requests.push(nodePromise);
setNodeData?.(nodeResults);
setLinkData?.(linkResults);
},
[
setCompareJunctionCalData,
setComparePipeCalData,
setCurrentJunctionCalData,
setCurrentPipeCalData,
]
);
const buildCacheKey = useCallback(
(
queryTime: string,
property: string,
sourceType: "scheme" | "realtime",
resultType: "node" | "link",
targetSchemeName: string,
targetSchemeType: string
) =>
[
queryTime,
sourceType,
resultType,
property,
targetSchemeName || "default",
targetSchemeType || "default",
].join("::"),
[]
);
const fetchDataBySource = useCallback(
async ({
queryTime,
junctionProperties,
pipeProperties,
sourceType,
target,
schemeName,
schemeType,
}: {
queryTime: Date;
junctionProperties: string;
pipeProperties: string;
sourceType: "scheme" | "realtime";
target: "primary" | "compare";
schemeName?: string;
schemeType?: string;
}) => {
const query_time = queryTime.toISOString();
let nodeRecords: any = { results: [] };
let linkRecords: any = { results: [] };
const requests: Promise<Response>[] = [];
let nodePromise: Promise<Response> | null = null;
let linkPromise: Promise<Response> | null = null;
if (junctionProperties !== "" && junctionProperties !== "elevation") {
const nodeCacheKey = buildCacheKey(
query_time,
junctionProperties,
sourceType,
"node",
schemeName || "",
schemeType || ""
);
if (nodeCacheRef.current.has(nodeCacheKey)) {
nodeRecords = nodeCacheRef.current.get(nodeCacheKey)!;
} else {
nodePromise =
sourceType === "scheme" && schemeName
? apiFetch(
`${config.BACKEND_URL}/api/v1/scheme/query/by-scheme-time-property?scheme_type=${schemeType}&scheme_name=${schemeName}&query_time=${query_time}&type=node&property=${junctionProperties}`
)
: apiFetch(
`${config.BACKEND_URL}/api/v1/realtime/query/by-time-property?query_time=${query_time}&type=node&property=${junctionProperties}`
);
requests.push(nodePromise);
}
}
}
// 处理特殊属性名称
if (pipeProperties === "unit_headloss") pipeProperties = "headloss";
// 检查link缓存
if (pipeProperties !== "" && pipeProperties !== "diameter") {
const linkCacheKey = `${query_time}_${pipeProperties}_${schemeName}_${schemeType}`;
if (linkCacheRef.current.has(linkCacheKey)) {
linkRecords = linkCacheRef.current.get(linkCacheKey)!;
} else {
disableDateSelection && schemeName
? (linkPromise = apiFetch(
// `${config.BACKEND_URL}/queryallschemerecordsbytimeproperty/?querytime=${query_time}&type=link&property=${pipeProperties}&schemename=${schemeName}`
`${config.BACKEND_URL}/api/v1/scheme/query/by-scheme-time-property?scheme_type=${schemeType}&scheme_name=${schemeName}&query_time=${query_time}&type=link&property=${pipeProperties}`,
))
: (linkPromise = apiFetch(
// `${config.BACKEND_URL}/queryallrecordsbytimeproperty/?querytime=${query_time}&type=link&property=${pipeProperties}`
`${config.BACKEND_URL}/api/v1/realtime/query/by-time-property?query_time=${query_time}&type=link&property=${pipeProperties}`,
));
requests.push(linkPromise);
const normalizedPipeProperties =
pipeProperties === "unit_headloss" ? "headloss" : pipeProperties;
if (normalizedPipeProperties !== "" && normalizedPipeProperties !== "diameter") {
const linkCacheKey = buildCacheKey(
query_time,
normalizedPipeProperties,
sourceType,
"link",
schemeName || "",
schemeType || ""
);
if (linkCacheRef.current.has(linkCacheKey)) {
linkRecords = linkCacheRef.current.get(linkCacheKey)!;
} else {
linkPromise =
sourceType === "scheme" && schemeName
? apiFetch(
`${config.BACKEND_URL}/api/v1/scheme/query/by-scheme-time-property?scheme_type=${schemeType}&scheme_name=${schemeName}&query_time=${query_time}&type=link&property=${normalizedPipeProperties}`
)
: apiFetch(
`${config.BACKEND_URL}/api/v1/realtime/query/by-time-property?query_time=${query_time}&type=link&property=${normalizedPipeProperties}`
);
requests.push(linkPromise);
}
}
}
// 等待所有有效请求
const responses = await Promise.all(requests);
const responses = await Promise.all(requests);
if (nodePromise) {
const nodeResponse = responses.shift()!;
if (!nodeResponse.ok)
throw new Error(`Node fetch failed: ${nodeResponse.status}`);
nodeRecords = await nodeResponse.json();
// 缓存数据(修复键以包含 schemeName
nodeCacheRef.current.set(
`${query_time}_${junctionProperties}_${schemeName}_${schemeType}`,
nodeRecords || [],
);
}
if (linkPromise) {
const linkResponse = responses.shift()!;
if (!linkResponse.ok)
throw new Error(`Link fetch failed: ${linkResponse.status}`);
linkRecords = await linkResponse.json();
// 缓存数据(修复键以包含 schemeName
linkCacheRef.current.set(
`${query_time}_${pipeProperties}_${schemeName}_${schemeType}`,
linkRecords || [],
);
}
// 更新状态
updateDataStates(nodeRecords.results || [], linkRecords.results || []);
}, [disableDateSelection, updateDataStates]);
if (nodePromise) {
const nodeResponse = responses.shift()!;
if (!nodeResponse.ok) {
throw new Error(`Node fetch failed: ${nodeResponse.status}`);
}
nodeRecords = await nodeResponse.json();
nodeCacheRef.current.set(
buildCacheKey(
query_time,
junctionProperties,
sourceType,
"node",
schemeName || "",
schemeType || ""
),
nodeRecords || []
);
}
if (linkPromise) {
const linkResponse = responses.shift()!;
if (!linkResponse.ok) {
throw new Error(`Link fetch failed: ${linkResponse.status}`);
}
linkRecords = await linkResponse.json();
linkCacheRef.current.set(
buildCacheKey(
query_time,
normalizedPipeProperties,
sourceType,
"link",
schemeName || "",
schemeType || ""
),
linkRecords || []
);
}
updateDataStates(nodeRecords.results || [], linkRecords.results || [], target);
},
[buildCacheKey, updateDataStates]
);
const fetchFrameData = useCallback(
async (
queryTime: Date,
junctionProperties: string,
pipeProperties: string,
schemeName: string,
schemeType: string
) => {
const primarySourceType =
disableDateSelection && schemeName ? "scheme" : "realtime";
const tasks = [
fetchDataBySource({
queryTime,
junctionProperties,
pipeProperties,
sourceType: primarySourceType,
target: "primary",
schemeName,
schemeType,
}),
];
if (isCompareMode && disableDateSelection && schemeName) {
tasks.push(
fetchDataBySource({
queryTime,
junctionProperties,
pipeProperties,
sourceType: "realtime",
target: "compare",
})
);
}
await Promise.all(tasks);
},
[disableDateSelection, fetchDataBySource, isCompareMode]
);
// 时间刻度数组 (每5分钟一个刻度)
const timeMarks = Array.from({ length: 288 }, (_, i) => ({
@@ -453,9 +565,9 @@ const Timeline: React.FC<TimelineProps> = ({
if (!cacheRef.current) return;
const cacheKeys = Array.from(cacheRef.current.keys());
cacheKeys.forEach((key) => {
const keyParts = key.split("_");
const cacheDate = keyParts[0].split("T")[0];
const cacheTimeStr = keyParts[0].split("T")[1];
const cacheTimeKey = key.split("::")[0];
const cacheDate = cacheTimeKey.split("T")[0];
const cacheTimeStr = cacheTimeKey.split("T")[1];
if (cacheDate === dateStr && cacheTimeStr) {
const [hours, minutes] = cacheTimeStr.split(":");
@@ -5,6 +5,7 @@ import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined";
import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
import PaletteOutlinedIcon from "@mui/icons-material/PaletteOutlined";
import QueryStatsOutlinedIcon from "@mui/icons-material/QueryStatsOutlined";
import CompareArrowsOutlinedIcon from "@mui/icons-material/CompareArrowsOutlined";
import PropertyPanel from "./PropertyPanel"; // 引入属性面板组件
import DrawPanel from "./DrawPanel"; // 引入绘图面板组件
import HistoryDataPanel from "./HistoryDataPanel"; // 引入绘图面板组件
@@ -34,12 +35,14 @@ interface ToolbarProps {
queryType?: string; // 可选的查询类型参数
schemeType?: string; // 可选的方案类型参数
HistoryPanel?: React.FC<any>; // 可选的自定义历史数据面板
enableCompare?: boolean;
}
const Toolbar: React.FC<ToolbarProps> = ({
hiddenButtons,
queryType,
schemeType,
HistoryPanel,
enableCompare = false,
}) => {
const map = useMap();
const data = useData();
@@ -55,6 +58,17 @@ const Toolbar: React.FC<ToolbarProps> = ({
const currentTime = data?.currentTime;
const selectedDate = data?.selectedDate;
const schemeName = data?.schemeName;
const isCompareMode = data?.isCompareMode ?? false;
const toggleCompareMode = data?.toggleCompareMode;
const canToggleCompare = Boolean(
enableCompare && (isCompareMode || (queryType === "scheme" && schemeName)),
);
useEffect(() => {
if (!enableCompare && isCompareMode) {
toggleCompareMode?.();
}
}, [enableCompare, isCompareMode, toggleCompareMode]);
// Chat tool action → direct featureInfos override (bypasses OL Feature lookup)
const [chatPanelFeatureInfos, setChatPanelFeatureInfos] = useState<
@@ -853,6 +867,15 @@ const Toolbar: React.FC<ToolbarProps> = ({
onClick={() => handleToolClick("style")}
/>
)}
{enableCompare && (
<ToolbarButton
icon={<CompareArrowsOutlinedIcon />}
name={isCompareMode ? "关闭对比" : "双屏对比"}
isActive={isCompareMode}
onClick={() => toggleCompareMode?.()}
disabled={!canToggleCompare}
/>
)}
</div>
{showPropertyPanel && <PropertyPanel {...getFeatureProperties()} />}
{showDrawPanel && map && <DrawPanel />}
+600 -166
View File
@@ -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<React.SetStateAction<any[]>>;
currentPipeCalData?: any[]; // 当前计算结果
setCurrentPipeCalData?: React.Dispatch<React.SetStateAction<any[]>>;
compareJunctionCalData?: any[];
setCompareJunctionCalData?: React.Dispatch<React.SetStateAction<any[]>>;
comparePipeCalData?: any[];
setComparePipeCalData?: React.Dispatch<React.SetStateAction<any[]>>;
isCompareMode?: boolean;
setCompareMode?: React.Dispatch<React.SetStateAction<boolean>>;
toggleCompareMode?: () => void;
showJunctionText?: boolean; // 是否显示节点文本
showPipeText?: boolean; // 是否显示管道文本
showJunctionId?: boolean; // 是否显示节点ID
@@ -69,6 +77,10 @@ interface DataContextType {
setPipeText?: React.Dispatch<React.SetStateAction<string>>;
setContours?: React.Dispatch<React.SetStateAction<any[]>>;
deckLayer?: DeckLayer;
compareDeckLayer?: DeckLayer;
deckLayers?: DeckLayer[];
compareMap?: OlMap;
maps?: OlMap[];
diameterRange?: [number, number];
elevationRange?: [number, number];
}
@@ -128,12 +140,18 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
const mapRef = useRef<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const compareMapRef = useRef<HTMLDivElement | null>(null);
const compareCanvasRef = useRef<HTMLCanvasElement | null>(null);
const deckLayerRef = useRef<DeckLayer | null>(null);
const compareDeckLayerRef = useRef<DeckLayer | null>(null);
const isDisposingRef = useRef(false);
const isCompareDisposingRef = useRef(false);
const pendingTimeoutsRef = useRef<number[]>([]);
const [map, setMap] = useState<OlMap>();
const [deckLayer, setDeckLayer] = useState<DeckLayer>();
const [compareMap, setCompareMap] = useState<OlMap>();
const [compareDeckLayer, setCompareDeckLayer] = useState<DeckLayer>();
// currentCalData 用于存储当前计算结果
const [currentTime, setCurrentTime] = useState<number>(-1); // 默认选择当前时间
// const [selectedDate, setSelectedDate] = useState<Date>(new Date("2025-9-17"));
@@ -144,6 +162,11 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
[],
);
const [currentPipeCalData, setCurrentPipeCalData] = useState<any[]>([]);
const [compareJunctionCalData, setCompareJunctionCalData] = useState<any[]>(
[],
);
const [comparePipeCalData, setComparePipeCalData] = useState<any[]>([]);
const [isCompareMode, setCompareMode] = useState(false);
// junctionData 和 pipeData 分别缓存瓦片解析后节点和管道的数据,用于 deck.gl 定位、标签渲染
// currentJunctionCalData 和 currentPipeCalData 变化时会新增并更新 junctionData 和 pipeData 的计算属性值
const [junctionData, setJunctionDataState] = useState<any[]>([]);
@@ -201,6 +224,37 @@ const MapComponent: React.FC<MapComponentProps> = ({ 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<MapComponentProps> = ({ 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<MapComponentProps> = ({ 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<MapComponentProps> = ({ 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<MapComponentProps> = ({ 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<MapComponentProps> = ({ children }) => {
currentPipeCalData,
currentZoom,
mergedPipeData,
mergedComparePipeData,
isCompareMode,
pipeText,
isWaterflowLayerAvailable,
showWaterflowLayer,
@@ -1097,6 +1491,13 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
setCurrentJunctionCalData,
currentPipeCalData,
setCurrentPipeCalData,
compareJunctionCalData,
setCompareJunctionCalData,
comparePipeCalData,
setComparePipeCalData,
isCompareMode,
setCompareMode,
toggleCompareMode,
setShowJunctionTextLayer,
setShowPipeTextLayer,
setShowJunctionId,
@@ -1115,17 +1516,50 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
pipeText,
setContours,
deckLayer,
compareDeckLayer,
deckLayers,
compareMap,
maps,
diameterRange,
elevationRange,
}}
>
<MapContext.Provider value={map}>
<div className="relative w-full h-full">
<div ref={mapRef} className="w-full h-full"></div>
<div className="flex w-full h-full">
<div
className={`relative h-full ${isCompareMode ? "w-1/2" : "w-full"}`}
>
<div ref={mapRef} className="w-full h-full"></div>
<canvas
ref={canvasRef}
className="pointer-events-none absolute inset-0"
/>
{isCompareMode && (
<div className="pointer-events-none absolute left-4 top-4 rounded-md bg-black/55 px-3 py-1 text-sm font-medium text-white">
</div>
)}
</div>
{isCompareMode && (
<div className="relative h-full w-1/2 border-l border-white/40">
<div ref={compareMapRef} className="w-full h-full"></div>
<canvas
ref={compareCanvasRef}
className="pointer-events-none absolute inset-0"
/>
<div className="pointer-events-none absolute left-4 top-4 rounded-md bg-black/55 px-3 py-1 text-sm font-medium text-white">
</div>
</div>
)}
</div>
{isCompareMode && (
<div className="pointer-events-none absolute inset-y-0 left-1/2 z-10 w-px -translate-x-1/2 bg-white/85 shadow-[0_0_0_1px_rgba(15,23,42,0.18)]" />
)}
<MapTools />
{children}
</div>
<canvas ref={canvasRef} />
</MapContext.Provider>
</DataContext.Provider>
</>