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

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 ( return (
<div className="relative w-full h-full overflow-hidden"> <div className="relative w-full h-full overflow-hidden">
<MapComponent> <MapComponent>
<MapToolbar queryType="scheme" schemeType="burst_analysis" /> <MapToolbar
queryType="scheme"
schemeType="burst_analysis"
enableCompare
/>
<BurstPipeAnalysisPanel /> <BurstPipeAnalysisPanel />
</MapComponent> </MapComponent>
</div> </div>
@@ -8,7 +8,11 @@ export default function Home() {
return ( return (
<div className="relative w-full h-full overflow-hidden"> <div className="relative w-full h-full overflow-hidden">
<MapComponent> <MapComponent>
<MapToolbar queryType="scheme" schemeType="contaminant_analysis" /> <MapToolbar
queryType="scheme"
schemeType="contaminant_analysis"
enableCompare
/>
<WaterQualityPanel /> <WaterQualityPanel />
</MapComponent> </MapComponent>
</div> </div>
@@ -1,60 +1,51 @@
import React, { useState, useEffect } from "react"; "use client";
import React, { useState, useEffect, useMemo, useRef } from "react";
import Image from "next/image"; import Image from "next/image";
import { useMap } from "../MapComponent"; import { useData, useMap } from "../MapComponent";
import TileLayer from "ol/layer/Tile.js"; import TileLayer from "ol/layer/Tile.js";
import XYZ from "ol/source/XYZ.js"; import XYZ from "ol/source/XYZ.js";
import Group from "ol/layer/Group";
import mapboxOutdoors from "@assets/map/layers/mapbox-outdoors.png"; import mapboxOutdoors from "@assets/map/layers/mapbox-outdoors.png";
import mapboxLight from "@assets/map/layers/mapbox-light.png"; import mapboxLight from "@assets/map/layers/mapbox-light.png";
import mapboxSatellite from "@assets/map/layers/mapbox-satellite.png"; import mapboxSatellite from "@assets/map/layers/mapbox-satellite.png";
import mapboxSatelliteStreet from "@assets/map/layers/mapbox-satellite-streets.png"; import mapboxSatelliteStreet from "@assets/map/layers/mapbox-satellite-streets.png";
import mapboxStreets from "@assets/map/layers/mapbox-streets.png"; import mapboxStreets from "@assets/map/layers/mapbox-streets.png";
import clsx from "clsx"; import clsx from "clsx";
import Group from "ol/layer/Group"; import { MAPBOX_TOKEN, TIANDITU_TOKEN } from "@config/config";
import { MAPBOX_TOKEN } from "@config/config"; import type { Map as OlMap } from "ol";
import { TIANDITU_TOKEN } from "@config/config";
const INITIAL_LAYER = "mapbox-light"; const INITIAL_LAYER = "mapbox-light";
const streetsLayer = new TileLayer({ const createTileLayer = (url: string, attributions: string) =>
new TileLayer({
source: new XYZ({ source: new XYZ({
url: `https://api.mapbox.com/styles/v1/mapbox/streets-v12/tiles/256/{z}/{x}/{y}@2x?access_token=${MAPBOX_TOKEN}`, url,
tileSize: 512, tileSize: 512,
maxZoom: 20, maxZoom: 20,
projection: "EPSG:3857", projection: "EPSG:3857",
attributions: 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 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({ const tiandituVectorLayer = new TileLayer({
source: new XYZ({ 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}`, 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}`,
@@ -83,25 +74,18 @@ const tiandituImageAnnotationLayer = new TileLayer({
attributions: '数据来源:<a href="https://www.tianditu.gov.cn/">天地图</a>', attributions: '数据来源:<a href="https://www.tianditu.gov.cn/">天地图</a>',
}), }),
}); });
const tiandituVectorLayerGroup = new Group({
layers: [tiandituVectorLayer, tiandituVectorAnnotationLayer], return [
});
const tiandituImageLayerGroup = new Group({
layers: [tiandituImageLayer, tiandituImageAnnotationLayer],
});
const baseLayers = [
{ {
id: "mapbox-light", id: "mapbox-light",
name: "默认地图", name: "默认地图",
layer: lightMapLayer, layer: lightMapLayer,
// layer: tiandituVectorLayerGroup,
img: mapboxLight.src, img: mapboxLight.src,
}, },
{ {
id: "mapbox-satellite", id: "mapbox-satellite",
name: "卫星地图", name: "卫星地图",
layer: satelliteLayer, layer: satelliteLayer,
// layer: tiandituImageLayerGroup,
img: mapboxSatellite.src, img: mapboxSatellite.src,
}, },
{ {
@@ -116,43 +100,75 @@ const baseLayers = [
layer: streetsLayer, layer: streetsLayer,
img: mapboxStreets.src, 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 BaseLayers: React.FC = () => {
const map = useMap(); 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 [isShow, setShow] = useState(false);
const [isExpanded, setExpanded] = useState(false); const [isExpanded, setExpanded] = useState(false);
// 快速切换底图
const [activeId, setActiveId] = useState(INITIAL_LAYER); const [activeId, setActiveId] = useState(INITIAL_LAYER);
// 初始化默认底图
useEffect(() => { useEffect(() => {
if (!map) return; maps.forEach((targetMap) => {
// 添加所有底图至地图并根据 activeId 控制可见性 let layerEntries = layerSetsRef.current.get(targetMap);
baseLayers.forEach((layerInfo) => { if (!layerEntries) {
const layers = map.getLayers().getArray(); layerEntries = createBaseLayerEntries();
layerSetsRef.current.set(targetMap, layerEntries);
}
layerEntries.forEach((layerInfo) => {
const layers = targetMap.getLayers().getArray();
if (!layers.includes(layerInfo.layer)) { if (!layers.includes(layerInfo.layer)) {
map.getLayers().insertAt(0, layerInfo.layer); targetMap.getLayers().insertAt(0, layerInfo.layer);
} }
layerInfo.layer.setVisible(layerInfo.id === activeId); layerInfo.layer.setVisible(layerInfo.id === activeId);
}); });
}, [map, activeId]); });
}, [activeId, maps]);
const changeMapLayers = (id: string) => { const changeMapLayers = (id: string) => {
if (map) { maps.forEach((targetMap) => {
// 根据 id 设置每个图层的可见性 const layerEntries = layerSetsRef.current.get(targetMap);
baseLayers.forEach(({ id: lid, layer }) => { layerEntries?.forEach(({ id: layerId, layer }) => {
layer.setVisible(lid === id); layer.setVisible(layerId === id);
});
}); });
}
}; };
const baseLayers = useMemo(() => createBaseLayerEntries().map(({ id, name, img }) => ({
id,
name,
img,
})), []);
const handleQuickSwitch = () => { const handleQuickSwitch = () => {
const nextId = const nextId =
activeId === baseLayers[0].id ? baseLayers[1].id : baseLayers[0].id; activeId === baseLayers[0].id ? baseLayers[1].id : baseLayers[0].id;
setActiveId(nextId); setActiveId(nextId);
handleMapLayers(nextId); changeMapLayers(nextId);
}; };
const handleMapLayers = (id: string) => { const handleMapLayers = (id: string) => {
@@ -160,7 +176,6 @@ const BaseLayers: React.FC = () => {
changeMapLayers(id); changeMapLayers(id);
}; };
// 记录定时器,避免多次触发
const hideTimer = React.useRef<NodeJS.Timeout | null>(null); const hideTimer = React.useRef<NodeJS.Timeout | null>(null);
const handleEnter = () => { const handleEnter = () => {
@@ -217,7 +232,7 @@ const BaseLayers: React.FC = () => {
{isExpanded && ( {isExpanded && (
<div <div
className={clsx( 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" isShow ? "opacity-100" : "opacity-0"
)} )}
onMouseEnter={handleEnter} onMouseEnter={handleEnter}
@@ -5,6 +5,7 @@ import WebGLVectorTileLayer from "ol/layer/WebGLVectorTile";
import VectorLayer from "ol/layer/Vector"; import VectorLayer from "ol/layer/Vector";
import VectorTileLayer from "ol/layer/VectorTile"; import VectorTileLayer from "ol/layer/VectorTile";
import { DeckLayer } from "@utils/layers"; import { DeckLayer } from "@utils/layers";
import type { Map as OlMap } from "ol";
// 定义统一的图层项接口 // 定义统一的图层项接口
interface LayerItem { interface LayerItem {
@@ -30,8 +31,10 @@ const LAYER_ORDER = [
const LayerControl: React.FC = () => { const LayerControl: React.FC = () => {
const map = useMap(); const map = useMap();
const data = useData(); const data = useData();
const maps: OlMap[] = data?.maps?.length ? data.maps : map ? [map] : [];
const [refreshKey, setRefreshKey] = useState(0); const [refreshKey, setRefreshKey] = useState(0);
const deckLayer = data?.deckLayer; const deckLayer = data?.deckLayer;
const deckLayers = data?.deckLayers ?? (deckLayer ? [deckLayer] : []);
const isContourLayerAvailable = data?.isContourLayerAvailable; const isContourLayerAvailable = data?.isContourLayerAvailable;
const isWaterflowLayerAvailable = data?.isWaterflowLayerAvailable; const isWaterflowLayerAvailable = data?.isWaterflowLayerAvailable;
const setShowWaterflowLayer = data?.setShowWaterflowLayer; const setShowWaterflowLayer = data?.setShowWaterflowLayer;
@@ -117,8 +120,16 @@ const LayerControl: React.FC = () => {
const handleVisibilityChange = (item: LayerItem, checked: boolean) => { const handleVisibilityChange = (item: LayerItem, checked: boolean) => {
if (item.type === "ol") { if (item.type === "ol") {
item.layerRef.setVisible(checked); maps.forEach((targetMap) => {
} else if (item.type === "deck" && deckLayer) { 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") { if (item.id === "junctionContourLayer") {
setShowContourLayer && setShowContourLayer(checked); setShowContourLayer && setShowContourLayer(checked);
} }
@@ -29,6 +29,7 @@ import { FlatStyleLike } from "ol/style/flat";
import { calculateClassification } from "@utils/breaks_classification"; import { calculateClassification } from "@utils/breaks_classification";
import { parseColor } from "@utils/parseColor"; import { parseColor } from "@utils/parseColor";
import { VectorTile } from "ol"; import { VectorTile } from "ol";
import type { Map as OlMap } from "ol";
import { useNotification } from "@refinedev/core"; import { useNotification } from "@refinedev/core";
import { config } from "@/config/config"; import { config } from "@/config/config";
@@ -182,6 +183,13 @@ const StyleEditorPanel: React.FC<StyleEditorPanelProps> = ({
const data = useData(); const data = useData();
const currentJunctionCalData = data?.currentJunctionCalData; const currentJunctionCalData = data?.currentJunctionCalData;
const currentPipeCalData = data?.currentPipeCalData; 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 junctionText = data?.junctionText ?? "";
const pipeText = data?.pipeText ?? ""; const pipeText = data?.pipeText ?? "";
const setShowJunctionTextLayer = data?.setShowJunctionTextLayer; const setShowJunctionTextLayer = data?.setShowJunctionTextLayer;
@@ -229,6 +237,45 @@ const StyleEditorPanel: React.FC<StyleEditorPanelProps> = ({
customColors: [], 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 = ( const getDefaultCustomColors = (
segments: number, segments: number,
existingColors: string[] = [] existingColors: string[] = []
@@ -613,13 +660,10 @@ const StyleEditorPanel: React.FC<StyleEditorPanelProps> = ({
return; return;
} }
const styleConfig = layerStyleConfig.styleConfig; const styleConfig = layerStyleConfig.styleConfig;
const renderLayer = renderLayers.filter((layer) => { const targetLayers = getRenderLayersById(layerStyleConfig.layerId);
return layer.get("value") === layerStyleConfig.layerId; const renderLayer = targetLayers[0];
})[0];
if (!renderLayer || !styleConfig?.property) return; if (!renderLayer || !styleConfig?.property) return;
const layerType: string = renderLayer?.get("type"); const layerType: string = renderLayer.get("type");
const source = renderLayer.getSource();
if (!source) return;
const breaksLength = breaks.length; const breaksLength = breaks.length;
// 根据 breaks 计算每个分段的颜色,线条粗细 // 根据 breaks 计算每个分段的颜色,线条粗细
@@ -757,7 +801,9 @@ const StyleEditorPanel: React.FC<StyleEditorPanelProps> = ({
dynamicStyle["circle-stroke-width"] = 2; dynamicStyle["circle-stroke-width"] = 2;
} }
// 应用样式到图层 // 应用样式到图层
renderLayer.setStyle(dynamicStyle); targetLayers.forEach((targetLayer) => {
targetLayer.setStyle(dynamicStyle);
});
// 用初始化时的样式配置更新图例配置,避免覆盖已有的图例名称和属性 // 用初始化时的样式配置更新图例配置,避免覆盖已有的图例名称和属性
const layerId = renderLayer.get("value"); const layerId = renderLayer.get("value");
const initLayerStyleState = layerStyleStates.find( const initLayerStyleState = layerStyleStates.find(
@@ -844,10 +890,12 @@ const StyleEditorPanel: React.FC<StyleEditorPanelProps> = ({
if (!selectedRenderLayer) return; if (!selectedRenderLayer) return;
// 重置 WebGL 图层样式 // 重置 WebGL 图层样式
const defaultFlatStyle: FlatStyleLike = config.MAP_DEFAULT_STYLE; 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) { if (layerId !== undefined) {
setLayerStyleStates((prev) => setLayerStyleStates((prev) =>
prev.filter((state) => state.layerId !== layerId) prev.filter((state) => state.layerId !== layerId)
@@ -870,11 +918,15 @@ const StyleEditorPanel: React.FC<StyleEditorPanelProps> = ({
} }
}; };
// 更新当前 VectorTileSource 中的所有缓冲要素属性 // 更新当前 VectorTileSource 中的所有缓冲要素属性
const updateVectorTileSource = (property: string, data: any[]) => { const updateVectorTileSource = (
if (!map) return; targetMap: OlMap,
const vectorTileSources = map layerId: string,
property: string,
data: any[]
) => {
const vectorTileSources = targetMap
.getAllLayers() .getAllLayers()
.filter((layer) => layer instanceof WebGLVectorTileLayer) .filter((layer) => layer.get("value") === layerId)
.map((layer) => layer.getSource() as VectorTileSource) .map((layer) => layer.getSource() as VectorTileSource)
.filter((source) => source); .filter((source) => source);
@@ -911,16 +963,16 @@ const StyleEditorPanel: React.FC<StyleEditorPanelProps> = ({
}; };
// 新增事件,监听 VectorTileSource 的 tileloadend 事件,为新增瓦片数据动态更新要素属性 // 新增事件,监听 VectorTileSource 的 tileloadend 事件,为新增瓦片数据动态更新要素属性
const tileLoadListenersRef = useRef< const tileLoadListenersRef = useRef<
Map<VectorTileSource, (event: any) => void> Map<string, { source: VectorTileSource; listener: (event: any) => void }>
>(new Map()); >(new Map());
const attachVectorTileSourceLoadedEvent = ( const attachVectorTileSourceLoadedEvent = (
targetMap: OlMap,
layerId: string, layerId: string,
property: string, property: string,
data: any[] data: any[]
) => { ) => {
if (!map) return; const vectorTileSource = targetMap
const vectorTileSource = map
.getAllLayers() .getAllLayers()
.filter((layer) => layer.get("value") === layerId) .filter((layer) => layer.get("value") === layerId)
.map((layer) => layer.getSource() as VectorTileSource) .map((layer) => layer.getSource() as VectorTileSource)
@@ -956,24 +1008,25 @@ const StyleEditorPanel: React.FC<StyleEditorPanelProps> = ({
} }
}; };
const listenerKey = getMapKey(targetMap, layerId);
vectorTileSource.on("tileloadend", listener); vectorTileSource.on("tileloadend", listener);
tileLoadListenersRef.current.set(vectorTileSource, listener); tileLoadListenersRef.current.set(listenerKey, {
source: vectorTileSource,
listener,
});
}; };
// 新增函数:取消对应 layerId 已添加的 on 事件 // 新增函数:取消对应 layerId 已添加的 on 事件
const removeVectorTileSourceLoadedEvent = (layerId: string) => { const removeVectorTileSourceLoadedEvent = useCallback(
if (!map) return; (targetMap: OlMap, layerId: string) => {
const vectorTileSource = map const listenerKey = getMapKey(targetMap, layerId);
.getAllLayers() const listenerState = tileLoadListenersRef.current.get(listenerKey);
.filter((layer) => layer.get("value") === layerId) if (listenerState) {
.map((layer) => layer.getSource() as VectorTileSource) listenerState.source.un("tileloadend", listenerState.listener);
.filter((source) => source)[0]; tileLoadListenersRef.current.delete(listenerKey);
if (!vectorTileSource) return;
const listener = tileLoadListenersRef.current.get(vectorTileSource);
if (listener) {
vectorTileSource.un("tileloadend", listener);
tileLoadListenersRef.current.delete(vectorTileSource);
} }
}; },
[getMapKey]
);
// 监听数据变化,重新应用样式。由样式应用按钮触发,或由数据变化触发 // 监听数据变化,重新应用样式。由样式应用按钮触发,或由数据变化触发
useEffect(() => { useEffect(() => {
@@ -998,20 +1051,24 @@ const StyleEditorPanel: React.FC<StyleEditorPanelProps> = ({
); );
if (isElevation) { if (isElevation) {
removeVectorTileSourceLoadedEvent("junctions"); activeMaps.forEach((targetMap) => {
removeVectorTileSourceLoadedEvent(targetMap, "junctions");
});
return; return;
} }
if (!currentJunctionCalData) return; activeMaps.forEach((targetMap) => {
// 更新现有的 VectorTileSource const targetData = getDataForMap(targetMap, "junctions");
updateVectorTileSource(junctionText, currentJunctionCalData); if (!targetData || targetData.length === 0) return;
// 移除旧的监听器,并添加新的监听器 updateVectorTileSource(targetMap, "junctions", junctionText, targetData);
removeVectorTileSourceLoadedEvent("junctions"); removeVectorTileSourceLoadedEvent(targetMap, "junctions");
attachVectorTileSourceLoadedEvent( attachVectorTileSourceLoadedEvent(
targetMap,
"junctions", "junctions",
junctionText, junctionText,
currentJunctionCalData targetData
); );
});
}; };
const updatePipeStyle = () => { const updatePipeStyle = () => {
const pipeStyleConfigState = layerStyleStates.find( const pipeStyleConfigState = layerStyleStates.find(
@@ -1023,16 +1080,24 @@ const StyleEditorPanel: React.FC<StyleEditorPanelProps> = ({
applyClassificationStyle("pipes", pipeStyleConfigState?.styleConfig); applyClassificationStyle("pipes", pipeStyleConfigState?.styleConfig);
if (isDiameter) { if (isDiameter) {
removeVectorTileSourceLoadedEvent("pipes"); activeMaps.forEach((targetMap) => {
removeVectorTileSourceLoadedEvent(targetMap, "pipes");
});
return; return;
} }
if (!currentPipeCalData) return; activeMaps.forEach((targetMap) => {
// 更新现有的 VectorTileSource const targetData = getDataForMap(targetMap, "pipes");
updateVectorTileSource(pipeText, currentPipeCalData); if (!targetData || targetData.length === 0) return;
// 移除旧的监听器,并添加新的监听器 updateVectorTileSource(targetMap, "pipes", pipeText, targetData);
removeVectorTileSourceLoadedEvent("pipes"); removeVectorTileSourceLoadedEvent(targetMap, "pipes");
attachVectorTileSourceLoadedEvent("pipes", pipeText, currentPipeCalData); attachVectorTileSourceLoadedEvent(
targetMap,
"pipes",
pipeText,
targetData
);
});
}; };
if (isUserTrigger) { if (isUserTrigger) {
if (selectedRenderLayer?.get("value") === "junctions") { if (selectedRenderLayer?.get("value") === "junctions") {
@@ -1060,10 +1125,14 @@ const StyleEditorPanel: React.FC<StyleEditorPanelProps> = ({
updatePipeStyle(); updatePipeStyle();
} }
if (!applyJunctionStyle) { if (!applyJunctionStyle) {
removeVectorTileSourceLoadedEvent("junctions"); activeMaps.forEach((targetMap) => {
removeVectorTileSourceLoadedEvent(targetMap, "junctions");
});
} }
if (!applyPipeStyle) { if (!applyPipeStyle) {
removeVectorTileSourceLoadedEvent("pipes"); activeMaps.forEach((targetMap) => {
removeVectorTileSourceLoadedEvent(targetMap, "pipes");
});
} }
// This effect is intentionally driven by explicit style triggers and data snapshots. // This effect is intentionally driven by explicit style triggers and data snapshots.
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
@@ -1073,8 +1142,20 @@ const StyleEditorPanel: React.FC<StyleEditorPanelProps> = ({
applyPipeStyle, applyPipeStyle,
currentJunctionCalData, currentJunctionCalData,
currentPipeCalData, currentPipeCalData,
compareJunctionCalData,
comparePipeCalData,
activeMaps,
]); ]);
useEffect(() => {
return () => {
activeMaps.forEach((targetMap) => {
removeVectorTileSourceLoadedEvent(targetMap, "junctions");
removeVectorTileSourceLoadedEvent(targetMap, "pipes");
});
};
}, [activeMaps, removeVectorTileSourceLoadedEvent]);
// 获取地图中的矢量图层,用于选择图层选项 // 获取地图中的矢量图层,用于选择图层选项
useEffect(() => { useEffect(() => {
if (!map) return; if (!map) return;
+173 -61
View File
@@ -63,6 +63,9 @@ const Timeline: React.FC<TimelineProps> = ({
const setSelectedDate = data?.setSelectedDate ?? NOOP_SET_SELECTED_DATE; const setSelectedDate = data?.setSelectedDate ?? NOOP_SET_SELECTED_DATE;
const setCurrentJunctionCalData = data?.setCurrentJunctionCalData; const setCurrentJunctionCalData = data?.setCurrentJunctionCalData;
const setCurrentPipeCalData = data?.setCurrentPipeCalData; const setCurrentPipeCalData = data?.setCurrentPipeCalData;
const setCompareJunctionCalData = data?.setCompareJunctionCalData;
const setComparePipeCalData = data?.setComparePipeCalData;
const isCompareMode = data?.isCompareMode ?? false;
const junctionText = data?.junctionText ?? ""; const junctionText = data?.junctionText ?? "";
const pipeText = data?.pipeText ?? ""; const pipeText = data?.pipeText ?? "";
const { open } = useNotification(); const { open } = useNotification();
@@ -94,100 +97,209 @@ const Timeline: React.FC<TimelineProps> = ({
// 添加防抖引用 // 添加防抖引用
const debounceRef = useRef<NodeJS.Timeout | null>(null); const debounceRef = useRef<NodeJS.Timeout | null>(null);
const updateDataStates = useCallback((nodeResults: any[], linkResults: any[]) => { const updateDataStates = useCallback(
if (setCurrentJunctionCalData) { (
setCurrentJunctionCalData(nodeResults); nodeResults: any[],
} else { linkResults: any[],
console.log("setCurrentJunctionCalData is undefined"); target: "primary" | "compare" = "primary"
}
if (setCurrentPipeCalData) {
setCurrentPipeCalData(linkResults);
} else {
console.log("setCurrentPipeCalData is undefined");
}
}, [setCurrentJunctionCalData, setCurrentPipeCalData]);
const fetchFrameData = useCallback(async (
queryTime: Date,
junctionProperties: string,
pipeProperties: string,
schemeName: string,
schemeType: string,
) => { ) => {
const setNodeData =
target === "compare"
? setCompareJunctionCalData
: setCurrentJunctionCalData;
const setLinkData =
target === "compare" ? setComparePipeCalData : setCurrentPipeCalData;
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(); const query_time = queryTime.toISOString();
let nodeRecords: any = { results: [] }; let nodeRecords: any = { results: [] };
let linkRecords: any = { results: [] }; let linkRecords: any = { results: [] };
const requests: Promise<Response>[] = []; const requests: Promise<Response>[] = [];
let nodePromise: Promise<any> | null = null; let nodePromise: Promise<Response> | null = null;
let linkPromise: Promise<any> | null = null; let linkPromise: Promise<Response> | null = null;
// 检查node缓存
if (junctionProperties !== "" && junctionProperties !== "elevation") { if (junctionProperties !== "" && junctionProperties !== "elevation") {
const nodeCacheKey = `${query_time}_${junctionProperties}_${schemeName}_${schemeType}`; const nodeCacheKey = buildCacheKey(
query_time,
junctionProperties,
sourceType,
"node",
schemeName || "",
schemeType || ""
);
if (nodeCacheRef.current.has(nodeCacheKey)) { if (nodeCacheRef.current.has(nodeCacheKey)) {
nodeRecords = nodeCacheRef.current.get(nodeCacheKey)!; nodeRecords = nodeCacheRef.current.get(nodeCacheKey)!;
} else { } else {
disableDateSelection && schemeName nodePromise =
? (nodePromise = apiFetch( sourceType === "scheme" && schemeName
// `${config.BACKEND_URL}/queryallschemerecordsbytimeproperty/?querytime=${query_time}&type=node&property=${junctionProperties}&schemename=${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}`, `${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( : 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}`
`${config.BACKEND_URL}/api/v1/realtime/query/by-time-property?query_time=${query_time}&type=node&property=${junctionProperties}`, );
));
requests.push(nodePromise); requests.push(nodePromise);
} }
} }
// 处理特殊属性名称
if (pipeProperties === "unit_headloss") pipeProperties = "headloss";
// 检查link缓存 const normalizedPipeProperties =
if (pipeProperties !== "" && pipeProperties !== "diameter") { pipeProperties === "unit_headloss" ? "headloss" : pipeProperties;
const linkCacheKey = `${query_time}_${pipeProperties}_${schemeName}_${schemeType}`;
if (normalizedPipeProperties !== "" && normalizedPipeProperties !== "diameter") {
const linkCacheKey = buildCacheKey(
query_time,
normalizedPipeProperties,
sourceType,
"link",
schemeName || "",
schemeType || ""
);
if (linkCacheRef.current.has(linkCacheKey)) { if (linkCacheRef.current.has(linkCacheKey)) {
linkRecords = linkCacheRef.current.get(linkCacheKey)!; linkRecords = linkCacheRef.current.get(linkCacheKey)!;
} else { } else {
disableDateSelection && schemeName linkPromise =
? (linkPromise = apiFetch( sourceType === "scheme" && schemeName
// `${config.BACKEND_URL}/queryallschemerecordsbytimeproperty/?querytime=${query_time}&type=link&property=${pipeProperties}&schemename=${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=${pipeProperties}`, `${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}`
)) )
: (linkPromise = apiFetch( : 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=${normalizedPipeProperties}`
`${config.BACKEND_URL}/api/v1/realtime/query/by-time-property?query_time=${query_time}&type=link&property=${pipeProperties}`, );
));
requests.push(linkPromise); requests.push(linkPromise);
} }
} }
// 等待所有有效请求
const responses = await Promise.all(requests); const responses = await Promise.all(requests);
if (nodePromise) { if (nodePromise) {
const nodeResponse = responses.shift()!; const nodeResponse = responses.shift()!;
if (!nodeResponse.ok) if (!nodeResponse.ok) {
throw new Error(`Node fetch failed: ${nodeResponse.status}`); throw new Error(`Node fetch failed: ${nodeResponse.status}`);
}
nodeRecords = await nodeResponse.json(); nodeRecords = await nodeResponse.json();
// 缓存数据(修复键以包含 schemeName
nodeCacheRef.current.set( nodeCacheRef.current.set(
`${query_time}_${junctionProperties}_${schemeName}_${schemeType}`, buildCacheKey(
nodeRecords || [], query_time,
junctionProperties,
sourceType,
"node",
schemeName || "",
schemeType || ""
),
nodeRecords || []
); );
} }
if (linkPromise) { if (linkPromise) {
const linkResponse = responses.shift()!; const linkResponse = responses.shift()!;
if (!linkResponse.ok) if (!linkResponse.ok) {
throw new Error(`Link fetch failed: ${linkResponse.status}`); throw new Error(`Link fetch failed: ${linkResponse.status}`);
}
linkRecords = await linkResponse.json(); linkRecords = await linkResponse.json();
// 缓存数据(修复键以包含 schemeName
linkCacheRef.current.set( linkCacheRef.current.set(
`${query_time}_${pipeProperties}_${schemeName}_${schemeType}`, buildCacheKey(
linkRecords || [], query_time,
normalizedPipeProperties,
sourceType,
"link",
schemeName || "",
schemeType || ""
),
linkRecords || []
); );
} }
// 更新状态
updateDataStates(nodeRecords.results || [], linkRecords.results || []); updateDataStates(nodeRecords.results || [], linkRecords.results || [], target);
}, [disableDateSelection, updateDataStates]); },
[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分钟一个刻度) // 时间刻度数组 (每5分钟一个刻度)
const timeMarks = Array.from({ length: 288 }, (_, i) => ({ const timeMarks = Array.from({ length: 288 }, (_, i) => ({
@@ -453,9 +565,9 @@ const Timeline: React.FC<TimelineProps> = ({
if (!cacheRef.current) return; if (!cacheRef.current) return;
const cacheKeys = Array.from(cacheRef.current.keys()); const cacheKeys = Array.from(cacheRef.current.keys());
cacheKeys.forEach((key) => { cacheKeys.forEach((key) => {
const keyParts = key.split("_"); const cacheTimeKey = key.split("::")[0];
const cacheDate = keyParts[0].split("T")[0]; const cacheDate = cacheTimeKey.split("T")[0];
const cacheTimeStr = keyParts[0].split("T")[1]; const cacheTimeStr = cacheTimeKey.split("T")[1];
if (cacheDate === dateStr && cacheTimeStr) { if (cacheDate === dateStr && cacheTimeStr) {
const [hours, minutes] = cacheTimeStr.split(":"); const [hours, minutes] = cacheTimeStr.split(":");
@@ -5,6 +5,7 @@ import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined";
import EditOutlinedIcon from "@mui/icons-material/EditOutlined"; import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
import PaletteOutlinedIcon from "@mui/icons-material/PaletteOutlined"; import PaletteOutlinedIcon from "@mui/icons-material/PaletteOutlined";
import QueryStatsOutlinedIcon from "@mui/icons-material/QueryStatsOutlined"; import QueryStatsOutlinedIcon from "@mui/icons-material/QueryStatsOutlined";
import CompareArrowsOutlinedIcon from "@mui/icons-material/CompareArrowsOutlined";
import PropertyPanel from "./PropertyPanel"; // 引入属性面板组件 import PropertyPanel from "./PropertyPanel"; // 引入属性面板组件
import DrawPanel from "./DrawPanel"; // 引入绘图面板组件 import DrawPanel from "./DrawPanel"; // 引入绘图面板组件
import HistoryDataPanel from "./HistoryDataPanel"; // 引入绘图面板组件 import HistoryDataPanel from "./HistoryDataPanel"; // 引入绘图面板组件
@@ -34,12 +35,14 @@ interface ToolbarProps {
queryType?: string; // 可选的查询类型参数 queryType?: string; // 可选的查询类型参数
schemeType?: string; // 可选的方案类型参数 schemeType?: string; // 可选的方案类型参数
HistoryPanel?: React.FC<any>; // 可选的自定义历史数据面板 HistoryPanel?: React.FC<any>; // 可选的自定义历史数据面板
enableCompare?: boolean;
} }
const Toolbar: React.FC<ToolbarProps> = ({ const Toolbar: React.FC<ToolbarProps> = ({
hiddenButtons, hiddenButtons,
queryType, queryType,
schemeType, schemeType,
HistoryPanel, HistoryPanel,
enableCompare = false,
}) => { }) => {
const map = useMap(); const map = useMap();
const data = useData(); const data = useData();
@@ -55,6 +58,17 @@ const Toolbar: React.FC<ToolbarProps> = ({
const currentTime = data?.currentTime; const currentTime = data?.currentTime;
const selectedDate = data?.selectedDate; const selectedDate = data?.selectedDate;
const schemeName = data?.schemeName; 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) // Chat tool action → direct featureInfos override (bypasses OL Feature lookup)
const [chatPanelFeatureInfos, setChatPanelFeatureInfos] = useState< const [chatPanelFeatureInfos, setChatPanelFeatureInfos] = useState<
@@ -853,6 +867,15 @@ const Toolbar: React.FC<ToolbarProps> = ({
onClick={() => handleToolClick("style")} onClick={() => handleToolClick("style")}
/> />
)} )}
{enableCompare && (
<ToolbarButton
icon={<CompareArrowsOutlinedIcon />}
name={isCompareMode ? "关闭对比" : "双屏对比"}
isActive={isCompareMode}
onClick={() => toggleCompareMode?.()}
disabled={!canToggleCompare}
/>
)}
</div> </div>
{showPropertyPanel && <PropertyPanel {...getFeatureProperties()} />} {showPropertyPanel && <PropertyPanel {...getFeatureProperties()} />}
{showDrawPanel && map && <DrawPanel />} {showDrawPanel && map && <DrawPanel />}
+514 -80
View File
@@ -7,6 +7,7 @@ import React, {
useState, useState,
useEffect, useEffect,
useMemo, useMemo,
useCallback,
useRef, useRef,
} from "react"; } from "react";
import { Map as OlMap, VectorTile } from "ol"; import { Map as OlMap, VectorTile } from "ol";
@@ -49,6 +50,13 @@ interface DataContextType {
setCurrentJunctionCalData?: React.Dispatch<React.SetStateAction<any[]>>; setCurrentJunctionCalData?: React.Dispatch<React.SetStateAction<any[]>>;
currentPipeCalData?: any[]; // 当前计算结果 currentPipeCalData?: any[]; // 当前计算结果
setCurrentPipeCalData?: React.Dispatch<React.SetStateAction<any[]>>; setCurrentPipeCalData?: React.Dispatch<React.SetStateAction<any[]>>;
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; // 是否显示节点文本 showJunctionText?: boolean; // 是否显示节点文本
showPipeText?: boolean; // 是否显示管道文本 showPipeText?: boolean; // 是否显示管道文本
showJunctionId?: boolean; // 是否显示节点ID showJunctionId?: boolean; // 是否显示节点ID
@@ -69,6 +77,10 @@ interface DataContextType {
setPipeText?: React.Dispatch<React.SetStateAction<string>>; setPipeText?: React.Dispatch<React.SetStateAction<string>>;
setContours?: React.Dispatch<React.SetStateAction<any[]>>; setContours?: React.Dispatch<React.SetStateAction<any[]>>;
deckLayer?: DeckLayer; deckLayer?: DeckLayer;
compareDeckLayer?: DeckLayer;
deckLayers?: DeckLayer[];
compareMap?: OlMap;
maps?: OlMap[];
diameterRange?: [number, number]; diameterRange?: [number, number];
elevationRange?: [number, number]; elevationRange?: [number, number];
} }
@@ -128,12 +140,18 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
const mapRef = useRef<HTMLDivElement | null>(null); const mapRef = useRef<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | 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 deckLayerRef = useRef<DeckLayer | null>(null);
const compareDeckLayerRef = useRef<DeckLayer | null>(null);
const isDisposingRef = useRef(false); const isDisposingRef = useRef(false);
const isCompareDisposingRef = useRef(false);
const pendingTimeoutsRef = useRef<number[]>([]); const pendingTimeoutsRef = useRef<number[]>([]);
const [map, setMap] = useState<OlMap>(); const [map, setMap] = useState<OlMap>();
const [deckLayer, setDeckLayer] = useState<DeckLayer>(); const [deckLayer, setDeckLayer] = useState<DeckLayer>();
const [compareMap, setCompareMap] = useState<OlMap>();
const [compareDeckLayer, setCompareDeckLayer] = useState<DeckLayer>();
// currentCalData 用于存储当前计算结果 // currentCalData 用于存储当前计算结果
const [currentTime, setCurrentTime] = useState<number>(-1); // 默认选择当前时间 const [currentTime, setCurrentTime] = useState<number>(-1); // 默认选择当前时间
// const [selectedDate, setSelectedDate] = useState<Date>(new Date("2025-9-17")); // 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 [currentPipeCalData, setCurrentPipeCalData] = useState<any[]>([]);
const [compareJunctionCalData, setCompareJunctionCalData] = useState<any[]>(
[],
);
const [comparePipeCalData, setComparePipeCalData] = useState<any[]>([]);
const [isCompareMode, setCompareMode] = useState(false);
// junctionData 和 pipeData 分别缓存瓦片解析后节点和管道的数据,用于 deck.gl 定位、标签渲染 // junctionData 和 pipeData 分别缓存瓦片解析后节点和管道的数据,用于 deck.gl 定位、标签渲染
// currentJunctionCalData 和 currentPipeCalData 变化时会新增并更新 junctionData 和 pipeData 的计算属性值 // currentJunctionCalData 和 currentPipeCalData 变化时会新增并更新 junctionData 和 pipeData 的计算属性值
const [junctionData, setJunctionDataState] = useState<any[]>([]); const [junctionData, setJunctionDataState] = useState<any[]>([]);
@@ -201,6 +224,37 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
}); });
}, [pipeData, currentPipeCalData, pipeText]); }, [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< const [diameterRange, setDiameterRange] = useState<
[number, number] | undefined [number, number] | undefined
>(); >();
@@ -208,6 +262,24 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
[number, number] | undefined [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 setJunctionData = (newData: any[]) => {
const uniqueNewData = newData.filter((item) => { const uniqueNewData = newData.filter((item) => {
if (!item || !item.id) return false; 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. // The map and layer instances are intentionally rebuilt only when workspace or extent changes.
useEffect(() => { useEffect(() => {
if (!mapRef.current) return; if (!mapRef.current) return;
@@ -857,19 +1101,142 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [MAP_WORKSPACE, MAP_EXTENT]); }, [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 图层 // 当数据变化时,更新 deck.gl 图层
useEffect(() => { useEffect(() => {
if (isDisposingRef.current) return; const syncDeckOverlay = (
const deckLayer = deckLayerRef.current; targetDeckLayer: DeckLayer | null,
if (!deckLayer) return; // 如果 deck 实例还未创建,则退出 targetJunctionData: any[],
if (deckLayer.isDisposedLayer()) return; targetPipeData: any[],
if (!mergedJunctionData.length) return; disposing: boolean,
if (!mergedPipeData.length) return; ) => {
const junctionTextLayer = new TextLayer({ if (disposing || !targetDeckLayer || targetDeckLayer.isDisposedLayer()) {
return;
}
const shouldShowJunctionText =
(showJunctionTextLayer || showJunctionId) &&
currentZoom >= 15 &&
currentZoom <= 24 &&
targetJunctionData.length > 0;
const shouldShowPipeText =
(showPipeTextLayer || showPipeId) &&
currentZoom >= 15 &&
currentZoom <= 24 &&
targetPipeData.length > 0;
const shouldShowContour =
showContourLayer &&
currentZoom >= 11 &&
currentZoom <= 24 &&
targetJunctionData.length > 0;
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", id: "junctionTextLayer",
name: "节点文字", name: "节点文字",
zIndex: 10, zIndex: 10,
data: mergedJunctionData, data: targetJunctionData,
getPosition: (d: any) => d.position, getPosition: (d: any) => d.position,
fontFamily: "Monaco, monospace", fontFamily: "Monaco, monospace",
getText: (d: any) => { getText: (d: any) => {
@@ -884,15 +1251,12 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
}, },
getSize: 14, getSize: 14,
fontWeight: "bold", fontWeight: "bold",
getColor: [33, 37, 41], // 深灰色,在灰白背景上清晰可见 getColor: [33, 37, 41],
getAngle: 0, getAngle: 0,
getTextAnchor: "middle", getTextAnchor: "middle",
getAlignmentBaseline: "center", getAlignmentBaseline: "center",
getPixelOffset: [0, -10], getPixelOffset: [0, -10],
visible: visible: true,
(showJunctionTextLayer || showJunctionId) &&
currentZoom >= 15 &&
currentZoom <= 24,
updateTriggers: { updateTriggers: {
getText: [showJunctionId, showJunctionTextLayer, junctionText], getText: [showJunctionId, showJunctionTextLayer, junctionText],
}, },
@@ -906,15 +1270,15 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
fontSize: 64, fontSize: 64,
buffer: 6, buffer: 6,
}, },
// outlineWidth: 3, })
// outlineColor: [255, 255, 255, 220], : null;
});
const pipeTextLayer = new TextLayer({ const pipeTextLayer = shouldShowPipeText
? new TextLayer({
id: "pipeTextLayer", id: "pipeTextLayer",
name: "管道文字", name: "管道文字",
zIndex: 10, zIndex: 10,
data: mergedPipeData, data: targetPipeData,
getPosition: (d: any) => d.position, getPosition: (d: any) => d.position,
fontFamily: "Monaco, monospace", fontFamily: "Monaco, monospace",
getText: (d: any) => { getText: (d: any) => {
@@ -936,15 +1300,12 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
}, },
getSize: 14, getSize: 14,
fontWeight: "bold", fontWeight: "bold",
getColor: [33, 37, 41], // 深灰色 getColor: [33, 37, 41],
getAngle: (d: any) => d.angle || 0, getAngle: (d: any) => d.angle || 0,
getPixelOffset: [0, -8], getPixelOffset: [0, -8],
getTextAnchor: "middle", getTextAnchor: "middle",
getAlignmentBaseline: "bottom", getAlignmentBaseline: "bottom",
visible: visible: true,
(showPipeTextLayer || showPipeId) &&
currentZoom >= 15 &&
currentZoom <= 24,
updateTriggers: { updateTriggers: {
getText: [showPipeId, showPipeTextLayer, pipeText], getText: [showPipeId, showPipeTextLayer, pipeText],
}, },
@@ -958,14 +1319,14 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
fontSize: 64, fontSize: 64,
buffer: 6, buffer: 6,
}, },
// outlineWidth: 3, })
// outlineColor: [255, 255, 255, 220], : null;
});
const contourLayer = new ContourLayer({ const contourLayer = shouldShowContour
? new ContourLayer({
id: "junctionContourLayer", id: "junctionContourLayer",
name: "等值线", name: "等值线",
data: mergedJunctionData, data: targetJunctionData,
aggregation: "MEAN", aggregation: "MEAN",
cellSize: 600, cellSize: 600,
strokeWidth: 0, strokeWidth: 0,
@@ -974,31 +1335,50 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
getWeight: (d: any) => getWeight: (d: any) =>
(d[junctionText] as number) < 0 ? 0 : (d[junctionText] as number), (d[junctionText] as number) < 0 ? 0 : (d[junctionText] as number),
opacity: 1, opacity: 1,
visible: showContourLayer && currentZoom >= 11 && currentZoom <= 24, visible: true,
updateTriggers: { updateTriggers: {
// 当 mergedJunctionData 内部数据更新时,通知 getWeight 重新计算 getWeight: [targetJunctionData, junctionText],
getWeight: [mergedJunctionData, junctionText],
}, },
}); })
if (deckLayer.getDeckLayerById("junctionTextLayer")) { : null;
// 传入完整 layer 实例以保证 clone/替换时保留 layer 类型和方法
deckLayer.updateDeckLayer("junctionTextLayer", junctionTextLayer); if (junctionTextLayer && targetDeckLayer.getDeckLayerById("junctionTextLayer")) {
} else { targetDeckLayer.updateDeckLayer("junctionTextLayer", junctionTextLayer);
deckLayer.addDeckLayer(junctionTextLayer); } else if (junctionTextLayer) {
targetDeckLayer.addDeckLayer(junctionTextLayer);
} }
if (deckLayer.getDeckLayerById("pipeTextLayer")) { if (pipeTextLayer && targetDeckLayer.getDeckLayerById("pipeTextLayer")) {
deckLayer.updateDeckLayer("pipeTextLayer", pipeTextLayer); targetDeckLayer.updateDeckLayer("pipeTextLayer", pipeTextLayer);
} else { } else if (pipeTextLayer) {
deckLayer.addDeckLayer(pipeTextLayer); targetDeckLayer.addDeckLayer(pipeTextLayer);
} }
if (deckLayer.getDeckLayerById("junctionContourLayer")) { if (contourLayer && targetDeckLayer.getDeckLayerById("junctionContourLayer")) {
deckLayer.updateDeckLayer("junctionContourLayer", contourLayer); targetDeckLayer.updateDeckLayer("junctionContourLayer", contourLayer);
} else { } else if (contourLayer) {
deckLayer.addDeckLayer(contourLayer); targetDeckLayer.addDeckLayer(contourLayer);
}
};
syncDeckOverlay(
deckLayerRef.current,
mergedJunctionData,
mergedPipeData,
isDisposingRef.current,
);
if (isCompareMode) {
syncDeckOverlay(
compareDeckLayerRef.current,
mergedCompareJunctionData,
mergedComparePipeData,
isCompareDisposingRef.current,
);
} }
}, [ }, [
mergedJunctionData, mergedJunctionData,
mergedPipeData, mergedPipeData,
mergedCompareJunctionData,
mergedComparePipeData,
isCompareMode,
junctionText, junctionText,
pipeText, pipeText,
currentZoom, currentZoom,
@@ -1012,57 +1392,69 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
// 控制流动动画开关 // 控制流动动画开关
useEffect(() => { useEffect(() => {
if (isDisposingRef.current) return; flowAnimation.current = pipeText === "flow" && currentPipeCalData.length > 0;
if (pipeText === "flow" && currentPipeCalData.length > 0) { const shouldShowWaterflow =
flowAnimation.current = true; isWaterflowLayerAvailable &&
} else { showWaterflowLayer &&
flowAnimation.current = false; flowAnimation.current &&
currentZoom >= 12 &&
currentZoom <= 24;
let animationFrameId: number;
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 deckLayer = deckLayerRef.current;
if (!deckLayer) return; // 如果 deck 实例还未创建,则退出
let animationFrameId: number; // 保存 requestAnimationFrame 的 ID
// 动画循环
const animate = () => {
if (isDisposingRef.current || deckLayer.isDisposedLayer()) return;
// 动画总时长(秒)
const animationDuration = 10; const animationDuration = 10;
const bufferTime = 2; const bufferTime = 2;
const loopLength = animationDuration + bufferTime; const loopLength = animationDuration + bufferTime;
const currentTime = (Date.now() / 1000) % loopLength; const currentFrameTime = (Date.now() / 1000) % loopLength;
const waterflowLayer = new TripsLayer({ const waterflowLayer = new TripsLayer({
id: "waterflowLayer", id: "waterflowLayer",
name: "水流", name: "水流",
data: mergedPipeData, data: targetPipeData,
getPath: (d) => d.path, getPath: (d) => d.path,
getTimestamps: (d) => { getTimestamps: (d) => d.timestamps,
return d.timestamps; // 这些应该是与 currentTime 匹配的数值
},
getColor: [0, 220, 255], getColor: [0, 220, 255],
opacity: 0.8, opacity: 0.8,
visible: visible: true,
isWaterflowLayerAvailable &&
showWaterflowLayer &&
flowAnimation.current && // 保持动画标志作为可见性的一部分
currentZoom >= 12 &&
currentZoom <= 24,
widthMinPixels: 5, widthMinPixels: 5,
jointRounded: true, // 拐角变圆 jointRounded: true,
// capRounded: true, // 端点变圆 trailLength: 2,
trailLength: 2, // 水流尾迹淡出时间 currentTime: currentFrameTime,
currentTime: currentTime,
}); });
if (deckLayer.getDeckLayerById("waterflowLayer")) { if (targetDeckLayer.getDeckLayerById("waterflowLayer")) {
deckLayer.updateDeckLayer("waterflowLayer", waterflowLayer); targetDeckLayer.updateDeckLayer("waterflowLayer", waterflowLayer);
} else { } else {
deckLayer.addDeckLayer(waterflowLayer); targetDeckLayer.addDeckLayer(waterflowLayer);
} }
};
// 只有在需要动画时才请求下一帧,但图层已经添加到了 deckLayer 中 const animate = () => {
if (flowAnimation.current) { syncWaterflowLayer(
deckLayerRef.current,
mergedPipeData,
isDisposingRef.current,
);
if (isCompareMode) {
syncWaterflowLayer(
compareDeckLayerRef.current,
mergedComparePipeData,
isCompareDisposingRef.current,
);
}
if (shouldShowWaterflow) {
animationFrameId = requestAnimationFrame(animate); animationFrameId = requestAnimationFrame(animate);
} }
}; };
@@ -1078,6 +1470,8 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
currentPipeCalData, currentPipeCalData,
currentZoom, currentZoom,
mergedPipeData, mergedPipeData,
mergedComparePipeData,
isCompareMode,
pipeText, pipeText,
isWaterflowLayerAvailable, isWaterflowLayerAvailable,
showWaterflowLayer, showWaterflowLayer,
@@ -1097,6 +1491,13 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
setCurrentJunctionCalData, setCurrentJunctionCalData,
currentPipeCalData, currentPipeCalData,
setCurrentPipeCalData, setCurrentPipeCalData,
compareJunctionCalData,
setCompareJunctionCalData,
comparePipeCalData,
setComparePipeCalData,
isCompareMode,
setCompareMode,
toggleCompareMode,
setShowJunctionTextLayer, setShowJunctionTextLayer,
setShowPipeTextLayer, setShowPipeTextLayer,
setShowJunctionId, setShowJunctionId,
@@ -1115,17 +1516,50 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
pipeText, pipeText,
setContours, setContours,
deckLayer, deckLayer,
compareDeckLayer,
deckLayers,
compareMap,
maps,
diameterRange, diameterRange,
elevationRange, elevationRange,
}} }}
> >
<MapContext.Provider value={map}> <MapContext.Provider value={map}>
<div className="relative w-full h-full"> <div className="relative w-full h-full">
<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> <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 /> <MapTools />
{children} {children}
</div> </div>
<canvas ref={canvasRef} />
</MapContext.Provider> </MapContext.Provider>
</DataContext.Provider> </DataContext.Provider>
</> </>