feat(workbench): integrate runtime data and map tooling
This commit is contained in:
@@ -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[],
|
||||
|
||||
Reference in New Issue
Block a user