feat(workbench): align v2 GIS and SCADA API
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user