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

792 lines
27 KiB
TypeScript

import "maplibre-gl/dist/maplibre-gl.css";
import type { FeatureCollection, Geometry, Point } from "geojson";
import * as maplibregl from "maplibre-gl";
import type {
GeoJSONSource,
Map as MapLibreMap,
MapSourceDataEvent,
StyleSpecification
} from "maplibre-gl";
import { useCallback, useEffect, useRef, useState, type RefObject } from "react";
import type { WorkbenchAccess } from "../api/request-context";
import { env } from "@/shared/config/env";
import { fetchScadaSnapshot, type ScadaDevice } from "../api/scada-client";
import type { DetailFeature } from "../types";
import {
SIMULATION_SOURCE_IDS,
simulationAnnotationLayers,
simulationSources
} from "../map/annotation-layers";
import { fitNetworkBounds, type NetworkViewParams } from "../map/camera";
import {
filterWaterNetworkLayersBySourceIds,
waterNetworkBusinessLayers,
waterNetworkHitLayers,
waterNetworkInteractionLayers
} from "../map/layers";
import {
DATA_DEPENDENT_GEOSERVER_SOURCE_IDS,
REQUIRED_GEOSERVER_SOURCE_IDS,
resolveGeoServerLayerAvailability
} from "../map/geoserver-layer-availability";
import { MAP_MAX_ZOOM } from "../map/map-layer-visuals";
import { createValueLabelLayers } from "../map/value-label";
import { setSimulationLayersVisibility } from "../map/simulation-layers";
import {
AVAILABLE_WATER_NETWORK_SOURCE_IDS,
GEOSERVER_WATER_NETWORK_SOURCE_IDS,
createBaseStyle,
createWaterNetworkSources,
WATER_NETWORK_GLOBAL_VIEW,
type GeoServerWaterNetworkSourceId,
type WaterNetworkSourceId
} from "../map/sources";
import {
SCADA_SOURCE_ID,
registerScadaImages,
scadaBusinessLayers,
scadaFallbackBusinessLayers,
scadaHitLayers,
scadaInteractionLayers
} from "../map/scada";
import { registerSupplyAssetImages } from "../map/supply-icons";
import { installCityScene } from "../map/city-scene-control";
import { useMapInteractions } from "./use-map-interactions";
import { useSimulationLayerVisibility } from "./use-simulation-layer-visibility";
declare global {
interface Window {
__waterNetworkMap?: MapLibreMap;
}
}
type UseWorkbenchMapOptions = {
featureInteractionsEnabled?: boolean;
containerRef: RefObject<HTMLDivElement | null>;
impactVisible: boolean;
layerVisibility: Record<string, boolean>;
onClearSelection: () => void;
onSelectFeature: (feature: DetailFeature) => void;
selectedFeature: DetailFeature | null;
simulationOverlay: FeatureCollection<Geometry> | null;
allowDemoSimulation: boolean;
networkView?: NetworkViewParams | null;
getAccessToken?: WorkbenchAccess;
geoserverWorkspace?: string;
};
type UseWorkbenchMapResult = {
mapRef: RefObject<MapLibreMap | null>;
mapReady: boolean;
mapError: string | null;
sourceStatuses: WorkbenchSourceStatus[];
availableSourceIds: WaterNetworkSourceId[];
sourceFeatureCounts: Partial<Record<WaterNetworkSourceId, number>>;
getScadaFeatures: (
deviceIds?: string[],
signal?: AbortSignal
) => Promise<FeatureCollection<Point, ScadaDevice>>;
scadaObservedAt: string | null;
refreshGeoServerLayers: () => boolean;
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 底图",
"tianditu-base": "天地图底图",
"geoserver-mvt": "GeoServer MVT",
"geoserver-catalog": "GeoServer 图层目录",
"scada-api": "SCADA API",
"scada-icons": "SCADA 图标",
"annotation-source": "业务标注源"
};
const SOURCE_GROUP_BY_ID: Record<string, string> = {
"mapbox-light": "mapbox-base",
"mapbox-satellite": "mapbox-base",
"tianditu-vector": "tianditu-base",
"tianditu-vector-annotation": "tianditu-base",
"tianditu-image": "tianditu-base",
"tianditu-image-annotation": "tianditu-base",
...Object.fromEntries(
GEOSERVER_WATER_NETWORK_SOURCE_IDS.map((sourceId) => [sourceId, "geoserver-mvt"])
),
[SIMULATION_SOURCE_IDS.impactArea]: "annotation-source",
[SIMULATION_SOURCE_IDS.annotations]: "annotation-source",
[SIMULATION_SOURCE_IDS.backendAnalysis]: "annotation-source"
};
const GEOSERVER_LAYER_PROBE_TIMEOUT_MS = 8_000;
const SCADA_REFRESH_INTERVAL_MS = 15_000;
const EMPTY_SIMULATION_COLLECTION: FeatureCollection = {
type: "FeatureCollection",
features: []
};
export function useWorkbenchMap({
featureInteractionsEnabled = true,
containerRef,
impactVisible,
layerVisibility,
onClearSelection,
onSelectFeature,
selectedFeature,
simulationOverlay,
allowDemoSimulation,
networkView,
geoserverWorkspace,
getAccessToken
}: UseWorkbenchMapOptions): UseWorkbenchMapResult {
const mapRef = useRef<MapLibreMap | null>(null);
const impactVisibleRef = useRef(impactVisible);
const simulationOverlayRef = useRef(simulationOverlay);
const allowDemoSimulationRef = useRef(allowDemoSimulation);
const networkViewRef = useRef<NetworkViewParams>(networkView ?? WATER_NETWORK_GLOBAL_VIEW);
const fittedNetworkViewRef = useRef<string | null>(null);
simulationOverlayRef.current = simulationOverlay;
allowDemoSimulationRef.current = allowDemoSimulation;
networkViewRef.current = networkView ?? WATER_NETWORK_GLOBAL_VIEW;
const [mapReady, setMapReady] = useState(false);
const [mapError, setMapError] = useState<string | null>(null);
const [sourceStatuses, setSourceStatuses] = useState<Record<string, WorkbenchSourceStatus>>({});
const [scadaObservedAt, setScadaObservedAt] = useState<string | null>(null);
const [availableGeoServerSourceIds, setAvailableGeoServerSourceIds] = useState<
GeoServerWaterNetworkSourceId[]
>(() => [...REQUIRED_GEOSERVER_SOURCE_IDS]);
const [sourceFeatureCounts, setSourceFeatureCounts] = useState<
Partial<Record<WaterNetworkSourceId, number>>
>({});
const [geoServerLayerRefreshRevision, setGeoServerLayerRefreshRevision] = useState(0);
const geoServerLayerProbeInFlightRef = useRef(false);
const scadaCollectionRef = useRef<FeatureCollection<Point, ScadaDevice>>({
type: "FeatureCollection",
features: []
});
const scadaLoadPromiseRef = useRef<Promise<FeatureCollection<Point, ScadaDevice>>>(
Promise.resolve(scadaCollectionRef.current)
);
const getScadaFeatures = useCallback(async (deviceIds?: string[], signal?: AbortSignal) => {
const collection = await scadaLoadPromiseRef.current;
if (signal?.aborted) throw new DOMException("The operation was aborted", "AbortError");
if (!deviceIds) return collection;
const selectedIds = new Set(deviceIds);
return {
type: "FeatureCollection" as const,
features: collection.features.filter((feature) => selectedIds.has(String(feature.id)))
};
}, []);
const refreshGeoServerLayers = useCallback(() => {
if (geoServerLayerProbeInFlightRef.current) return false;
geoServerLayerProbeInFlightRef.current = true;
setGeoServerLayerRefreshRevision((revision) => revision + 1);
return true;
}, []);
useEffect(() => {
const lifecycleAbortController = new AbortController();
const requestAbortController = new AbortController();
const probeTimeout = window.setTimeout(
() => requestAbortController.abort(),
GEOSERVER_LAYER_PROBE_TIMEOUT_MS
);
geoServerLayerProbeInFlightRef.current = true;
void resolveGeoServerLayerAvailability(requestAbortController.signal, geoserverWorkspace)
.then((availability) => {
if (lifecycleAbortController.signal.aborted) return;
setAvailableGeoServerSourceIds((current) =>
current?.join("|") === availability.availableSourceIds.join("|")
? current
: availability.availableSourceIds
);
setSourceFeatureCounts((current) =>
areFeatureCountsEqual(current, availability.featureCounts)
? current
: availability.featureCounts
);
if (availability.failedSourceIds.length > 0) {
updateSourceStatus(
setSourceStatuses,
"geoserver-catalog",
"degraded",
`无法确认 ${availability.failedSourceIds.join("、")} 图层是否有数据,已隐藏对应入口。`
);
} else {
clearSourceStatus(setSourceStatuses, "geoserver-catalog");
}
})
.finally(() => {
window.clearTimeout(probeTimeout);
if (!lifecycleAbortController.signal.aborted) {
geoServerLayerProbeInFlightRef.current = false;
}
});
return () => {
lifecycleAbortController.abort();
requestAbortController.abort();
geoServerLayerProbeInFlightRef.current = false;
window.clearTimeout(probeTimeout);
};
}, [geoServerLayerRefreshRevision, geoserverWorkspace]);
useEffect(() => {
impactVisibleRef.current = impactVisible;
}, [impactVisible]);
useEffect(() => {
if (
selectedFeature &&
DATA_DEPENDENT_GEOSERVER_SOURCE_IDS.includes(
selectedFeature.layer as (typeof DATA_DEPENDENT_GEOSERVER_SOURCE_IDS)[number]
) &&
!availableGeoServerSourceIds.includes(selectedFeature.layer as GeoServerWaterNetworkSourceId)
) {
onClearSelection();
}
}, [availableGeoServerSourceIds, onClearSelection, selectedFeature]);
useEffect(() => {
if (!containerRef.current || mapRef.current) {
return;
}
setMapReady(false);
setMapError(null);
const mapboxToken = env.TJWATER_MAPBOX_ACCESS_TOKEN || undefined;
const tiandituToken = env.TJWATER_TIANDITU_TOKEN || undefined;
const scadaAbortController = new AbortController();
let scadaRefreshTimeoutId: number | undefined;
updateSourceStatus(setSourceStatuses, "scada-api", "loading", "正在通过API加载SCADA设备。");
const map = new maplibregl.Map({
container: containerRef.current,
style: createBaseStyle(mapboxToken, tiandituToken),
pitch: 0,
bearing: 0,
dragRotate: false,
touchPitch: false,
maxZoom: MAP_MAX_ZOOM,
canvasContextAttributes: {
antialias: true,
preserveDrawingBuffer: true
},
trackResize: false,
attributionControl: false
});
// MapLibre throttles automatic resizing to 50ms, which lags behind the dock edge.
const resizeObserver = new ResizeObserver(() => {
const container = map.getContainer();
const canvas = map.getCanvas();
if (!container.clientWidth || !container.clientHeight) return;
if (
canvas.clientWidth === container.clientWidth &&
canvas.clientHeight === container.clientHeight
) return;
map.resize();
// Resizing clears the drawing buffer; repaint in the same layout cycle.
map.redraw();
});
resizeObserver.observe(map.getContainer());
window.__waterNetworkMap = map;
let removeCityScene: (() => void) | undefined;
map.on("load", async () => {
map.resize();
(map.getSource(SCADA_SOURCE_ID) as GeoJSONSource | undefined)?.setData(
scadaCollectionRef.current
);
const sources = createWaterNetworkSources(REQUIRED_GEOSERVER_SOURCE_IDS, geoserverWorkspace);
REQUIRED_GEOSERVER_SOURCE_IDS.forEach((sourceId) => {
const source = sources[sourceId];
if (source) map.addSource(sourceId, source);
});
map.addSource(SIMULATION_SOURCE_IDS.impactArea, simulationSources.impactArea);
map.addSource(SIMULATION_SOURCE_IDS.annotations, simulationSources.annotations);
map.addSource(SIMULATION_SOURCE_IDS.backendAnalysis, simulationSources.backendAnalysis);
syncSimulationSourceData(map, simulationOverlayRef.current, allowDemoSimulationRef.current);
await registerSupplyAssetImages(map, scadaAbortController.signal);
if (scadaAbortController.signal.aborted) return;
const scadaRegistration = await registerScadaImages(map, scadaAbortController.signal);
if (scadaAbortController.signal.aborted) return;
const activeScadaBusinessLayers =
scadaRegistration.status === "unavailable"
? scadaFallbackBusinessLayers
: scadaBusinessLayers;
simulationAnnotationLayers
.filter((layer) => layer.id === "simulation-impact-fill")
.forEach((layer) => map.addLayer(layer));
filterWaterNetworkLayersBySourceIds(
waterNetworkBusinessLayers,
REQUIRED_GEOSERVER_SOURCE_IDS
).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));
filterWaterNetworkLayersBySourceIds(
waterNetworkInteractionLayers,
REQUIRED_GEOSERVER_SOURCE_IDS
).forEach((layer) => map.addLayer(layer));
scadaInteractionLayers.forEach((layer) => map.addLayer(layer));
filterWaterNetworkLayersBySourceIds(
createValueLabelLayers(),
REQUIRED_GEOSERVER_SOURCE_IDS
).forEach((layer) => map.addLayer(layer));
simulationAnnotationLayers
.filter((layer) => layer.type === "symbol")
.forEach((layer) => map.addLayer(layer));
filterWaterNetworkLayersBySourceIds(
waterNetworkHitLayers,
REQUIRED_GEOSERVER_SOURCE_IDS
).forEach((layer) => map.addLayer(layer));
scadaHitLayers.forEach((layer) => map.addLayer(layer));
removeCityScene = installCityScene(map);
setSimulationLayersVisibility(map, impactVisibleRef.current, false);
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, networkViewRef.current);
fittedNetworkViewRef.current = networkViewKey(networkViewRef.current);
});
const refreshScada = async () => {
const snapshotPromise = fetchScadaSnapshot(getAccessToken, scadaAbortController.signal);
scadaLoadPromiseRef.current = snapshotPromise
.then((snapshot) => snapshot.collection)
.catch(() => scadaCollectionRef.current);
try {
const snapshot = await snapshotPromise;
if (scadaAbortController.signal.aborted) return;
scadaCollectionRef.current = snapshot.collection;
setScadaObservedAt(snapshot.observedAt);
(map.getSource(SCADA_SOURCE_ID) as GeoJSONSource | undefined)?.setData(snapshot.collection);
updateSourceStatus(
setSourceStatuses,
"scada-api",
snapshot.telemetryStatus === "ready" ? "online" : "degraded",
getScadaStatusMessage(snapshot)
);
} catch (error) {
if (scadaAbortController.signal.aborted) return;
setScadaObservedAt(null);
updateSourceStatus(
setSourceStatuses,
"scada-api",
scadaCollectionRef.current.features.length > 0 ? "degraded" : "offline",
error instanceof Error ? error.message : "SCADA API加载失败。"
);
} finally {
if (!scadaAbortController.signal.aborted) {
scadaRefreshTimeoutId = window.setTimeout(refreshScada, SCADA_REFRESH_INTERVAL_MS);
}
}
};
void refreshScada();
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 () => {
resizeObserver.disconnect();
scadaAbortController.abort();
if (scadaRefreshTimeoutId !== undefined) {
window.clearTimeout(scadaRefreshTimeoutId);
}
removeCityScene?.();
map.remove();
mapRef.current = null;
delete window.__waterNetworkMap;
};
}, [containerRef, getAccessToken, geoserverWorkspace]);
useEffect(() => {
const map = mapRef.current;
if (!mapReady || !map) return;
syncDataDependentGeoServerLayers(map, availableGeoServerSourceIds, layerVisibility, geoserverWorkspace);
}, [availableGeoServerSourceIds, layerVisibility, mapReady, geoserverWorkspace]);
useEffect(() => {
const map = mapRef.current;
if (!mapReady || !map || !networkView) return;
const key = networkViewKey(networkView);
if (fittedNetworkViewRef.current === key) return;
fitNetworkBounds(map, networkView);
fittedNetworkViewRef.current = key;
}, [mapReady, networkView]);
useMapInteractions({
enabled: featureInteractionsEnabled,
availableSourceIds: availableGeoServerSourceIds,
mapRef,
mapReady,
onSelectFeature,
selectedFeature
});
useSimulationLayerVisibility({
mapRef,
mapReady,
visible: impactVisible
});
useEffect(() => {
const map = mapRef.current;
if (!map || !mapReady) return;
syncSimulationSourceData(map, simulationOverlay, allowDemoSimulation);
}, [allowDemoSimulation, mapReady, simulationOverlay]);
function fitToNetworkBounds() {
const map = mapRef.current;
if (map) {
fitNetworkBounds(map, networkViewRef.current);
}
}
return {
mapRef,
mapReady,
mapError,
sourceStatuses: Object.values(sourceStatuses),
availableSourceIds: AVAILABLE_WATER_NETWORK_SOURCE_IDS.filter(
(sourceId) =>
sourceId === "scada" ||
availableGeoServerSourceIds.includes(sourceId as GeoServerWaterNetworkSourceId)
),
sourceFeatureCounts,
getScadaFeatures,
scadaObservedAt,
refreshGeoServerLayers,
fitNetworkBounds: fitToNetworkBounds
};
}
function areFeatureCountsEqual(
current: Partial<Record<WaterNetworkSourceId, number>>,
next: Partial<Record<WaterNetworkSourceId, number>>
) {
const currentEntries = Object.entries(current);
const nextEntries = Object.entries(next);
return (
currentEntries.length === nextEntries.length &&
nextEntries.every(([sourceId, count]) => current[sourceId as WaterNetworkSourceId] === count)
);
}
function networkViewKey(view: NetworkViewParams) {
return view.bbox3857.join(",");
}
function getScadaStatusMessage(snapshot: Awaited<ReturnType<typeof fetchScadaSnapshot>>) {
const deviceSummary = `已通过API加载 ${snapshot.collection.features.length} 个SCADA设备`;
if (snapshot.telemetryStatus === "ready") {
return `${deviceSummary},其中 ${snapshot.observedDeviceCount} 个带有最近15分钟监测值。`;
}
if (snapshot.telemetryStatus === "unavailable") {
return `${deviceSummary};实时监测值暂不可用,保留设备元数据。`;
}
return `${deviceSummary};最近15分钟没有监测值,保留设备元数据。`;
}
function syncSimulationSourceData(
map: MapLibreMap,
backendOverlay: FeatureCollection<Geometry> | null,
allowDemoSimulation: boolean
) {
const useBackendOverlay = Boolean(backendOverlay?.features.length);
(map.getSource(SIMULATION_SOURCE_IDS.backendAnalysis) as GeoJSONSource | undefined)?.setData(
useBackendOverlay ? backendOverlay! : EMPTY_SIMULATION_COLLECTION
);
(map.getSource(SIMULATION_SOURCE_IDS.impactArea) as GeoJSONSource | undefined)?.setData(
useBackendOverlay || !allowDemoSimulation
? EMPTY_SIMULATION_COLLECTION
: simulationSources.impactArea.data
);
(map.getSource(SIMULATION_SOURCE_IDS.annotations) as GeoJSONSource | undefined)?.setData(
useBackendOverlay || !allowDemoSimulation
? EMPTY_SIMULATION_COLLECTION
: simulationSources.annotations.data
);
}
function syncDataDependentGeoServerLayers(
map: MapLibreMap,
availableSourceIds: readonly GeoServerWaterNetworkSourceId[],
layerVisibility: Record<string, boolean>,
workspace?: string
) {
const availableSourceIdSet = new Set<GeoServerWaterNetworkSourceId>(availableSourceIds);
DATA_DEPENDENT_GEOSERVER_SOURCE_IDS.forEach((sourceId) => {
if (availableSourceIdSet.has(sourceId)) return;
map
.getStyle()
.layers.filter((layer) => "source" in layer && layer.source === sourceId)
.reverse()
.forEach((layer) => map.removeLayer(layer.id));
if (map.getSource(sourceId)) map.removeSource(sourceId);
});
const requestedOptionalSourceIds = DATA_DEPENDENT_GEOSERVER_SOURCE_IDS.filter((sourceId) =>
availableSourceIdSet.has(sourceId)
);
const sources = createWaterNetworkSources(requestedOptionalSourceIds, workspace);
requestedOptionalSourceIds.forEach((sourceId) => {
if (!map.getSource(sourceId) && sources[sourceId]) {
map.addSource(sourceId, sources[sourceId]);
}
});
addMissingDataDependentLayers(
map,
waterNetworkBusinessLayers,
[...scadaBusinessLayers, ...scadaFallbackBusinessLayers].map((layer) => layer.id),
availableSourceIdSet,
layerVisibility
);
addMissingDataDependentLayers(
map,
waterNetworkInteractionLayers,
scadaInteractionLayers.map((layer) => layer.id),
availableSourceIdSet,
layerVisibility
);
addMissingDataDependentLayers(
map,
createValueLabelLayers(),
[
...simulationAnnotationLayers.filter((layer) => layer.type === "symbol"),
...waterNetworkHitLayers,
...scadaHitLayers
].map((layer) => layer.id),
availableSourceIdSet,
layerVisibility
);
addMissingDataDependentLayers(
map,
waterNetworkHitLayers,
scadaHitLayers.map((layer) => layer.id),
availableSourceIdSet,
layerVisibility
);
}
function addMissingDataDependentLayers(
map: MapLibreMap,
layers: StyleSpecification["layers"],
beforeLayerCandidates: readonly string[],
availableSourceIdSet: ReadonlySet<GeoServerWaterNetworkSourceId>,
layerVisibility: Record<string, boolean>,
workspace?: string
) {
const beforeLayerId = beforeLayerCandidates.find((layerId) => map.getLayer(layerId));
layers.forEach((layer) => {
if (!("source" in layer) || typeof layer.source !== "string") return;
const sourceId = layer.source as GeoServerWaterNetworkSourceId;
if (
!DATA_DEPENDENT_GEOSERVER_SOURCE_IDS.includes(
sourceId as (typeof DATA_DEPENDENT_GEOSERVER_SOURCE_IDS)[number]
) ||
!availableSourceIdSet.has(sourceId) ||
map.getLayer(layer.id)
) {
return;
}
map.addLayer(layer, beforeLayerId);
if (layerVisibility[sourceId] === false) {
map.setLayoutProperty(layer.id, "visibility", "none");
}
});
}
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 clearSourceStatus(
setSourceStatuses: (
updater: (
current: Record<string, WorkbenchSourceStatus>
) => Record<string, WorkbenchSourceStatus>
) => void,
sourceGroupId: string
) {
setSourceStatuses((current) => {
if (!current[sourceGroupId]) return current;
const next = { ...current };
delete next[sourceGroupId];
return next;
});
}
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";
}
if (message.includes("tianditu.gov.cn") || message.includes("tianditu")) {
return "tianditu-base";
}
return null;
}
function getSourceStatusMessage(sourceGroupId: string, status: WorkbenchSourceStatusValue) {
if (["mapbox-base", "tianditu-base"].includes(sourceGroupId)) {
return status === "online"
? "底图瓦片已恢复正常。"
: "底图瓦片请求中断,业务图层仍可继续使用。";
}
if (sourceGroupId === "geoserver-mvt") {
return status === "online"
? "供水管网矢量瓦片已加载。"
: "供水管网瓦片请求中断,当前视图可能不完整。";
}
if (sourceGroupId === "annotation-source") {
return status === "online"
? "业务标注和影响范围已加载。"
: "业务标注源加载中断,模拟标注可能暂不可见。";
}
if (sourceGroupId === "scada-api") {
return status === "online"
? "SCADA设备已通过API加载。"
: "SCADA API请求中断,管网图层仍可继续使用。";
}
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}`;
}
if (sourceGroupId === "scada-api") {
return `SCADA API请求失败:${errorMessage}`;
}
return errorMessage;
}