Files
next-tjwater-drainage-frontend/features/workbench/hooks/use-workbench-map.ts
T

365 lines
12 KiB
TypeScript

"use client";
import "maplibre-gl/dist/maplibre-gl.css";
import maplibregl, { type Map as MapLibreMap, type MapSourceDataEvent } from "maplibre-gl";
import { useEffect, useRef, useState, type RefObject } from "react";
import type { DetailFeature } from "../types";
import {
SIMULATION_SOURCE_IDS,
simulationAnnotationLayers,
simulationSources
} from "../map/annotation-layers";
import { fitNetworkBounds } from "../map/camera";
import {
waterNetworkBusinessLayers,
waterNetworkHitLayers,
waterNetworkInteractionLayers
} from "../map/layers";
import { MAP_MAX_ZOOM } from "../map/map-layer-visuals";
import {
VALUE_LABEL_LAYER_IDS,
VALUE_LABEL_SOURCE_ID,
createEmptyValueLabelCollection
} from "../map/value-label";
import { setSimulationLayersVisibility } from "../map/simulation-layers";
import {
registerScadaImages,
scadaFallbackLayers,
scadaLayers,
type ScadaImageRegistrationResult
} from "../map/scada";
import {
SCADA_ANALYSIS_SOURCE_ID,
createEmptyScadaAnalysisCollection,
ensureScadaAnalysisLayers
} from "../map/scada-analysis";
import {
createBaseStyle,
createWaterNetworkSources,
WATER_NETWORK_GLOBAL_VIEW
} from "../map/sources";
import { useMapInteractions } from "./use-map-interactions";
import { useSimulationLayerVisibility } from "./use-simulation-layer-visibility";
declare global {
interface Window {
__waterNetworkMap?: MapLibreMap;
}
}
type UseWorkbenchMapOptions = {
containerRef: RefObject<HTMLDivElement | null>;
leftPanelOpen: boolean;
rightPanelOpen: boolean;
impactVisible: boolean;
onSelectFeature: (feature: DetailFeature) => void;
selectedFeature: DetailFeature | null;
};
type UseWorkbenchMapResult = {
mapRef: RefObject<MapLibreMap | null>;
mapReady: boolean;
mapError: string | null;
sourceStatuses: WorkbenchSourceStatus[];
fitNetworkBounds: () => void;
};
export type WorkbenchSourceStatusValue = "loading" | "online" | "degraded" | "offline";
export type WorkbenchSourceStatus = {
id: string;
sourceName: string;
status: WorkbenchSourceStatusValue;
message: string;
};
const SOURCE_STATUS_LABELS: Record<string, string> = {
"mapbox-base": "Mapbox 底图",
"geoserver-mvt": "GeoServer MVT",
"scada-icons": "SCADA 图标",
"annotation-source": "业务标注源"
};
const SOURCE_GROUP_BY_ID: Record<string, string> = {
"mapbox-light": "mapbox-base",
"mapbox-satellite": "mapbox-base",
conduits: "geoserver-mvt",
junctions: "geoserver-mvt",
orifices: "geoserver-mvt",
outfalls: "geoserver-mvt",
pumps: "geoserver-mvt",
scada: "geoserver-mvt",
[SIMULATION_SOURCE_IDS.impactArea]: "annotation-source",
[SIMULATION_SOURCE_IDS.annotations]: "annotation-source"
};
export function useWorkbenchMap({
containerRef,
leftPanelOpen,
rightPanelOpen,
impactVisible,
onSelectFeature,
selectedFeature
}: UseWorkbenchMapOptions): UseWorkbenchMapResult {
const mapRef = useRef<MapLibreMap | null>(null);
const impactVisibleRef = useRef(impactVisible);
const layoutOpenRef = useRef({ leftPanelOpen, rightPanelOpen });
const [mapReady, setMapReady] = useState(false);
const [mapError, setMapError] = useState<string | null>(null);
const [sourceStatuses, setSourceStatuses] = useState<Record<string, WorkbenchSourceStatus>>({});
useEffect(() => {
layoutOpenRef.current = { leftPanelOpen, rightPanelOpen };
}, [leftPanelOpen, rightPanelOpen]);
useEffect(() => {
impactVisibleRef.current = impactVisible;
}, [impactVisible]);
useEffect(() => {
if (!containerRef.current || mapRef.current) {
return;
}
const mapboxToken = process.env.NEXT_PUBLIC_MAPBOX_ACCESS_TOKEN;
const map = new maplibregl.Map({
container: containerRef.current,
style: createBaseStyle(mapboxToken),
pitch: 0,
bearing: 0,
maxZoom: MAP_MAX_ZOOM,
preserveDrawingBuffer: true,
attributionControl: false
});
window.__waterNetworkMap = map;
map.on("load", async () => {
map.resize();
const sources = createWaterNetworkSources();
map.addSource("conduits", sources.conduits);
map.addSource("junctions", sources.junctions);
map.addSource("orifices", sources.orifices);
map.addSource("outfalls", sources.outfalls);
map.addSource("pumps", sources.pumps);
map.addSource("scada", sources.scada);
map.addSource(SCADA_ANALYSIS_SOURCE_ID, {
type: "geojson",
data: createEmptyScadaAnalysisCollection()
});
map.addSource(SIMULATION_SOURCE_IDS.impactArea, simulationSources.impactArea);
map.addSource(SIMULATION_SOURCE_IDS.annotations, simulationSources.annotations);
map.addSource(VALUE_LABEL_SOURCE_ID, { type: "geojson", data: createEmptyValueLabelCollection() });
simulationAnnotationLayers.filter((layer) => layer.id === "simulation-impact-fill").forEach((layer) => map.addLayer(layer));
waterNetworkBusinessLayers.forEach((layer) => map.addLayer(layer));
simulationAnnotationLayers.filter((layer) => layer.type !== "symbol" && layer.id !== "simulation-impact-fill").forEach((layer) => map.addLayer(layer));
waterNetworkInteractionLayers.forEach((layer) => map.addLayer(layer));
map.addLayer({
id: VALUE_LABEL_LAYER_IDS[0],
type: "symbol",
source: VALUE_LABEL_SOURCE_ID,
filter: ["==", ["geometry-type"], "Point"],
layout: { "text-field": ["get", "label"], "text-size": 12, "text-offset": [0, -1.35], "text-anchor": "bottom" },
paint: { "text-color": "#0F172A", "text-halo-color": "#FFFFFF", "text-halo-width": 2 }
});
map.addLayer({
id: VALUE_LABEL_LAYER_IDS[1],
type: "symbol",
source: VALUE_LABEL_SOURCE_ID,
filter: ["in", ["geometry-type"], ["literal", ["LineString", "MultiLineString"]]],
layout: { "symbol-placement": "line-center", "text-field": ["get", "label"], "text-size": 12 },
paint: { "text-color": "#0F172A", "text-halo-color": "#FFFFFF", "text-halo-width": 2 }
});
let scadaRegistration: ScadaImageRegistrationResult = {
status: "unavailable",
failedImageIds: []
};
try {
scadaRegistration = await registerScadaImages(map);
} finally {
try {
const presentationLayers = scadaRegistration.status === "unavailable"
? scadaFallbackLayers
: scadaLayers;
presentationLayers.slice(0, -1).forEach((layer) => map.addLayer(layer));
simulationAnnotationLayers.filter((layer) => layer.type === "symbol").forEach((layer) => map.addLayer(layer));
waterNetworkHitLayers.forEach((layer) => map.addLayer(layer));
map.addLayer(presentationLayers[presentationLayers.length - 1]);
ensureScadaAnalysisLayers(map);
} finally {
setSimulationLayersVisibility(map, impactVisibleRef.current);
setMapReady(true);
updateSourceStatus(setSourceStatuses, "annotation-source", "online", "业务标注源已就绪。");
fitNetworkBounds(map, WATER_NETWORK_GLOBAL_VIEW);
}
}
if (scadaRegistration.status !== "ready") {
updateSourceStatus(
setSourceStatuses,
"scada-icons",
"degraded",
scadaRegistration.status === "degraded"
? "部分 SCADA 分类图标加载失败,已使用通用图标。"
: "SCADA 图标加载失败,已使用通用点位显示。"
);
}
});
const resizeMap = () => map.resize();
window.addEventListener("resize", resizeMap);
window.setTimeout(resizeMap, 0);
window.setTimeout(resizeMap, 300);
window.setTimeout(resizeMap, 1000);
map.on("sourcedata", (event) => {
updateStatusFromSourceEvent(event, "online", setSourceStatuses);
});
map.on("sourcedataabort", (event) => {
updateStatusFromSourceEvent(event, "degraded", setSourceStatuses);
});
map.on("error", (event) => {
const message = event.error?.message ?? "地图渲染异常";
if (!message.includes("AbortError")) {
const sourceGroupId = getSourceGroupFromErrorEvent(event);
if (sourceGroupId) {
updateSourceStatus(setSourceStatuses, sourceGroupId, "offline", getSourceErrorMessage(sourceGroupId, message));
} else {
setMapError(message);
}
}
});
mapRef.current = map;
return () => {
window.removeEventListener("resize", resizeMap);
map.remove();
mapRef.current = null;
delete window.__waterNetworkMap;
};
}, [containerRef]);
useMapInteractions({
mapRef,
mapReady,
onSelectFeature,
selectedFeature
});
useSimulationLayerVisibility({
mapRef,
mapReady,
visible: impactVisible
});
function fitToNetworkBounds() {
const map = mapRef.current;
if (map) {
fitNetworkBounds(map, WATER_NETWORK_GLOBAL_VIEW);
}
}
return {
mapRef,
mapReady,
mapError,
sourceStatuses: Object.values(sourceStatuses),
fitNetworkBounds: fitToNetworkBounds
};
}
function updateStatusFromSourceEvent(
event: MapSourceDataEvent,
status: WorkbenchSourceStatusValue,
setSourceStatuses: (updater: (current: Record<string, WorkbenchSourceStatus>) => Record<string, WorkbenchSourceStatus>) => void
) {
const sourceGroupId = SOURCE_GROUP_BY_ID[event.sourceId];
if (!sourceGroupId) {
return;
}
if (status === "online" && !event.isSourceLoaded) {
return;
}
updateSourceStatus(setSourceStatuses, sourceGroupId, status, getSourceStatusMessage(sourceGroupId, status));
}
function updateSourceStatus(
setSourceStatuses: (updater: (current: Record<string, WorkbenchSourceStatus>) => Record<string, WorkbenchSourceStatus>) => void,
sourceGroupId: string,
status: WorkbenchSourceStatusValue,
message: string
) {
setSourceStatuses((current) => {
const previous = current[sourceGroupId];
if (previous?.status === status && previous.message === message) {
return current;
}
return {
...current,
[sourceGroupId]: {
id: sourceGroupId,
sourceName: SOURCE_STATUS_LABELS[sourceGroupId] ?? sourceGroupId,
status,
message
}
};
});
}
function getSourceGroupFromErrorEvent(event: { sourceId?: string; error?: { message?: string } }) {
if (event.sourceId && SOURCE_GROUP_BY_ID[event.sourceId]) {
return SOURCE_GROUP_BY_ID[event.sourceId];
}
const message = event.error?.message ?? "";
if (message.includes("geoserver.waternetwork.cn")) {
return "geoserver-mvt";
}
if (message.includes("api.mapbox.com") || message.includes("mapbox")) {
return "mapbox-base";
}
return null;
}
function getSourceStatusMessage(sourceGroupId: string, status: WorkbenchSourceStatusValue) {
if (sourceGroupId === "mapbox-base") {
return status === "online" ? "底图瓦片已恢复正常。" : "底图瓦片请求中断,业务图层仍可继续使用。";
}
if (sourceGroupId === "geoserver-mvt") {
return status === "online" ? "排水管网矢量瓦片已加载。" : "排水管网瓦片请求中断,当前视图可能不完整。";
}
if (sourceGroupId === "annotation-source") {
return status === "online" ? "业务标注和影响范围已加载。" : "业务标注源加载中断,模拟标注可能暂不可见。";
}
return status === "online" ? "地图数据源已恢复正常。" : "地图数据源请求中断。";
}
function getSourceErrorMessage(sourceGroupId: string, errorMessage: string) {
if (sourceGroupId === "mapbox-base") {
return `底图瓦片请求失败:${errorMessage}`;
}
if (sourceGroupId === "geoserver-mvt") {
return `GeoServer 矢量瓦片请求失败:${errorMessage}`;
}
if (sourceGroupId === "annotation-source") {
return `业务标注源请求失败:${errorMessage}`;
}
return errorMessage;
}