feat(workbench): integrate runtime data and map tooling

This commit is contained in:
2026-09-22 10:45:46 +08:00
parent bf44cc0f25
commit bebcf2a06c
163 changed files with 15642 additions and 1312 deletions
+144 -28
View File
@@ -1,6 +1,6 @@
import "maplibre-gl/dist/maplibre-gl.css";
import type { FeatureCollection, Point } from "geojson";
import type { FeatureCollection, Geometry, Point } from "geojson";
import maplibregl, {
type GeoJSONSource,
type Map as MapLibreMap,
@@ -10,14 +10,14 @@ import maplibregl, {
import { useCallback, useEffect, useRef, useState, type RefObject } from "react";
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
import { env } from "@/shared/config/env";
import { fetchScadaDeviceCollection, type ScadaDevice } from "../api/scada-client";
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 } from "../map/camera";
import { fitNetworkBounds, type NetworkViewParams } from "../map/camera";
import {
filterWaterNetworkLayersBySourceIds,
waterNetworkBusinessLayers,
@@ -66,6 +66,9 @@ type UseWorkbenchMapOptions = {
onClearSelection: () => void;
onSelectFeature: (feature: DetailFeature) => void;
selectedFeature: DetailFeature | null;
simulationOverlay: FeatureCollection<Geometry> | null;
allowDemoSimulation: boolean;
networkView?: NetworkViewParams | null;
getAccessToken?: AccessTokenProvider;
};
@@ -75,7 +78,9 @@ type UseWorkbenchMapResult = {
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;
};
@@ -105,10 +110,16 @@ const SOURCE_GROUP_BY_ID: Record<string, string> = {
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.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({
containerRef,
@@ -117,16 +128,30 @@ export function useWorkbenchMap({
onClearSelection,
onSelectFeature,
selectedFeature,
simulationOverlay,
allowDemoSimulation,
networkView,
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>>({
@@ -172,6 +197,9 @@ export function useWorkbenchMap({
? current
: availability.availableSourceIds
);
setSourceFeatureCounts((current) => areFeatureCountsEqual(current, availability.featureCounts)
? current
: availability.featureCounts);
if (availability.failedSourceIds.length > 0) {
updateSourceStatus(
@@ -224,12 +252,8 @@ export function useWorkbenchMap({
setMapError(null);
const mapboxToken = env.TJWATER_MAPBOX_ACCESS_TOKEN || undefined;
const scadaAbortController = new AbortController();
let scadaRefreshTimeoutId: number | undefined;
updateSourceStatus(setSourceStatuses, "scada-api", "loading", "正在通过API加载SCADA设备。");
const scadaLoadPromise = fetchScadaDeviceCollection(
getAccessToken,
scadaAbortController.signal
);
scadaLoadPromiseRef.current = scadaLoadPromise;
const map = new maplibregl.Map({
container: containerRef.current,
style: createBaseStyle(mapboxToken),
@@ -254,6 +278,12 @@ export function useWorkbenchMap({
});
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);
const scadaRegistration = await registerScadaImages(map);
const activeScadaBusinessLayers =
@@ -305,27 +335,47 @@ export function useWorkbenchMap({
: "SCADA 图标不可用,已使用圆点图层降级显示。"
);
updateSourceStatus(setSourceStatuses, "annotation-source", "online", "业务标注源已就绪。");
fitNetworkBounds(map, WATER_NETWORK_GLOBAL_VIEW);
fitNetworkBounds(map, networkViewRef.current);
fittedNetworkViewRef.current = networkViewKey(networkViewRef.current);
});
void scadaLoadPromise.then((collection) => {
scadaCollectionRef.current = collection;
(map.getSource(SCADA_SOURCE_ID) as GeoJSONSource | undefined)?.setData(collection);
updateSourceStatus(
setSourceStatuses,
"scada-api",
"online",
`已通过API加载 ${collection.features.length} 个SCADA设备。`
const refreshScada = async () => {
const snapshotPromise = fetchScadaSnapshot(
getAccessToken,
scadaAbortController.signal
);
}).catch((error) => {
if (scadaAbortController.signal.aborted) return;
updateSourceStatus(
setSourceStatuses,
"scada-api",
"offline",
error instanceof Error ? error.message : "SCADA API加载失败。"
);
});
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();
let resizeFrame: number | null = null;
const resizeMap = () => map.resize();
@@ -375,6 +425,9 @@ export function useWorkbenchMap({
}
resizeObserver.disconnect();
scadaAbortController.abort();
if (scadaRefreshTimeoutId !== undefined) {
window.clearTimeout(scadaRefreshTimeoutId);
}
map.remove();
mapRef.current = null;
delete window.__waterNetworkMap;
@@ -387,6 +440,15 @@ export function useWorkbenchMap({
syncDataDependentGeoServerLayers(map, availableGeoServerSourceIds, layerVisibility);
}, [availableGeoServerSourceIds, layerVisibility, mapReady]);
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({
availableSourceIds: availableGeoServerSourceIds,
mapRef,
@@ -401,10 +463,16 @@ export function useWorkbenchMap({
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, WATER_NETWORK_GLOBAL_VIEW);
fitNetworkBounds(map, networkViewRef.current);
}
}
@@ -418,12 +486,60 @@ export function useWorkbenchMap({
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[],