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

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
+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 />}