1194 lines
40 KiB
TypeScript
1194 lines
40 KiB
TypeScript
"use client";
|
|
import { config } from "@/config/config";
|
|
import { useProject } from "@/contexts/ProjectContext";
|
|
import React, {
|
|
createContext,
|
|
useContext,
|
|
useState,
|
|
useEffect,
|
|
useMemo,
|
|
useCallback,
|
|
useRef,
|
|
} from "react";
|
|
import { Map as OlMap, VectorTile } from "ol";
|
|
import View from "ol/View.js";
|
|
import "ol/ol.css";
|
|
import MapTools from "./MapTools";
|
|
|
|
// 导入 DeckLayer
|
|
import { DeckLayer } from "@utils/layers";
|
|
import { toLonLat } from "ol/proj";
|
|
import { along, bearing, lineString, length } from "@turf/turf";
|
|
import { Deck } from "@deck.gl/core";
|
|
import { TextLayer } from "@deck.gl/layers";
|
|
import { TripsLayer } from "@deck.gl/geo-layers";
|
|
import { CollisionFilterExtension } from "@deck.gl/extensions";
|
|
import { ContourLayer } from "deck.gl";
|
|
import { toM3h } from "@utils/units";
|
|
import { usePathname } from "next/navigation";
|
|
import {
|
|
cleanupTransientMapResources,
|
|
disposeMapResources,
|
|
markMapResourcePersistent,
|
|
} from "./mapLifecycle";
|
|
import { createOperationalMapResources } from "./operationalLayers";
|
|
import { getRoundedCurrentTimelineMinutes } from "./Controls/timelineTime";
|
|
|
|
interface MapComponentProps {
|
|
children?: React.ReactNode;
|
|
}
|
|
interface DataContextType {
|
|
currentTime?: number; // 当前时间
|
|
setCurrentTime?: React.Dispatch<React.SetStateAction<number>>;
|
|
selectedDate?: Date; // 选择的日期
|
|
schemeName?: string; // 当前方案名称
|
|
setSchemeName?: React.Dispatch<React.SetStateAction<string>>;
|
|
setSelectedDate?: React.Dispatch<React.SetStateAction<Date>>;
|
|
currentJunctionCalData?: any[]; // 当前计算结果
|
|
setCurrentJunctionCalData?: React.Dispatch<React.SetStateAction<any[]>>;
|
|
currentPipeCalData?: any[]; // 当前计算结果
|
|
setCurrentPipeCalData?: React.Dispatch<React.SetStateAction<any[]>>;
|
|
compareJunctionCalData?: any[];
|
|
setCompareJunctionCalData?: React.Dispatch<React.SetStateAction<any[]>>;
|
|
comparePipeCalData?: any[];
|
|
setComparePipeCalData?: React.Dispatch<React.SetStateAction<any[]>>;
|
|
isCompareMode?: boolean;
|
|
setCompareMode?: React.Dispatch<React.SetStateAction<boolean>>;
|
|
toggleCompareMode?: () => void;
|
|
showJunctionText?: boolean; // 是否显示节点文本
|
|
showPipeText?: boolean; // 是否显示管道文本
|
|
showJunctionId?: boolean; // 是否显示节点ID
|
|
showPipeId?: boolean; // 是否显示管道ID
|
|
setShowJunctionTextLayer?: React.Dispatch<React.SetStateAction<boolean>>;
|
|
setShowPipeTextLayer?: React.Dispatch<React.SetStateAction<boolean>>;
|
|
setShowJunctionId?: React.Dispatch<React.SetStateAction<boolean>>;
|
|
setShowPipeId?: React.Dispatch<React.SetStateAction<boolean>>;
|
|
showContourLayer?: boolean;
|
|
setShowContourLayer?: React.Dispatch<React.SetStateAction<boolean>>;
|
|
isContourLayerAvailable?: boolean;
|
|
showWaterflowLayer?: boolean;
|
|
setShowWaterflowLayer?: React.Dispatch<React.SetStateAction<boolean>>;
|
|
setContourLayerAvailable?: React.Dispatch<React.SetStateAction<boolean>>;
|
|
isWaterflowLayerAvailable?: boolean;
|
|
setWaterflowLayerAvailable?: React.Dispatch<React.SetStateAction<boolean>>;
|
|
junctionText: string;
|
|
pipeText: string;
|
|
setJunctionText?: React.Dispatch<React.SetStateAction<string>>;
|
|
setPipeText?: React.Dispatch<React.SetStateAction<string>>;
|
|
setContours?: React.Dispatch<React.SetStateAction<any[]>>;
|
|
deckLayer?: DeckLayer;
|
|
compareDeckLayer?: DeckLayer;
|
|
deckLayers?: DeckLayer[];
|
|
compareMap?: OlMap;
|
|
maps?: OlMap[];
|
|
diameterRange?: [number, number];
|
|
elevationRange?: [number, number];
|
|
forceStyleAutoApplyVersion?: number;
|
|
setForceStyleAutoApplyVersion?: React.Dispatch<React.SetStateAction<number>>;
|
|
}
|
|
|
|
// 跨组件传递
|
|
const MapContext = createContext<OlMap | undefined>(undefined);
|
|
const DataContext = createContext<DataContextType | undefined>(undefined);
|
|
|
|
// 添加防抖函数
|
|
type DebouncedFunction<F extends (...args: any[]) => any> = ((
|
|
...args: Parameters<F>
|
|
) => void) & {
|
|
cancel: () => void;
|
|
};
|
|
|
|
function debounce<F extends (...args: any[]) => any>(
|
|
func: F,
|
|
waitFor: number
|
|
): DebouncedFunction<F> {
|
|
let timeout: ReturnType<typeof setTimeout> | null = null;
|
|
|
|
const debounced = (...args: Parameters<F>): void => {
|
|
if (timeout !== null) {
|
|
clearTimeout(timeout);
|
|
}
|
|
timeout = setTimeout(() => func(...args), waitFor);
|
|
};
|
|
|
|
debounced.cancel = () => {
|
|
if (timeout !== null) {
|
|
clearTimeout(timeout);
|
|
timeout = null;
|
|
}
|
|
};
|
|
|
|
return debounced;
|
|
}
|
|
|
|
export const useMap = () => {
|
|
return useContext(MapContext);
|
|
};
|
|
export const useData = () => {
|
|
return useContext(DataContext);
|
|
};
|
|
|
|
const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
|
|
const pathname = usePathname();
|
|
const project = useProject();
|
|
const MAP_WORKSPACE = project?.workspace || config.MAP_WORKSPACE;
|
|
const MAP_EXTENT = (project?.extent || config.MAP_EXTENT) as [
|
|
number,
|
|
number,
|
|
number,
|
|
number,
|
|
];
|
|
const MAP_URL = config.MAP_URL;
|
|
const MAP_VIEW_STORAGE_KEY = `${MAP_WORKSPACE}_map_view`; // 持久化 key
|
|
|
|
const mapRef = useRef<HTMLDivElement | null>(null);
|
|
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
|
const compareMapRef = useRef<HTMLDivElement | null>(null);
|
|
const compareCanvasRef = useRef<HTMLCanvasElement | null>(null);
|
|
const deckLayerRef = useRef<DeckLayer | null>(null);
|
|
const compareDeckLayerRef = useRef<DeckLayer | null>(null);
|
|
const isDisposingRef = useRef(false);
|
|
const isCompareDisposingRef = useRef(false);
|
|
const pendingTimeoutsRef = useRef<number[]>([]);
|
|
|
|
const [map, setMap] = useState<OlMap>();
|
|
const [deckLayer, setDeckLayer] = useState<DeckLayer>();
|
|
const [compareMap, setCompareMap] = useState<OlMap>();
|
|
const [compareDeckLayer, setCompareDeckLayer] = useState<DeckLayer>();
|
|
// currentCalData 用于存储当前计算结果
|
|
const [currentTime, setCurrentTime] = useState<number>(
|
|
getRoundedCurrentTimelineMinutes,
|
|
); // 默认选择当前时间
|
|
// const [selectedDate, setSelectedDate] = useState<Date>(new Date("2025-9-17"));
|
|
const [selectedDate, setSelectedDate] = useState<Date>(new Date()); // 默认今天
|
|
const [schemeName, setSchemeName] = useState<string>(""); // 当前方案名称
|
|
// 记录 id、对应属性的计算值
|
|
const [currentJunctionCalData, setCurrentJunctionCalData] = 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 定位、标签渲染
|
|
// currentJunctionCalData 和 currentPipeCalData 变化时会新增并更新 junctionData 和 pipeData 的计算属性值
|
|
const [junctionData, setJunctionDataState] = useState<any[]>([]);
|
|
const [pipeData, setPipeDataState] = useState<any[]>([]);
|
|
const junctionDataIds = useRef(new Set<string>());
|
|
const pipeDataIds = useRef(new Set<string>());
|
|
const tileJunctionDataBuffer = useRef<any[]>([]);
|
|
const tilePipeDataBuffer = useRef<any[]>([]);
|
|
|
|
const [showJunctionTextLayer, setShowJunctionTextLayer] = useState(false); // 控制节点文本图层显示
|
|
const [showPipeTextLayer, setShowPipeTextLayer] = useState(false); // 控制管道文本图层显示
|
|
const [showJunctionId, setShowJunctionId] = useState(false); // 控制节点ID显示
|
|
const [showPipeId, setShowPipeId] = useState(false); // 控制管道ID显示
|
|
const [showContourLayer, setShowContourLayer] = useState(false); // 控制等高线图层显示
|
|
const [junctionText, setJunctionText] = useState("pressure");
|
|
const [pipeText, setPipeText] = useState("velocity");
|
|
const [contours, setContours] = useState<any[]>([]);
|
|
const flowAnimation = useRef(false); // 添加动画控制标志
|
|
const [isContourLayerAvailable, setContourLayerAvailable] = useState(false); // 控制等高线图层显示
|
|
const [isWaterflowLayerAvailable, setWaterflowLayerAvailable] =
|
|
useState(false); // 控制等高线图层显示
|
|
const [showWaterflowLayer, setShowWaterflowLayer] = useState(false); // 控制等高线图层显示
|
|
const [currentZoom, setCurrentZoom] = useState(11); // 当前缩放级别
|
|
|
|
// 实时合并计算结果到基础地理数据中
|
|
const mergedJunctionData = useMemo(() => {
|
|
const nodeMap = new Map(currentJunctionCalData.map((r: any) => [r.ID, r]));
|
|
return junctionData.map((j) => {
|
|
const record = nodeMap.get(j.id);
|
|
let val = record ? record.value : undefined;
|
|
// 在这合并时将实际需水量从 LPS 转换为大写表示
|
|
if (val !== undefined && junctionText === "actualdemand") {
|
|
val = toM3h(val, "lps");
|
|
}
|
|
return record ? { ...j, [junctionText]: val } : j;
|
|
});
|
|
}, [junctionData, currentJunctionCalData, junctionText]);
|
|
|
|
const mergedPipeData = useMemo(() => {
|
|
const linkMap = new Map(currentPipeCalData.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, currentPipeCalData, pipeText]);
|
|
|
|
const mergedCompareJunctionData = useMemo(() => {
|
|
const nodeMap = new Map(compareJunctionCalData.map((r: any) => [r.ID, r]));
|
|
return junctionData.map((j) => {
|
|
const record = nodeMap.get(j.id);
|
|
let val = record ? record.value : undefined;
|
|
if (val !== undefined && junctionText === "actualdemand") {
|
|
val = toM3h(val, "lps");
|
|
}
|
|
return record ? { ...j, [junctionText]: val } : j;
|
|
});
|
|
}, [junctionData, compareJunctionCalData, junctionText]);
|
|
|
|
const mergedComparePipeData = useMemo(() => {
|
|
const linkMap = new Map(comparePipeCalData.map((r: any) => [r.ID, r]));
|
|
return pipeData.map((p) => {
|
|
const record = linkMap.get(p.id);
|
|
if (!record) return p;
|
|
const isFlow = pipeText === "flow";
|
|
let val = record.value;
|
|
if (val !== undefined && isFlow) {
|
|
val = toM3h(val, "lps");
|
|
}
|
|
return {
|
|
...p,
|
|
[pipeText]: isFlow ? Math.abs(val) : val,
|
|
flowFlag: isFlow && record.value < 0 ? -1 : 1,
|
|
path: isFlow && record.value < 0 ? [...p.path].reverse() : p.path,
|
|
};
|
|
});
|
|
}, [pipeData, comparePipeCalData, pipeText]);
|
|
|
|
const [diameterRange, setDiameterRange] = useState<
|
|
[number, number] | undefined
|
|
>();
|
|
const [elevationRange, setElevationRange] = useState<
|
|
[number, number] | undefined
|
|
>();
|
|
const [forceStyleAutoApplyVersion, setForceStyleAutoApplyVersion] =
|
|
useState(0);
|
|
|
|
const toggleCompareMode = useCallback(() => {
|
|
setCompareMode((prev) => !prev);
|
|
}, []);
|
|
|
|
const maps = useMemo(
|
|
() =>
|
|
[map, isCompareMode ? compareMap : undefined].filter(Boolean) as OlMap[],
|
|
[compareMap, isCompareMode, map],
|
|
);
|
|
|
|
const deckLayers = useMemo(
|
|
() =>
|
|
[deckLayer, isCompareMode ? compareDeckLayer : undefined].filter(
|
|
Boolean,
|
|
) as DeckLayer[],
|
|
[compareDeckLayer, deckLayer, isCompareMode],
|
|
);
|
|
|
|
const setJunctionData = (newData: any[]) => {
|
|
const uniqueNewData = newData.filter((item) => {
|
|
if (!item || !item.id) return false;
|
|
if (!junctionDataIds.current.has(item.id)) {
|
|
junctionDataIds.current.add(item.id);
|
|
return true;
|
|
}
|
|
return false;
|
|
});
|
|
if (uniqueNewData.length > 0) {
|
|
setJunctionDataState((prev) => prev.concat(uniqueNewData));
|
|
setElevationRange((prev) => {
|
|
const elevations = uniqueNewData
|
|
.map((d) => d.elevation)
|
|
.filter((v) => typeof v === "number");
|
|
if (elevations.length === 0) return prev;
|
|
|
|
let newMin = elevations[0];
|
|
let newMax = elevations[0];
|
|
for (let i = 1; i < elevations.length; i++) {
|
|
if (elevations[i] < newMin) newMin = elevations[i];
|
|
if (elevations[i] > newMax) newMax = elevations[i];
|
|
}
|
|
|
|
if (!prev) {
|
|
return [newMin, newMax];
|
|
}
|
|
return [Math.min(prev[0], newMin), Math.max(prev[1], newMax)];
|
|
});
|
|
}
|
|
};
|
|
|
|
const setPipeData = (newData: any[]) => {
|
|
const uniqueNewData = newData.filter((item) => {
|
|
if (!item || !item.id) return false;
|
|
if (!pipeDataIds.current.has(item.id)) {
|
|
pipeDataIds.current.add(item.id);
|
|
return true;
|
|
}
|
|
return false;
|
|
});
|
|
if (uniqueNewData.length > 0) {
|
|
setPipeDataState((prev) => prev.concat(uniqueNewData));
|
|
setDiameterRange((prev) => {
|
|
const diameters = uniqueNewData
|
|
.map((d) => d.diameter)
|
|
.filter((v) => typeof v === "number");
|
|
if (diameters.length === 0) return prev;
|
|
|
|
let newMin = diameters[0];
|
|
let newMax = diameters[0];
|
|
for (let i = 1; i < diameters.length; i++) {
|
|
if (diameters[i] < newMin) newMin = diameters[i];
|
|
if (diameters[i] > newMax) newMax = diameters[i];
|
|
}
|
|
|
|
if (!prev) {
|
|
return [newMin, newMax];
|
|
}
|
|
return [Math.min(prev[0], newMin), Math.max(prev[1], newMax)];
|
|
});
|
|
}
|
|
};
|
|
|
|
const debouncedUpdateDataRef = useRef<DebouncedFunction<() => void> | null>(
|
|
null,
|
|
);
|
|
|
|
useEffect(() => {
|
|
debouncedUpdateDataRef.current = debounce(() => {
|
|
if (tileJunctionDataBuffer.current.length > 0) {
|
|
setJunctionData(tileJunctionDataBuffer.current);
|
|
tileJunctionDataBuffer.current = [];
|
|
}
|
|
if (tilePipeDataBuffer.current.length > 0) {
|
|
setPipeData(tilePipeDataBuffer.current);
|
|
tilePipeDataBuffer.current = [];
|
|
}
|
|
}, 100);
|
|
|
|
return () => {
|
|
debouncedUpdateDataRef.current?.cancel();
|
|
debouncedUpdateDataRef.current = null;
|
|
};
|
|
}, []);
|
|
const operationalResources = useMemo(
|
|
() =>
|
|
createOperationalMapResources({
|
|
mapUrl: MAP_URL,
|
|
workspace: MAP_WORKSPACE,
|
|
extent: MAP_EXTENT,
|
|
persistent: true,
|
|
}),
|
|
[MAP_URL, MAP_WORKSPACE, MAP_EXTENT],
|
|
);
|
|
const { junctions: junctionSource, pipes: pipeSource } =
|
|
operationalResources.sources;
|
|
const { junctions: junctionsLayer, pipes: pipesLayer } =
|
|
operationalResources.layers;
|
|
|
|
// The map and layer instances are intentionally rebuilt only when workspace or extent changes.
|
|
useEffect(() => {
|
|
if (!mapRef.current) return;
|
|
if (!canvasRef.current) {
|
|
return;
|
|
}
|
|
isDisposingRef.current = false;
|
|
const activeJunctionDataIds = junctionDataIds.current;
|
|
const activePipeDataIds = pipeDataIds.current;
|
|
|
|
const addTimeout = (callback: () => void, delay: number) => {
|
|
const timerId = window.setTimeout(() => {
|
|
pendingTimeoutsRef.current = pendingTimeoutsRef.current.filter(
|
|
(id) => id !== timerId,
|
|
);
|
|
if (isDisposingRef.current) return;
|
|
callback();
|
|
}, delay);
|
|
pendingTimeoutsRef.current.push(timerId);
|
|
return timerId;
|
|
};
|
|
|
|
const clearPendingTimeouts = () => {
|
|
pendingTimeoutsRef.current.forEach((id) => clearTimeout(id));
|
|
pendingTimeoutsRef.current = [];
|
|
};
|
|
|
|
// 缓存 junction、pipe 数据,提供给 deck.gl 提供坐标供标签显示
|
|
const handleJunctionTileLoadEnd = (event: any) => {
|
|
if (isDisposingRef.current) return;
|
|
try {
|
|
if (event.tile instanceof VectorTile) {
|
|
const renderFeatures = event.tile.getFeatures();
|
|
const data = new Map();
|
|
|
|
renderFeatures.forEach((renderFeature: any) => {
|
|
const props = renderFeature.getProperties();
|
|
const featureId = props.id;
|
|
if (featureId && !junctionDataIds.current.has(featureId)) {
|
|
const geometry = renderFeature.getGeometry();
|
|
if (geometry) {
|
|
const coordinates = geometry.getFlatCoordinates();
|
|
const coordWGS84 = toLonLat(coordinates);
|
|
data.set(featureId, {
|
|
id: featureId,
|
|
position: coordWGS84,
|
|
elevation: props.elevation || 0,
|
|
demand: props.demand || 0,
|
|
});
|
|
}
|
|
}
|
|
});
|
|
|
|
const uniqueData = Array.from(data.values());
|
|
if (uniqueData.length > 0) {
|
|
uniqueData.forEach((item) =>
|
|
tileJunctionDataBuffer.current.push(item),
|
|
);
|
|
debouncedUpdateDataRef.current?.();
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error("Junction tile load error:", error);
|
|
}
|
|
};
|
|
const handlePipeTileLoadEnd = (event: any) => {
|
|
if (isDisposingRef.current) return;
|
|
try {
|
|
if (event.tile instanceof VectorTile) {
|
|
const renderFeatures = event.tile.getFeatures();
|
|
const data = new Map();
|
|
|
|
renderFeatures.forEach((renderFeature: any) => {
|
|
try {
|
|
const props = renderFeature.getProperties();
|
|
const featureId = props.id;
|
|
if (featureId && !pipeDataIds.current.has(featureId)) {
|
|
const geometry = renderFeature.getGeometry();
|
|
if (geometry) {
|
|
const flatCoordinates = geometry.getFlatCoordinates();
|
|
const stride = geometry.getStride(); // 获取步长,通常为 2
|
|
// 重建为 LineString GeoJSON 格式的 coordinates: [[x1, y1], [x2, y2], ...]
|
|
const lineCoords = [];
|
|
for (let i = 0; i < flatCoordinates.length; i += stride) {
|
|
lineCoords.push([
|
|
flatCoordinates[i],
|
|
flatCoordinates[i + 1],
|
|
]);
|
|
}
|
|
const lineCoordsWGS84 = lineCoords.map((coord) => {
|
|
const [lon, lat] = toLonLat(coord);
|
|
return [lon, lat];
|
|
});
|
|
// 添加验证:确保至少有 2 个坐标点
|
|
if (lineCoordsWGS84.length < 2) return; // 跳过此特征
|
|
// 计算中点
|
|
const lineStringFeature = lineString(lineCoordsWGS84);
|
|
const lineLength = length(lineStringFeature);
|
|
const midPoint = along(lineStringFeature, lineLength / 2)
|
|
.geometry.coordinates;
|
|
// 计算角度
|
|
const prevPoint = along(lineStringFeature, lineLength * 0.49)
|
|
.geometry.coordinates;
|
|
const nextPoint = along(lineStringFeature, lineLength * 0.51)
|
|
.geometry.coordinates;
|
|
let lineAngle = bearing(prevPoint, nextPoint);
|
|
lineAngle = -lineAngle + 90;
|
|
if (lineAngle < -90 || lineAngle > 90) {
|
|
lineAngle += 180;
|
|
}
|
|
|
|
// 计算时间戳(可选)
|
|
const numSegments = lineCoordsWGS84.length - 1;
|
|
const timestamps = [0];
|
|
if (numSegments > 0) {
|
|
for (let i = 1; i <= numSegments; i++) {
|
|
timestamps.push((i / numSegments) * 10);
|
|
}
|
|
}
|
|
|
|
data.set(featureId, {
|
|
id: featureId,
|
|
diameter: props.diameter || 0,
|
|
length: props.length || 0,
|
|
path: lineCoordsWGS84, // 使用重建后的坐标
|
|
position: midPoint,
|
|
angle: lineAngle,
|
|
timestamps,
|
|
});
|
|
}
|
|
}
|
|
} catch (geomError) {
|
|
console.error("Geometry calculation error:", geomError);
|
|
}
|
|
});
|
|
|
|
const uniqueData = Array.from(data.values());
|
|
if (uniqueData.length > 0) {
|
|
uniqueData.forEach((item) => tilePipeDataBuffer.current.push(item));
|
|
debouncedUpdateDataRef.current?.();
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error("Pipe tile load error:", error);
|
|
}
|
|
};
|
|
junctionSource.on("tileloadend", handleJunctionTileLoadEnd);
|
|
pipeSource.on("tileloadend", handlePipeTileLoadEnd);
|
|
// 监听 junctionsLayer 的 visible 变化
|
|
const handleJunctionVisibilityChange = () => {
|
|
const isVisible = junctionsLayer.getVisible();
|
|
setShowJunctionTextLayer(isVisible);
|
|
};
|
|
// 监听 pipesLayer 的 visible 变化
|
|
const handlePipeVisibilityChange = () => {
|
|
const isVisible = pipesLayer.getVisible();
|
|
setShowPipeTextLayer(isVisible);
|
|
};
|
|
// 添加事件监听器
|
|
junctionsLayer.on("change:visible", handleJunctionVisibilityChange);
|
|
pipesLayer.on("change:visible", handlePipeVisibilityChange);
|
|
const map = new OlMap({
|
|
target: mapRef.current,
|
|
view: new View({
|
|
maxZoom: 24,
|
|
projection: "EPSG:3857",
|
|
}),
|
|
// 图层依面、线、点、标注次序添加
|
|
layers: operationalResources.orderedLayers.slice(),
|
|
controls: [],
|
|
});
|
|
map.getInteractions().forEach(markMapResourcePersistent);
|
|
setMap(map);
|
|
|
|
// 恢复上次视图;如果没有则适配 MAP_EXTENT
|
|
try {
|
|
const stored = localStorage.getItem(MAP_VIEW_STORAGE_KEY);
|
|
if (stored) {
|
|
const viewState = JSON.parse(stored);
|
|
if (
|
|
viewState &&
|
|
Array.isArray(viewState.center) &&
|
|
viewState.center.length === 2 &&
|
|
typeof viewState.zoom === "number" &&
|
|
viewState.zoom >= 11 &&
|
|
viewState.zoom <= 24
|
|
) {
|
|
map.getView().setCenter(viewState.center);
|
|
map.getView().setZoom(viewState.zoom);
|
|
} else {
|
|
map.getView().fit(MAP_EXTENT, {
|
|
padding: [50, 50, 50, 50],
|
|
duration: 1000,
|
|
});
|
|
}
|
|
} else {
|
|
map.getView().fit(MAP_EXTENT, {
|
|
padding: [50, 50, 50, 50],
|
|
duration: 1000,
|
|
});
|
|
}
|
|
} catch (err) {
|
|
console.warn("Restore map view failed", err);
|
|
map.getView().fit(MAP_EXTENT, {
|
|
padding: [50, 50, 50, 50],
|
|
duration: 1000,
|
|
});
|
|
}
|
|
// 持久化视图(中心点 + 缩放),防抖写入 localStorage
|
|
const persistView = debounce(() => {
|
|
if (isDisposingRef.current) return;
|
|
try {
|
|
const view = map.getView();
|
|
const center = view.getCenter();
|
|
const zoom = view.getZoom();
|
|
if (center && typeof zoom === "number") {
|
|
localStorage.setItem(
|
|
MAP_VIEW_STORAGE_KEY,
|
|
JSON.stringify({ center, zoom }),
|
|
);
|
|
}
|
|
} catch (err) {
|
|
console.warn("Save map view failed", err);
|
|
}
|
|
}, 250);
|
|
|
|
// 监听缩放变化并持久化,同时更新 currentZoom
|
|
const handleViewChange = () => {
|
|
addTimeout(() => {
|
|
const zoom = map.getView().getZoom() || 0;
|
|
setCurrentZoom(zoom);
|
|
persistView();
|
|
}, 250);
|
|
};
|
|
map.getView().on("change", handleViewChange);
|
|
|
|
// 初始化当前缩放级别并强制触发瓦片加载
|
|
addTimeout(() => {
|
|
const initialZoom = map.getView().getZoom() || 11;
|
|
setCurrentZoom(initialZoom);
|
|
// 强制触发地图渲染,让瓦片加载事件触发
|
|
map.render();
|
|
}, 100);
|
|
|
|
// 初始化 deck.gl
|
|
const deck = new Deck({
|
|
initialViewState: {
|
|
longitude: 0,
|
|
latitude: 0,
|
|
zoom: 1,
|
|
},
|
|
canvas: canvasRef.current,
|
|
controller: false, // 由 OpenLayers 控制视图
|
|
layers: [],
|
|
});
|
|
const deckLayer = markMapResourcePersistent(
|
|
new DeckLayer(deck, canvasRef.current, {
|
|
name: "deckLayer",
|
|
value: "deckLayer",
|
|
}),
|
|
);
|
|
deckLayerRef.current = deckLayer;
|
|
setDeckLayer(deckLayer);
|
|
map.addLayer(deckLayer);
|
|
|
|
// 清理函数
|
|
return () => {
|
|
isDisposingRef.current = true;
|
|
clearPendingTimeouts();
|
|
debouncedUpdateDataRef.current?.cancel();
|
|
persistView.cancel();
|
|
junctionSource.un("tileloadend", handleJunctionTileLoadEnd);
|
|
pipeSource.un("tileloadend", handlePipeTileLoadEnd);
|
|
map.getView().un("change", handleViewChange);
|
|
junctionsLayer.un("change:visible", handleJunctionVisibilityChange);
|
|
pipesLayer.un("change:visible", handlePipeVisibilityChange);
|
|
if (deckLayerRef.current && !deckLayerRef.current.isDisposedLayer()) {
|
|
try {
|
|
map.removeLayer(deckLayerRef.current);
|
|
} catch {
|
|
// Layer may have already been removed during teardown.
|
|
}
|
|
deckLayerRef.current.disposeDeck();
|
|
}
|
|
deckLayerRef.current = null;
|
|
setDeckLayer(undefined);
|
|
// React Strict Mode re-runs effects with the same memoized layer instances.
|
|
// Detach and clear them here, but leave final layer/source disposal to GC.
|
|
disposeMapResources(map, { disposeLayers: false });
|
|
activeJunctionDataIds.clear();
|
|
activePipeDataIds.clear();
|
|
tileJunctionDataBuffer.current = [];
|
|
tilePipeDataBuffer.current = [];
|
|
};
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [MAP_WORKSPACE, MAP_EXTENT]);
|
|
|
|
useEffect(() => {
|
|
if (!isCompareMode) {
|
|
isCompareDisposingRef.current = true;
|
|
setCompareJunctionCalData([]);
|
|
setComparePipeCalData([]);
|
|
return;
|
|
}
|
|
if (!map || !compareMapRef.current || !compareCanvasRef.current) return;
|
|
|
|
isCompareDisposingRef.current = false;
|
|
const compareResources = createOperationalMapResources({
|
|
mapUrl: MAP_URL,
|
|
workspace: MAP_WORKSPACE,
|
|
extent: MAP_EXTENT,
|
|
});
|
|
const nextCompareMap = new OlMap({
|
|
target: compareMapRef.current,
|
|
view: map.getView(),
|
|
layers: compareResources.orderedLayers.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);
|
|
disposeMapResources(nextCompareMap);
|
|
};
|
|
// 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]);
|
|
|
|
useEffect(() => {
|
|
setCurrentTime(getRoundedCurrentTimelineMinutes());
|
|
setSelectedDate(new Date());
|
|
setSchemeName("");
|
|
setCurrentJunctionCalData([]);
|
|
setCurrentPipeCalData([]);
|
|
setCompareJunctionCalData([]);
|
|
setComparePipeCalData([]);
|
|
setCompareMode(false);
|
|
setShowJunctionTextLayer(false);
|
|
setShowPipeTextLayer(false);
|
|
setShowJunctionId(false);
|
|
setShowPipeId(false);
|
|
setShowContourLayer(false);
|
|
setContours([]);
|
|
setContourLayerAvailable(false);
|
|
setWaterflowLayerAvailable(false);
|
|
setShowWaterflowLayer(false);
|
|
setForceStyleAutoApplyVersion(0);
|
|
|
|
return () => {
|
|
if (!map) return;
|
|
cleanupTransientMapResources(map);
|
|
deckLayerRef.current?.resetSessionLayers();
|
|
operationalResources.resetStyles();
|
|
};
|
|
}, [pathname, map, operationalResources]);
|
|
|
|
// 当数据变化时,更新 deck.gl 图层
|
|
useEffect(() => {
|
|
const syncDeckOverlay = (
|
|
targetDeckLayer: DeckLayer | null,
|
|
targetJunctionData: any[],
|
|
targetPipeData: any[],
|
|
disposing: boolean,
|
|
) => {
|
|
if (disposing || !targetDeckLayer || targetDeckLayer.isDisposedLayer()) {
|
|
return;
|
|
}
|
|
const shouldShowJunctionText =
|
|
(showJunctionTextLayer || showJunctionId) &&
|
|
currentZoom >= 15 &&
|
|
currentZoom <= 24 &&
|
|
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",
|
|
name: "节点文字",
|
|
zIndex: 10,
|
|
data: targetJunctionData,
|
|
getPosition: (d: any) => d.position,
|
|
fontFamily: "Monaco, monospace",
|
|
getText: (d: any) => {
|
|
let idPart = showJunctionId ? d.id : "";
|
|
let propPart = "";
|
|
if (showJunctionTextLayer && d[junctionText] !== undefined) {
|
|
const value = (d[junctionText] as number).toFixed(3);
|
|
propPart = `${value}`;
|
|
}
|
|
if (idPart && propPart) return `${idPart} - ${propPart}`;
|
|
return idPart || propPart;
|
|
},
|
|
getSize: 14,
|
|
fontWeight: "bold",
|
|
getColor: [33, 37, 41],
|
|
getAngle: 0,
|
|
getTextAnchor: "middle",
|
|
getAlignmentBaseline: "center",
|
|
getPixelOffset: [0, -10],
|
|
visible: true,
|
|
updateTriggers: {
|
|
getText: [showJunctionId, showJunctionTextLayer, junctionText],
|
|
},
|
|
extensions: [new CollisionFilterExtension()],
|
|
collisionTestProps: {
|
|
sizeScale: 3,
|
|
},
|
|
characterSet: "auto",
|
|
fontSettings: {
|
|
sdf: true,
|
|
fontSize: 64,
|
|
buffer: 6,
|
|
},
|
|
})
|
|
: null;
|
|
|
|
const pipeTextLayer = shouldShowPipeText
|
|
? new TextLayer({
|
|
id: "pipeTextLayer",
|
|
name: "管道文字",
|
|
zIndex: 10,
|
|
data: targetPipeData,
|
|
getPosition: (d: any) => d.position,
|
|
fontFamily: "Monaco, monospace",
|
|
getText: (d: any) => {
|
|
let idPart = showPipeId ? d.id : "";
|
|
let propPart = "";
|
|
if (showPipeTextLayer && d[pipeText] !== undefined) {
|
|
let value;
|
|
if (pipeText === "unit_headloss") {
|
|
value = (
|
|
(d["unit_headloss"] / (d["length"] / 1000)) as number
|
|
).toFixed(3);
|
|
} else {
|
|
value = Math.abs(d[pipeText] as number).toFixed(3);
|
|
}
|
|
propPart = `${value}`;
|
|
}
|
|
if (idPart && propPart) return `${idPart} - ${propPart}`;
|
|
return idPart || propPart;
|
|
},
|
|
getSize: 14,
|
|
fontWeight: "bold",
|
|
getColor: [33, 37, 41],
|
|
getAngle: (d: any) => d.angle || 0,
|
|
getPixelOffset: [0, -8],
|
|
getTextAnchor: "middle",
|
|
getAlignmentBaseline: "bottom",
|
|
visible: true,
|
|
updateTriggers: {
|
|
getText: [showPipeId, showPipeTextLayer, pipeText],
|
|
},
|
|
extensions: [new CollisionFilterExtension()],
|
|
collisionTestProps: {
|
|
sizeScale: 3,
|
|
},
|
|
characterSet: "auto",
|
|
fontSettings: {
|
|
sdf: true,
|
|
fontSize: 64,
|
|
buffer: 6,
|
|
},
|
|
})
|
|
: null;
|
|
|
|
const contourLayer = shouldShowContour
|
|
? new ContourLayer({
|
|
id: "junctionContourLayer",
|
|
name: "等值线",
|
|
data: targetJunctionData,
|
|
aggregation: "MEAN",
|
|
cellSize: 600,
|
|
strokeWidth: 0,
|
|
contours: contours,
|
|
getPosition: (d) => d.position,
|
|
getWeight: (d: any) =>
|
|
(d[junctionText] as number) < 0 ? 0 : (d[junctionText] as number),
|
|
opacity: 1,
|
|
visible: true,
|
|
updateTriggers: {
|
|
getWeight: [targetJunctionData, junctionText],
|
|
},
|
|
})
|
|
: null;
|
|
|
|
if (
|
|
junctionTextLayer &&
|
|
targetDeckLayer.getDeckLayerById("junctionTextLayer")
|
|
) {
|
|
targetDeckLayer.updateDeckLayer("junctionTextLayer", junctionTextLayer);
|
|
} else if (junctionTextLayer) {
|
|
targetDeckLayer.addDeckLayer(junctionTextLayer);
|
|
}
|
|
if (pipeTextLayer && targetDeckLayer.getDeckLayerById("pipeTextLayer")) {
|
|
targetDeckLayer.updateDeckLayer("pipeTextLayer", pipeTextLayer);
|
|
} else if (pipeTextLayer) {
|
|
targetDeckLayer.addDeckLayer(pipeTextLayer);
|
|
}
|
|
if (
|
|
contourLayer &&
|
|
targetDeckLayer.getDeckLayerById("junctionContourLayer")
|
|
) {
|
|
targetDeckLayer.updateDeckLayer("junctionContourLayer", contourLayer);
|
|
} else if (contourLayer) {
|
|
targetDeckLayer.addDeckLayer(contourLayer);
|
|
}
|
|
};
|
|
|
|
syncDeckOverlay(
|
|
deckLayerRef.current,
|
|
mergedJunctionData,
|
|
mergedPipeData,
|
|
isDisposingRef.current,
|
|
);
|
|
if (isCompareMode) {
|
|
syncDeckOverlay(
|
|
compareDeckLayerRef.current,
|
|
mergedCompareJunctionData,
|
|
mergedComparePipeData,
|
|
isCompareDisposingRef.current,
|
|
);
|
|
}
|
|
}, [
|
|
mergedJunctionData,
|
|
mergedPipeData,
|
|
mergedCompareJunctionData,
|
|
mergedComparePipeData,
|
|
isCompareMode,
|
|
junctionText,
|
|
pipeText,
|
|
currentZoom,
|
|
showJunctionTextLayer,
|
|
showPipeTextLayer,
|
|
showJunctionId,
|
|
showPipeId,
|
|
showContourLayer,
|
|
contours,
|
|
]);
|
|
|
|
// 控制流动动画开关
|
|
useEffect(() => {
|
|
flowAnimation.current = pipeText === "flow" && currentPipeCalData.length > 0;
|
|
const shouldShowWaterflow =
|
|
isWaterflowLayerAvailable &&
|
|
showWaterflowLayer &&
|
|
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 animationDuration = 10;
|
|
const bufferTime = 2;
|
|
const loopLength = animationDuration + bufferTime;
|
|
const currentFrameTime = (Date.now() / 1000) % loopLength;
|
|
|
|
const waterflowLayer = new TripsLayer({
|
|
id: "waterflowLayer",
|
|
name: "水流",
|
|
data: targetPipeData,
|
|
getPath: (d) => d.path,
|
|
getTimestamps: (d) => d.timestamps,
|
|
getColor: [0, 220, 255],
|
|
opacity: 0.8,
|
|
visible: true,
|
|
widthMinPixels: 5,
|
|
jointRounded: true,
|
|
trailLength: 2,
|
|
currentTime: currentFrameTime,
|
|
});
|
|
|
|
if (targetDeckLayer.getDeckLayerById("waterflowLayer")) {
|
|
targetDeckLayer.updateDeckLayer("waterflowLayer", waterflowLayer);
|
|
} else {
|
|
targetDeckLayer.addDeckLayer(waterflowLayer);
|
|
}
|
|
};
|
|
|
|
const animate = () => {
|
|
syncWaterflowLayer(
|
|
deckLayerRef.current,
|
|
mergedPipeData,
|
|
isDisposingRef.current,
|
|
);
|
|
if (isCompareMode) {
|
|
syncWaterflowLayer(
|
|
compareDeckLayerRef.current,
|
|
mergedComparePipeData,
|
|
isCompareDisposingRef.current,
|
|
);
|
|
}
|
|
if (shouldShowWaterflow) {
|
|
animationFrameId = requestAnimationFrame(animate);
|
|
}
|
|
};
|
|
animate();
|
|
|
|
// 清理函数:取消动画帧
|
|
return () => {
|
|
if (animationFrameId) {
|
|
cancelAnimationFrame(animationFrameId);
|
|
}
|
|
};
|
|
}, [
|
|
currentPipeCalData,
|
|
currentZoom,
|
|
mergedPipeData,
|
|
mergedComparePipeData,
|
|
isCompareMode,
|
|
pipeText,
|
|
isWaterflowLayerAvailable,
|
|
showWaterflowLayer,
|
|
]);
|
|
|
|
return (
|
|
<>
|
|
<DataContext.Provider
|
|
value={{
|
|
currentTime,
|
|
setCurrentTime,
|
|
selectedDate,
|
|
setSelectedDate,
|
|
schemeName,
|
|
setSchemeName,
|
|
currentJunctionCalData,
|
|
setCurrentJunctionCalData,
|
|
currentPipeCalData,
|
|
setCurrentPipeCalData,
|
|
compareJunctionCalData,
|
|
setCompareJunctionCalData,
|
|
comparePipeCalData,
|
|
setComparePipeCalData,
|
|
isCompareMode,
|
|
setCompareMode,
|
|
toggleCompareMode,
|
|
setShowJunctionTextLayer,
|
|
setShowPipeTextLayer,
|
|
setShowJunctionId,
|
|
setShowPipeId,
|
|
showJunctionId,
|
|
showPipeId,
|
|
showContourLayer,
|
|
setShowContourLayer,
|
|
isContourLayerAvailable,
|
|
showWaterflowLayer,
|
|
setContourLayerAvailable,
|
|
isWaterflowLayerAvailable,
|
|
setWaterflowLayerAvailable,
|
|
setShowWaterflowLayer,
|
|
setJunctionText,
|
|
setPipeText,
|
|
junctionText,
|
|
pipeText,
|
|
setContours,
|
|
deckLayer,
|
|
compareDeckLayer,
|
|
deckLayers,
|
|
compareMap,
|
|
maps,
|
|
diameterRange,
|
|
elevationRange,
|
|
forceStyleAutoApplyVersion,
|
|
setForceStyleAutoApplyVersion,
|
|
}}
|
|
>
|
|
<MapContext.Provider value={map}>
|
|
<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>
|
|
<canvas
|
|
ref={canvasRef}
|
|
className="pointer-events-none absolute inset-0"
|
|
/>
|
|
{isCompareMode && (
|
|
<div className="pointer-events-none absolute right-4 top-4 rounded-md bg-black/55 px-3 py-1 text-sm font-medium text-white">
|
|
方案模拟
|
|
</div>
|
|
)}
|
|
</div>
|
|
{isCompareMode && (
|
|
<div className="relative h-full w-1/2 border-l border-white/40">
|
|
<div ref={compareMapRef} className="w-full h-full"></div>
|
|
<canvas
|
|
ref={compareCanvasRef}
|
|
className="pointer-events-none absolute inset-0"
|
|
/>
|
|
<div className="pointer-events-none absolute left-4 top-4 rounded-md bg-black/55 px-3 py-1 text-sm font-medium text-white">
|
|
实时模拟
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
{isCompareMode && (
|
|
<div className="pointer-events-none absolute inset-y-0 left-1/2 z-10 w-px -translate-x-1/2 bg-white/85 shadow-[0_0_0_1px_rgba(15,23,42,0.18)]" />
|
|
)}
|
|
<MapTools />
|
|
{children}
|
|
</div>
|
|
</MapContext.Provider>
|
|
</DataContext.Provider>
|
|
</>
|
|
);
|
|
};
|
|
|
|
export default MapComponent;
|