feat(workbench): align v2 GIS and SCADA API
Generic Container CI/CD / test-build-publish (push) Successful in 26s
Frontend CI/CD / build-test-publish-and-deploy (push) Successful in 26s

This commit is contained in:
2026-09-08 18:18:40 +08:00
parent 28287a1447
commit f9c71cc076
45 changed files with 1025 additions and 254 deletions
@@ -7,7 +7,7 @@ import {
import { INTERACTIVE_HIT_LAYER_IDS } from "../map/layers";
describe("map feature interaction state", () => {
const feature = { source: "junctions", sourceLayer: "geo_junctions_mat", id: "junction-7" } as const;
const feature = { source: "junctions", sourceLayer: "junctions", id: "junction-7" } as const;
it("maps selected business features to promoted vector feature references", () => {
expect(toMapFeatureReference({ id: "junction-7", layer: "junctions" })).toEqual(feature);
@@ -18,7 +18,7 @@ describe("map feature interaction state", () => {
const map = { setFeatureState: vi.fn(), removeFeatureState: vi.fn() };
setMapFeatureInteractionState(map, feature, { selected: true, hovered: false });
expect(map.setFeatureState).toHaveBeenCalledWith(
{ source: "junctions", sourceLayer: "geo_junctions_mat", id: "junction-7" },
{ source: "junctions", sourceLayer: "junctions", id: "junction-7" },
{ selected: true, hovered: false }
);
});
@@ -27,7 +27,7 @@ describe("map feature interaction state", () => {
const map = { setFeatureState: vi.fn(), removeFeatureState: vi.fn() };
clearMapFeatureInteractionState(map, feature, "selected");
expect(map.removeFeatureState).toHaveBeenCalledWith(
{ source: "junctions", sourceLayer: "geo_junctions_mat", id: "junction-7" },
{ source: "junctions", sourceLayer: "junctions", id: "junction-7" },
"selected"
);
});
@@ -38,6 +38,8 @@ describe("map feature interaction state", () => {
"junctions-hit",
"valves-hit",
"reservoirs-hit",
"pumps-hit",
"tanks-hit",
"scada-hit"
]);
});
@@ -12,7 +12,7 @@ import { SOURCE_LAYERS, type WaterNetworkSourceId } from "../map/sources";
export type MapFeatureInteractionState = {
source: WaterNetworkSourceId;
sourceLayer: string;
sourceLayer?: string;
id: string;
hovered: boolean;
selected: boolean;
@@ -25,7 +25,9 @@ export function toMapFeatureReference(
feature: Pick<DetailFeature, "id" | "layer"> | null
): FeatureReference | null {
if (!feature?.id) return null;
return { source: feature.layer, sourceLayer: SOURCE_LAYERS[feature.layer], id: feature.id };
return feature.layer === "scada"
? { source: feature.layer, id: feature.id }
: { source: feature.layer, sourceLayer: SOURCE_LAYERS[feature.layer], id: feature.id };
}
export function setMapFeatureInteractionState(
@@ -33,10 +35,7 @@ export function setMapFeatureInteractionState(
feature: FeatureReference,
state: Partial<Pick<MapFeatureInteractionState, "hovered" | "selected">>
) {
map.setFeatureState(
{ source: feature.source, sourceLayer: feature.sourceLayer, id: feature.id },
state
);
map.setFeatureState(toFeatureStateTarget(feature), state);
}
export function clearMapFeatureInteractionState(
@@ -44,10 +43,7 @@ export function clearMapFeatureInteractionState(
feature: FeatureReference,
key?: "hovered" | "selected"
) {
map.removeFeatureState(
{ source: feature.source, sourceLayer: feature.sourceLayer, id: feature.id },
key
);
map.removeFeatureState(toFeatureStateTarget(feature), key);
}
type UseMapInteractionsOptions = {
@@ -81,7 +77,9 @@ export function useMapInteractions({
const id = feature && getFeatureId(feature);
if (!source || !id) return;
const next = { source, sourceLayer: SOURCE_LAYERS[source], id };
const next = source === "scada"
? { source, id }
: { source, sourceLayer: SOURCE_LAYERS[source], id };
if (selectedFeature?.id === id && selectedFeature.layer === source) {
clearHoveredFeature();
return;
@@ -127,3 +125,9 @@ export function useMapInteractions({
};
}, [mapReady, mapRef, selectedFeature]);
}
function toFeatureStateTarget(feature: FeatureReference) {
return feature.sourceLayer
? { source: feature.source, sourceLayer: feature.sourceLayer, id: feature.id }
: { source: feature.source, id: feature.id };
}
@@ -1,4 +1,6 @@
import type { FeatureCollection, Point } from "geojson";
import type { Map as MapLibreMap } from "maplibre-gl";
import type { ScadaDevice } from "../api/scada-client";
import { useEffect, useRef, useSyncExternalStore, type RefObject } from "react";
import { getResponsiveWorkbenchPadding } from "../map/camera";
import { WorkbenchMapController } from "../map/workbench-map-controller";
@@ -9,7 +11,8 @@ export function useWorkbenchMapController({
leftPanelOpen,
rightPanelOpen,
conditionPanelExpanded = false,
agentPanelWidth
agentPanelWidth,
getScadaFeatures
}: {
mapRef: RefObject<MapLibreMap | null>;
mapReady: boolean;
@@ -17,6 +20,7 @@ export function useWorkbenchMapController({
rightPanelOpen: boolean;
conditionPanelExpanded?: boolean;
agentPanelWidth?: number;
getScadaFeatures?: (deviceIds?: string[], signal?: AbortSignal) => Promise<FeatureCollection<Point, ScadaDevice>>;
}) {
const valuesRef = useRef({
mapReady,
@@ -48,7 +52,8 @@ export function useWorkbenchMapController({
valuesRef.current.agentPanelWidth
)
: { top: 48, right: 48, bottom: 48, left: 48 };
}
},
getScadaFeatures
});
}
@@ -1,8 +1,11 @@
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 { FeatureCollection, Point } from "geojson";
import maplibregl, { type GeoJSONSource, type Map as MapLibreMap, type MapSourceDataEvent } from "maplibre-gl";
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 type { DetailFeature } from "../types";
import {
SIMULATION_SOURCE_IDS,
@@ -20,11 +23,13 @@ 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
} from "../map/sources";
import {
SCADA_SOURCE_ID,
registerScadaImages,
scadaBusinessLayers,
scadaFallbackBusinessLayers,
@@ -46,6 +51,7 @@ type UseWorkbenchMapOptions = {
impactVisible: boolean;
onSelectFeature: (feature: DetailFeature) => void;
selectedFeature: DetailFeature | null;
getAccessToken?: AccessTokenProvider;
};
type UseWorkbenchMapResult = {
@@ -53,6 +59,7 @@ type UseWorkbenchMapResult = {
mapReady: boolean;
mapError: string | null;
sourceStatuses: WorkbenchSourceStatus[];
getScadaFeatures: (deviceIds?: string[], signal?: AbortSignal) => Promise<FeatureCollection<Point, ScadaDevice>>;
fitNetworkBounds: () => void;
};
@@ -68,6 +75,7 @@ export type WorkbenchSourceStatus = {
const SOURCE_STATUS_LABELS: Record<string, string> = {
"mapbox-base": "Mapbox 底图",
"geoserver-mvt": "GeoServer MVT",
"scada-api": "SCADA API",
"scada-icons": "SCADA 图标",
"annotation-source": "业务标注源"
};
@@ -76,7 +84,7 @@ 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"])
GEOSERVER_WATER_NETWORK_SOURCE_IDS.map((sourceId) => [sourceId, "geoserver-mvt"])
),
[SIMULATION_SOURCE_IDS.impactArea]: "annotation-source",
[SIMULATION_SOURCE_IDS.annotations]: "annotation-source"
@@ -86,13 +94,32 @@ export function useWorkbenchMap({
containerRef,
impactVisible,
onSelectFeature,
selectedFeature
selectedFeature,
getAccessToken
}: UseWorkbenchMapOptions): UseWorkbenchMapResult {
const mapRef = useRef<MapLibreMap | null>(null);
const impactVisibleRef = useRef(impactVisible);
const [mapReady, setMapReady] = useState(false);
const [mapError, setMapError] = useState<string | null>(null);
const [sourceStatuses, setSourceStatuses] = useState<Record<string, WorkbenchSourceStatus>>({});
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)))
};
}, []);
useEffect(() => {
impactVisibleRef.current = impactVisible;
@@ -104,6 +131,13 @@ export function useWorkbenchMap({
}
const mapboxToken = env.TJWATER_MAPBOX_ACCESS_TOKEN || undefined;
const scadaAbortController = new AbortController();
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),
@@ -118,8 +152,11 @@ export function useWorkbenchMap({
map.on("load", async () => {
map.resize();
(map.getSource(SCADA_SOURCE_ID) as GeoJSONSource | undefined)?.setData(
scadaCollectionRef.current
);
const sources = createWaterNetworkSources();
AVAILABLE_WATER_NETWORK_SOURCE_IDS.forEach((sourceId) =>
GEOSERVER_WATER_NETWORK_SOURCE_IDS.forEach((sourceId) =>
map.addSource(sourceId, sources[sourceId])
);
map.addSource(SIMULATION_SOURCE_IDS.impactArea, simulationSources.impactArea);
@@ -166,6 +203,25 @@ export function useWorkbenchMap({
fitNetworkBounds(map, WATER_NETWORK_GLOBAL_VIEW);
});
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设备。`
);
}).catch((error) => {
if (scadaAbortController.signal.aborted) return;
updateSourceStatus(
setSourceStatuses,
"scada-api",
"offline",
error instanceof Error ? error.message : "SCADA API加载失败。"
);
});
let resizeFrame: number | null = null;
const resizeMap = () => map.resize();
const resizeObserver = new ResizeObserver(() => {
@@ -213,11 +269,12 @@ export function useWorkbenchMap({
window.cancelAnimationFrame(resizeFrame);
}
resizeObserver.disconnect();
scadaAbortController.abort();
map.remove();
mapRef.current = null;
delete window.__waterNetworkMap;
};
}, [containerRef]);
}, [containerRef, getAccessToken]);
useMapInteractions({
mapRef,
@@ -244,6 +301,7 @@ export function useWorkbenchMap({
mapReady,
mapError,
sourceStatuses: Object.values(sourceStatuses),
getScadaFeatures,
fitNetworkBounds: fitToNetworkBounds
};
}
@@ -338,6 +396,12 @@ function getSourceStatusMessage(sourceGroupId: string, status: WorkbenchSourceSt
: "业务标注源加载中断,模拟标注可能暂不可见。";
}
if (sourceGroupId === "scada-api") {
return status === "online"
? "SCADA设备已通过API加载。"
: "SCADA API请求中断,管网图层仍可继续使用。";
}
return status === "online" ? "地图数据源已恢复正常。" : "地图数据源请求中断。";
}
@@ -354,5 +418,9 @@ function getSourceErrorMessage(sourceGroupId: string, errorMessage: string) {
return `业务标注源请求失败:${errorMessage}`;
}
if (sourceGroupId === "scada-api") {
return `SCADA API请求失败:${errorMessage}`;
}
return errorMessage;
}