1183 lines
39 KiB
TypeScript
1183 lines
39 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 } 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 { isLpsFlowProperty, toM3h } from "@utils/units";
|
|
import { usePathname } from "next/navigation";
|
|
import {
|
|
cleanupTransientMapResources,
|
|
disposeMapResources,
|
|
markMapResourcePersistent,
|
|
} from "./mapLifecycle";
|
|
import {
|
|
createOperationalMapResources,
|
|
createOperationalMapSources,
|
|
} from "./operationalLayers";
|
|
import { getRoundedCurrentTimelineMinutes } from "./Controls/timelineTime";
|
|
import { useTimelineTimeConfig } from "./Controls/useTimelineTimeConfig";
|
|
import {
|
|
TileFeatureIndex,
|
|
clipLineStringPartsToExtent,
|
|
coordinatesToLonLat,
|
|
lineStringFromFlatCoordinates,
|
|
type TileFeatureInstance,
|
|
} from "./tileFeatureIndex";
|
|
|
|
interface MapComponentProps {
|
|
children?: React.ReactNode;
|
|
}
|
|
|
|
const COMPARE_OPEN_START_MARK = "tjwater:compare-open-start";
|
|
const COMPARE_OPEN_READY_MARK = "tjwater:compare-open-ready";
|
|
const COMPARE_OPEN_MEASURE = "tjwater:compare-open";
|
|
|
|
const markCompareOpenStart = () => {
|
|
performance.clearMarks(COMPARE_OPEN_START_MARK);
|
|
performance.clearMarks(COMPARE_OPEN_READY_MARK);
|
|
performance.clearMeasures(COMPARE_OPEN_MEASURE);
|
|
performance.mark(COMPARE_OPEN_START_MARK);
|
|
};
|
|
|
|
const markCompareOpenReady = () => {
|
|
if (performance.getEntriesByName(COMPARE_OPEN_START_MARK).length === 0) {
|
|
return;
|
|
}
|
|
performance.mark(COMPARE_OPEN_READY_MARK);
|
|
performance.measure(
|
|
COMPARE_OPEN_MEASURE,
|
|
COMPARE_OPEN_START_MARK,
|
|
COMPARE_OPEN_READY_MARK,
|
|
);
|
|
};
|
|
|
|
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;
|
|
}
|
|
|
|
const indexCalculationRecords = (records: any[]) =>
|
|
new Map(records.map((record) => [String(record.ID), record]));
|
|
|
|
const mergeJunctionValues = (
|
|
features: any[],
|
|
records: any[],
|
|
property: string,
|
|
) => {
|
|
const recordsById = indexCalculationRecords(records);
|
|
return features.map((feature) => {
|
|
const record = recordsById.get(String(feature.id));
|
|
if (!record) return feature;
|
|
const value = isLpsFlowProperty(property)
|
|
? toM3h(record.value, "lps")
|
|
: record.value;
|
|
return { ...feature, [property]: value };
|
|
});
|
|
};
|
|
|
|
const mergePipeValues = (
|
|
features: any[],
|
|
records: any[],
|
|
property: string,
|
|
) => {
|
|
const recordsById = indexCalculationRecords(records);
|
|
const isFlow = property === "flow";
|
|
return features.map((feature) => {
|
|
const record = recordsById.get(String(feature.id));
|
|
if (!record) return feature;
|
|
const value = isFlow ? toM3h(record.value, "lps") : record.value;
|
|
const reverseFlow = isFlow && record.value < 0;
|
|
return {
|
|
...feature,
|
|
[property]: isFlow ? Math.abs(value) : value,
|
|
flowFlag: reverseFlow ? -1 : 1,
|
|
path: reverseFlow ? [...feature.path].reverse() : feature.path,
|
|
};
|
|
});
|
|
};
|
|
|
|
const getNumericRange = (values: number[]): [number, number] | undefined => {
|
|
let min = Infinity;
|
|
let max = -Infinity;
|
|
values.forEach((value) => {
|
|
if (!Number.isFinite(value)) return;
|
|
min = Math.min(min, value);
|
|
max = Math.max(max, value);
|
|
});
|
|
return min === Infinity ? undefined : [min, max];
|
|
};
|
|
|
|
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 { durationMinutes, stepMinutes } = useTimelineTimeConfig();
|
|
|
|
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 [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 为当前层级和视口内按 ID 去重的节点数据;pipeData 为管道标签数据。
|
|
// pipeFragments 保留当前层级和视口内每个瓦片管道片段,水流动画按 instanceKey 渲染,不能按业务 ID 去重。
|
|
const [junctionData, setJunctionDataState] = useState<any[]>([]);
|
|
const [pipeData, setPipeDataState] = useState<any[]>([]);
|
|
const [pipeFragments, setPipeFragments] = useState<any[]>([]);
|
|
const junctionIndexRef = useRef<TileFeatureIndex | null>(null);
|
|
const pipeIndexRef = useRef<TileFeatureIndex | null>(null);
|
|
|
|
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 [isContourLayerAvailable, setContourLayerAvailable] = useState(false); // 控制等高线图层显示
|
|
const [isWaterflowLayerAvailable, setWaterflowLayerAvailable] =
|
|
useState(false); // 控制等高线图层显示
|
|
const [showWaterflowLayer, setShowWaterflowLayer] = useState(false); // 控制等高线图层显示
|
|
const [currentZoom, setCurrentZoom] = useState(11); // 当前缩放级别
|
|
|
|
// 实时合并计算结果到基础地理数据中
|
|
const mergedJunctionData = useMemo(
|
|
() =>
|
|
mergeJunctionValues(junctionData, currentJunctionCalData, junctionText),
|
|
[junctionData, currentJunctionCalData, junctionText],
|
|
);
|
|
const mergedPipeData = useMemo(
|
|
() => mergePipeValues(pipeData, currentPipeCalData, pipeText),
|
|
[pipeData, currentPipeCalData, pipeText],
|
|
);
|
|
const mergedPipeFragments = useMemo(
|
|
() => mergePipeValues(pipeFragments, currentPipeCalData, pipeText),
|
|
[pipeFragments, currentPipeCalData, pipeText],
|
|
);
|
|
const mergedCompareJunctionData = useMemo(
|
|
() =>
|
|
isCompareMode
|
|
? mergeJunctionValues(junctionData, compareJunctionCalData, junctionText)
|
|
: [],
|
|
[isCompareMode, junctionData, compareJunctionCalData, junctionText],
|
|
);
|
|
const mergedComparePipeData = useMemo(
|
|
() =>
|
|
isCompareMode
|
|
? mergePipeValues(pipeData, comparePipeCalData, pipeText)
|
|
: [],
|
|
[isCompareMode, pipeData, comparePipeCalData, pipeText],
|
|
);
|
|
const mergedComparePipeFragments = useMemo(
|
|
() =>
|
|
isCompareMode
|
|
? mergePipeValues(pipeFragments, comparePipeCalData, pipeText)
|
|
: [],
|
|
[isCompareMode, pipeFragments, 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) => {
|
|
if (!prev) markCompareOpenStart();
|
|
return !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 buildPipeFragments = useCallback((instance: TileFeatureInstance) => {
|
|
const tileCoordinates = lineStringFromFlatCoordinates(
|
|
instance.flatCoordinates,
|
|
instance.stride,
|
|
);
|
|
const clippedParts = clipLineStringPartsToExtent(
|
|
tileCoordinates,
|
|
instance.tileExtent,
|
|
);
|
|
return clippedParts.flatMap((clippedCoordinates, partIndex) => {
|
|
const path = coordinatesToLonLat(clippedCoordinates);
|
|
const lineStringFeature = lineString(path);
|
|
const fragmentLength = length(lineStringFeature);
|
|
if (fragmentLength <= 0) return [];
|
|
|
|
const timestamps = [0];
|
|
let cumulativeLength = 0;
|
|
for (let i = 1; i < path.length; i += 1) {
|
|
cumulativeLength += length(lineString([path[i - 1], path[i]]));
|
|
timestamps.push((cumulativeLength / fragmentLength) * 10);
|
|
}
|
|
|
|
const midPoint = along(lineStringFeature, fragmentLength / 2).geometry
|
|
.coordinates;
|
|
const prevPoint = along(lineStringFeature, fragmentLength * 0.49).geometry
|
|
.coordinates;
|
|
const nextPoint = along(lineStringFeature, fragmentLength * 0.51).geometry
|
|
.coordinates;
|
|
let lineAngle = bearing(prevPoint, nextPoint);
|
|
lineAngle = -lineAngle + 90;
|
|
if (lineAngle < -90 || lineAngle > 90) {
|
|
lineAngle += 180;
|
|
}
|
|
|
|
return [
|
|
{
|
|
instanceKey: `${instance.instanceKey}/${partIndex}`,
|
|
id: instance.featureId,
|
|
diameter: instance.properties.diameter || 0,
|
|
length: instance.properties.length || fragmentLength * 1000,
|
|
path,
|
|
position: midPoint,
|
|
angle: lineAngle,
|
|
timestamps,
|
|
fragmentLength,
|
|
},
|
|
];
|
|
});
|
|
}, []);
|
|
|
|
const publishActiveTileSnapshot = useCallback(
|
|
(targetMap: OlMap) => {
|
|
const zoom = targetMap.getView().getZoom() ?? 0;
|
|
const junctionSnapshot = junctionIndexRef.current?.getSnapshot(
|
|
targetMap,
|
|
zoom,
|
|
);
|
|
const pipeSnapshot = pipeIndexRef.current?.getSnapshot(targetMap, zoom);
|
|
|
|
const nextJunctionData = Array.from(
|
|
junctionSnapshot?.instancesById.values() ?? [],
|
|
)
|
|
.map((instances) => instances[0])
|
|
.filter(Boolean)
|
|
.map((instance) => {
|
|
const [x, y] = lineStringFromFlatCoordinates(
|
|
instance.flatCoordinates,
|
|
instance.stride,
|
|
)[0];
|
|
return {
|
|
id: instance.featureId,
|
|
instanceKey: instance.instanceKey,
|
|
position: toLonLat([x, y]),
|
|
elevation: instance.properties.elevation || 0,
|
|
demand: instance.properties.demand || 0,
|
|
};
|
|
})
|
|
.sort((a, b) => String(a.id).localeCompare(String(b.id)));
|
|
|
|
const nextPipeFragments = (pipeSnapshot?.instances ?? [])
|
|
.filter((instance) => instance.geometryType.includes("Line"))
|
|
.flatMap(buildPipeFragments)
|
|
.sort((a, b) => a.instanceKey.localeCompare(b.instanceKey));
|
|
|
|
const labelById = new Map<string, any>();
|
|
nextPipeFragments.forEach((fragment) => {
|
|
const previous = labelById.get(fragment.id);
|
|
if (
|
|
!previous ||
|
|
fragment.fragmentLength > previous.fragmentLength ||
|
|
(fragment.fragmentLength === previous.fragmentLength &&
|
|
fragment.instanceKey.localeCompare(previous.instanceKey) < 0)
|
|
) {
|
|
labelById.set(fragment.id, fragment);
|
|
}
|
|
});
|
|
const nextPipeLabels = Array.from(labelById.values()).sort((a, b) =>
|
|
String(a.id).localeCompare(String(b.id)),
|
|
);
|
|
|
|
setJunctionDataState(nextJunctionData);
|
|
setPipeFragments(nextPipeFragments);
|
|
setPipeDataState(nextPipeLabels);
|
|
setElevationRange(
|
|
getNumericRange(nextJunctionData.map((item) => item.elevation)),
|
|
);
|
|
setDiameterRange(
|
|
getNumericRange(nextPipeLabels.map((item) => item.diameter)),
|
|
);
|
|
},
|
|
[buildPipeFragments],
|
|
);
|
|
const operationalSources = useMemo(
|
|
() =>
|
|
createOperationalMapSources({
|
|
mapUrl: MAP_URL,
|
|
workspace: MAP_WORKSPACE,
|
|
}),
|
|
[MAP_URL, MAP_WORKSPACE],
|
|
);
|
|
const operationalResources = useMemo(
|
|
() =>
|
|
createOperationalMapResources({
|
|
mapUrl: MAP_URL,
|
|
workspace: MAP_WORKSPACE,
|
|
extent: MAP_EXTENT,
|
|
persistent: true,
|
|
sources: operationalSources,
|
|
}),
|
|
[MAP_URL, MAP_WORKSPACE, MAP_EXTENT, operationalSources],
|
|
);
|
|
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;
|
|
|
|
junctionIndexRef.current = new TileFeatureIndex("junctions", junctionSource);
|
|
pipeIndexRef.current = new TileFeatureIndex("pipes", pipeSource);
|
|
|
|
const handleJunctionTileLoadEnd = (event: any) => {
|
|
if (isDisposingRef.current) return;
|
|
try {
|
|
junctionIndexRef.current?.registerTile(event.tile);
|
|
scheduleActiveTileSnapshot();
|
|
} catch (error) {
|
|
console.error("Junction tile load error:", error);
|
|
}
|
|
};
|
|
const handlePipeTileLoadEnd = (event: any) => {
|
|
if (isDisposingRef.current) return;
|
|
try {
|
|
pipeIndexRef.current?.registerTile(event.tile);
|
|
scheduleActiveTileSnapshot();
|
|
} catch (error) {
|
|
console.error("Pipe tile load error:", error);
|
|
}
|
|
};
|
|
// 监听 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: [],
|
|
});
|
|
const scheduleActiveTileSnapshot = debounce(
|
|
() => publishActiveTileSnapshot(map),
|
|
50,
|
|
);
|
|
junctionSource.on("tileloadend", handleJunctionTileLoadEnd);
|
|
pipeSource.on("tileloadend", handlePipeTileLoadEnd);
|
|
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,
|
|
});
|
|
}
|
|
// 视图稳定后同步 Deck 数据并持久化,避免移动过程中重复扫描瓦片。
|
|
const handleViewChange = debounce(() => {
|
|
if (isDisposingRef.current) return;
|
|
const view = map.getView();
|
|
const zoom = view.getZoom() || 0;
|
|
setCurrentZoom(zoom);
|
|
junctionIndexRef.current?.scanLoadedTiles();
|
|
pipeIndexRef.current?.scanLoadedTiles();
|
|
scheduleActiveTileSnapshot();
|
|
try {
|
|
const center = view.getCenter();
|
|
if (center) {
|
|
localStorage.setItem(
|
|
MAP_VIEW_STORAGE_KEY,
|
|
JSON.stringify({ center, zoom }),
|
|
);
|
|
}
|
|
} catch (err) {
|
|
console.warn("Save map view failed", err);
|
|
}
|
|
}, 250);
|
|
map.getView().on("change", handleViewChange);
|
|
|
|
// 初始化当前缩放级别并强制触发瓦片加载
|
|
const initializeTimer = window.setTimeout(() => {
|
|
if (isDisposingRef.current) return;
|
|
const initialZoom = map.getView().getZoom() || 11;
|
|
setCurrentZoom(initialZoom);
|
|
junctionIndexRef.current?.scanLoadedTiles();
|
|
pipeIndexRef.current?.scanLoadedTiles();
|
|
scheduleActiveTileSnapshot();
|
|
// 强制触发地图渲染,让瓦片加载事件触发
|
|
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;
|
|
window.clearTimeout(initializeTimer);
|
|
scheduleActiveTileSnapshot.cancel();
|
|
handleViewChange.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 });
|
|
junctionIndexRef.current = null;
|
|
pipeIndexRef.current = null;
|
|
setJunctionDataState([]);
|
|
setPipeDataState([]);
|
|
setPipeFragments([]);
|
|
};
|
|
// 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,
|
|
sources: operationalSources,
|
|
});
|
|
const nextCompareMap = new OlMap({
|
|
target: compareMapRef.current,
|
|
view: map.getView(),
|
|
layers: compareResources.orderedLayers.slice(),
|
|
controls: [],
|
|
});
|
|
const handleCompareRenderComplete = () => markCompareOpenReady();
|
|
nextCompareMap.once("rendercomplete", handleCompareRenderComplete);
|
|
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);
|
|
nextCompareMap.un("rendercomplete", handleCompareRenderComplete);
|
|
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, { disposeSources: false });
|
|
};
|
|
// 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(new Date(), stepMinutes, durationMinutes),
|
|
);
|
|
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();
|
|
};
|
|
}, [durationMinutes, pathname, map, operationalResources, stepMinutes]);
|
|
|
|
// 当数据变化时,更新 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(() => {
|
|
const hasFlowData = pipeText === "flow" && currentPipeCalData.length > 0;
|
|
const shouldShowWaterflow =
|
|
isWaterflowLayerAvailable &&
|
|
showWaterflowLayer &&
|
|
hasFlowData &&
|
|
currentZoom >= 12 &&
|
|
currentZoom <= 24;
|
|
|
|
let animationFrameId: number;
|
|
|
|
const syncWaterflowLayer = (
|
|
targetDeckLayer: DeckLayer | null,
|
|
targetPipeFragments: any[],
|
|
disposing: boolean,
|
|
) => {
|
|
if (disposing || !targetDeckLayer || targetDeckLayer.isDisposedLayer()) {
|
|
return;
|
|
}
|
|
if (!shouldShowWaterflow || targetPipeFragments.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: targetPipeFragments,
|
|
getObjectId: (d: any) => d.instanceKey,
|
|
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,
|
|
mergedPipeFragments,
|
|
isDisposingRef.current,
|
|
);
|
|
if (isCompareMode) {
|
|
syncWaterflowLayer(
|
|
compareDeckLayerRef.current,
|
|
mergedComparePipeFragments,
|
|
isCompareDisposingRef.current,
|
|
);
|
|
}
|
|
if (shouldShowWaterflow) {
|
|
animationFrameId = requestAnimationFrame(animate);
|
|
}
|
|
};
|
|
animate();
|
|
|
|
// 清理函数:取消动画帧
|
|
return () => {
|
|
if (animationFrameId) {
|
|
cancelAnimationFrame(animationFrameId);
|
|
}
|
|
};
|
|
}, [
|
|
currentPipeCalData,
|
|
currentZoom,
|
|
mergedPipeFragments,
|
|
mergedComparePipeFragments,
|
|
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;
|