feat: initialize TJWater WebGIS frontend
This commit is contained in:
@@ -0,0 +1,338 @@
|
||||
"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 { env } from "@/shared/config/env";
|
||||
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 {
|
||||
AVAILABLE_WATER_NETWORK_SOURCE_IDS,
|
||||
createBaseStyle,
|
||||
createWaterNetworkSources,
|
||||
WATER_NETWORK_GLOBAL_VIEW
|
||||
} from "../map/sources";
|
||||
import {
|
||||
registerScadaImages,
|
||||
scadaBusinessLayers,
|
||||
scadaFallbackBusinessLayers,
|
||||
scadaHitLayers,
|
||||
scadaInteractionLayers
|
||||
} from "../map/scada";
|
||||
import { registerSupplyAssetImages } from "../map/supply-icons";
|
||||
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",
|
||||
...Object.fromEntries(AVAILABLE_WATER_NETWORK_SOURCE_IDS.map((sourceId) => [sourceId, "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 = env.TJWATER_MAPBOX_ACCESS_TOKEN || undefined;
|
||||
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();
|
||||
AVAILABLE_WATER_NETWORK_SOURCE_IDS.forEach((sourceId) => map.addSource(sourceId, sources[sourceId]));
|
||||
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() });
|
||||
await registerSupplyAssetImages(map);
|
||||
const scadaRegistration = await registerScadaImages(map);
|
||||
const activeScadaBusinessLayers = scadaRegistration.status === "unavailable"
|
||||
? scadaFallbackBusinessLayers
|
||||
: scadaBusinessLayers;
|
||||
simulationAnnotationLayers.filter((layer) => layer.id === "simulation-impact-fill").forEach((layer) => map.addLayer(layer));
|
||||
waterNetworkBusinessLayers.forEach((layer) => map.addLayer(layer));
|
||||
activeScadaBusinessLayers.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));
|
||||
scadaInteractionLayers.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 }
|
||||
});
|
||||
simulationAnnotationLayers.filter((layer) => layer.type === "symbol").forEach((layer) => map.addLayer(layer));
|
||||
waterNetworkHitLayers.forEach((layer) => map.addLayer(layer));
|
||||
scadaHitLayers.forEach((layer) => map.addLayer(layer));
|
||||
setSimulationLayersVisibility(map, impactVisibleRef.current);
|
||||
setMapReady(true);
|
||||
updateSourceStatus(
|
||||
setSourceStatuses,
|
||||
"scada-icons",
|
||||
scadaRegistration.status === "ready" ? "online" : scadaRegistration.status === "degraded" ? "degraded" : "offline",
|
||||
scadaRegistration.status === "ready"
|
||||
? "SCADA 图标已就绪。"
|
||||
: scadaRegistration.status === "degraded"
|
||||
? "部分 SCADA 图标加载失败,已使用默认图标。"
|
||||
: "SCADA 图标不可用,已使用圆点图层降级显示。"
|
||||
);
|
||||
updateSourceStatus(setSourceStatuses, "annotation-source", "online", "业务标注源已就绪。");
|
||||
fitNetworkBounds(map, WATER_NETWORK_GLOBAL_VIEW);
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user