fix: hide unavailable GeoServer layers
This commit is contained in:
@@ -47,6 +47,7 @@ export function clearMapFeatureInteractionState(
|
|||||||
}
|
}
|
||||||
|
|
||||||
type UseMapInteractionsOptions = {
|
type UseMapInteractionsOptions = {
|
||||||
|
availableSourceIds: readonly WaterNetworkSourceId[];
|
||||||
mapRef: RefObject<MapLibreMap | null>;
|
mapRef: RefObject<MapLibreMap | null>;
|
||||||
mapReady: boolean;
|
mapReady: boolean;
|
||||||
onSelectFeature: (feature: DetailFeature) => void;
|
onSelectFeature: (feature: DetailFeature) => void;
|
||||||
@@ -54,6 +55,7 @@ type UseMapInteractionsOptions = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function useMapInteractions({
|
export function useMapInteractions({
|
||||||
|
availableSourceIds,
|
||||||
mapRef,
|
mapRef,
|
||||||
mapReady,
|
mapReady,
|
||||||
onSelectFeature,
|
onSelectFeature,
|
||||||
@@ -102,7 +104,7 @@ export function useMapInteractions({
|
|||||||
if (feature) onSelectFeature(toDetailFeature(feature));
|
if (feature) onSelectFeature(toDetailFeature(feature));
|
||||||
};
|
};
|
||||||
|
|
||||||
const hitLayerIds = [...INTERACTIVE_HIT_LAYER_IDS];
|
const hitLayerIds = INTERACTIVE_HIT_LAYER_IDS.filter((layerId) => map.getLayer(layerId));
|
||||||
map.on("mousemove", hitLayerIds, handleMouseMove);
|
map.on("mousemove", hitLayerIds, handleMouseMove);
|
||||||
map.on("mouseleave", hitLayerIds, handleMouseLeave);
|
map.on("mouseleave", hitLayerIds, handleMouseLeave);
|
||||||
map.on("click", hitLayerIds, handleClick);
|
map.on("click", hitLayerIds, handleClick);
|
||||||
@@ -113,17 +115,21 @@ export function useMapInteractions({
|
|||||||
map.off("mouseleave", hitLayerIds, handleMouseLeave);
|
map.off("mouseleave", hitLayerIds, handleMouseLeave);
|
||||||
map.off("click", hitLayerIds, handleClick);
|
map.off("click", hitLayerIds, handleClick);
|
||||||
};
|
};
|
||||||
}, [mapRef, mapReady, onSelectFeature, selectedFeature]);
|
}, [availableSourceIds, mapRef, mapReady, onSelectFeature, selectedFeature]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const map = mapRef.current;
|
const map = mapRef.current;
|
||||||
if (!mapReady || !map) return;
|
if (!mapReady || !map) return;
|
||||||
const next = toMapFeatureReference(selectedFeature);
|
const next = toMapFeatureReference(selectedFeature);
|
||||||
if (next) setMapFeatureInteractionState(map, next, { selected: true, hovered: false });
|
if (next && map.getSource(next.source)) {
|
||||||
|
setMapFeatureInteractionState(map, next, { selected: true, hovered: false });
|
||||||
|
}
|
||||||
return () => {
|
return () => {
|
||||||
if (next) clearMapFeatureInteractionState(map, next, "selected");
|
if (next && map.getSource(next.source)) {
|
||||||
|
clearMapFeatureInteractionState(map, next, "selected");
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}, [mapReady, mapRef, selectedFeature]);
|
}, [availableSourceIds, mapReady, mapRef, selectedFeature]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function toFeatureStateTarget(feature: FeatureReference) {
|
function toFeatureStateTarget(feature: FeatureReference) {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { Map as MapLibreMap } from "maplibre-gl";
|
|||||||
import type { ScadaDevice } from "../api/scada-client";
|
import type { ScadaDevice } from "../api/scada-client";
|
||||||
import { useEffect, useRef, useSyncExternalStore, type RefObject } from "react";
|
import { useEffect, useRef, useSyncExternalStore, type RefObject } from "react";
|
||||||
import { getResponsiveWorkbenchPadding } from "../map/camera";
|
import { getResponsiveWorkbenchPadding } from "../map/camera";
|
||||||
|
import type { WaterNetworkSourceId } from "../map/sources";
|
||||||
import { WorkbenchMapController } from "../map/workbench-map-controller";
|
import { WorkbenchMapController } from "../map/workbench-map-controller";
|
||||||
|
|
||||||
export function useWorkbenchMapController({
|
export function useWorkbenchMapController({
|
||||||
@@ -12,7 +13,8 @@ export function useWorkbenchMapController({
|
|||||||
rightPanelOpen,
|
rightPanelOpen,
|
||||||
conditionPanelExpanded = false,
|
conditionPanelExpanded = false,
|
||||||
agentPanelWidth,
|
agentPanelWidth,
|
||||||
getScadaFeatures
|
getScadaFeatures,
|
||||||
|
availableSourceIds
|
||||||
}: {
|
}: {
|
||||||
mapRef: RefObject<MapLibreMap | null>;
|
mapRef: RefObject<MapLibreMap | null>;
|
||||||
mapReady: boolean;
|
mapReady: boolean;
|
||||||
@@ -21,6 +23,7 @@ export function useWorkbenchMapController({
|
|||||||
conditionPanelExpanded?: boolean;
|
conditionPanelExpanded?: boolean;
|
||||||
agentPanelWidth?: number;
|
agentPanelWidth?: number;
|
||||||
getScadaFeatures?: (deviceIds?: string[], signal?: AbortSignal) => Promise<FeatureCollection<Point, ScadaDevice>>;
|
getScadaFeatures?: (deviceIds?: string[], signal?: AbortSignal) => Promise<FeatureCollection<Point, ScadaDevice>>;
|
||||||
|
availableSourceIds?: readonly WaterNetworkSourceId[];
|
||||||
}) {
|
}) {
|
||||||
const valuesRef = useRef({
|
const valuesRef = useRef({
|
||||||
mapReady,
|
mapReady,
|
||||||
@@ -63,6 +66,12 @@ export function useWorkbenchMapController({
|
|||||||
controller.getSnapshot,
|
controller.getSnapshot,
|
||||||
controller.getSnapshot
|
controller.getSnapshot
|
||||||
);
|
);
|
||||||
|
useEffect(() => {
|
||||||
|
const target = state.target;
|
||||||
|
if (target && availableSourceIds && !availableSourceIds.includes(target.sourceId)) {
|
||||||
|
controller.clearHighlight();
|
||||||
|
}
|
||||||
|
}, [availableSourceIds, controller, state.target]);
|
||||||
useEffect(() => () => controller.destroy(), [controller]);
|
useEffect(() => () => controller.destroy(), [controller]);
|
||||||
return { controller, state };
|
return { controller, state };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
import "maplibre-gl/dist/maplibre-gl.css";
|
import "maplibre-gl/dist/maplibre-gl.css";
|
||||||
|
|
||||||
import type { FeatureCollection, Point } from "geojson";
|
import type { FeatureCollection, Point } from "geojson";
|
||||||
import maplibregl, { type GeoJSONSource, type Map as MapLibreMap, type MapSourceDataEvent } from "maplibre-gl";
|
import maplibregl, {
|
||||||
|
type GeoJSONSource,
|
||||||
|
type Map as MapLibreMap,
|
||||||
|
type MapSourceDataEvent,
|
||||||
|
type StyleSpecification
|
||||||
|
} from "maplibre-gl";
|
||||||
import { useCallback, useEffect, useRef, useState, type RefObject } from "react";
|
import { useCallback, useEffect, useRef, useState, type RefObject } from "react";
|
||||||
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
|
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
|
||||||
import { env } from "@/shared/config/env";
|
import { env } from "@/shared/config/env";
|
||||||
@@ -14,10 +19,16 @@ import {
|
|||||||
} from "../map/annotation-layers";
|
} from "../map/annotation-layers";
|
||||||
import { fitNetworkBounds } from "../map/camera";
|
import { fitNetworkBounds } from "../map/camera";
|
||||||
import {
|
import {
|
||||||
|
filterWaterNetworkLayersBySourceIds,
|
||||||
waterNetworkBusinessLayers,
|
waterNetworkBusinessLayers,
|
||||||
waterNetworkHitLayers,
|
waterNetworkHitLayers,
|
||||||
waterNetworkInteractionLayers
|
waterNetworkInteractionLayers
|
||||||
} from "../map/layers";
|
} 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 { MAP_MAX_ZOOM } from "../map/map-layer-visuals";
|
||||||
import { createValueLabelLayers } from "../map/value-label";
|
import { createValueLabelLayers } from "../map/value-label";
|
||||||
import { setSimulationLayersVisibility } from "../map/simulation-layers";
|
import { setSimulationLayersVisibility } from "../map/simulation-layers";
|
||||||
@@ -26,7 +37,9 @@ import {
|
|||||||
GEOSERVER_WATER_NETWORK_SOURCE_IDS,
|
GEOSERVER_WATER_NETWORK_SOURCE_IDS,
|
||||||
createBaseStyle,
|
createBaseStyle,
|
||||||
createWaterNetworkSources,
|
createWaterNetworkSources,
|
||||||
WATER_NETWORK_GLOBAL_VIEW
|
WATER_NETWORK_GLOBAL_VIEW,
|
||||||
|
type GeoServerWaterNetworkSourceId,
|
||||||
|
type WaterNetworkSourceId
|
||||||
} from "../map/sources";
|
} from "../map/sources";
|
||||||
import {
|
import {
|
||||||
SCADA_SOURCE_ID,
|
SCADA_SOURCE_ID,
|
||||||
@@ -49,6 +62,8 @@ declare global {
|
|||||||
type UseWorkbenchMapOptions = {
|
type UseWorkbenchMapOptions = {
|
||||||
containerRef: RefObject<HTMLDivElement | null>;
|
containerRef: RefObject<HTMLDivElement | null>;
|
||||||
impactVisible: boolean;
|
impactVisible: boolean;
|
||||||
|
layerVisibility: Record<string, boolean>;
|
||||||
|
onClearSelection: () => void;
|
||||||
onSelectFeature: (feature: DetailFeature) => void;
|
onSelectFeature: (feature: DetailFeature) => void;
|
||||||
selectedFeature: DetailFeature | null;
|
selectedFeature: DetailFeature | null;
|
||||||
getAccessToken?: AccessTokenProvider;
|
getAccessToken?: AccessTokenProvider;
|
||||||
@@ -59,7 +74,9 @@ type UseWorkbenchMapResult = {
|
|||||||
mapReady: boolean;
|
mapReady: boolean;
|
||||||
mapError: string | null;
|
mapError: string | null;
|
||||||
sourceStatuses: WorkbenchSourceStatus[];
|
sourceStatuses: WorkbenchSourceStatus[];
|
||||||
|
availableSourceIds: WaterNetworkSourceId[];
|
||||||
getScadaFeatures: (deviceIds?: string[], signal?: AbortSignal) => Promise<FeatureCollection<Point, ScadaDevice>>;
|
getScadaFeatures: (deviceIds?: string[], signal?: AbortSignal) => Promise<FeatureCollection<Point, ScadaDevice>>;
|
||||||
|
refreshGeoServerLayers: () => boolean;
|
||||||
fitNetworkBounds: () => void;
|
fitNetworkBounds: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -75,6 +92,7 @@ export type WorkbenchSourceStatus = {
|
|||||||
const SOURCE_STATUS_LABELS: Record<string, string> = {
|
const SOURCE_STATUS_LABELS: Record<string, string> = {
|
||||||
"mapbox-base": "Mapbox 底图",
|
"mapbox-base": "Mapbox 底图",
|
||||||
"geoserver-mvt": "GeoServer MVT",
|
"geoserver-mvt": "GeoServer MVT",
|
||||||
|
"geoserver-catalog": "GeoServer 图层目录",
|
||||||
"scada-api": "SCADA API",
|
"scada-api": "SCADA API",
|
||||||
"scada-icons": "SCADA 图标",
|
"scada-icons": "SCADA 图标",
|
||||||
"annotation-source": "业务标注源"
|
"annotation-source": "业务标注源"
|
||||||
@@ -90,9 +108,13 @@ const SOURCE_GROUP_BY_ID: Record<string, string> = {
|
|||||||
[SIMULATION_SOURCE_IDS.annotations]: "annotation-source"
|
[SIMULATION_SOURCE_IDS.annotations]: "annotation-source"
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const GEOSERVER_LAYER_PROBE_TIMEOUT_MS = 8_000;
|
||||||
|
|
||||||
export function useWorkbenchMap({
|
export function useWorkbenchMap({
|
||||||
containerRef,
|
containerRef,
|
||||||
impactVisible,
|
impactVisible,
|
||||||
|
layerVisibility,
|
||||||
|
onClearSelection,
|
||||||
onSelectFeature,
|
onSelectFeature,
|
||||||
selectedFeature,
|
selectedFeature,
|
||||||
getAccessToken
|
getAccessToken
|
||||||
@@ -102,6 +124,11 @@ export function useWorkbenchMap({
|
|||||||
const [mapReady, setMapReady] = useState(false);
|
const [mapReady, setMapReady] = useState(false);
|
||||||
const [mapError, setMapError] = useState<string | null>(null);
|
const [mapError, setMapError] = useState<string | null>(null);
|
||||||
const [sourceStatuses, setSourceStatuses] = useState<Record<string, WorkbenchSourceStatus>>({});
|
const [sourceStatuses, setSourceStatuses] = useState<Record<string, WorkbenchSourceStatus>>({});
|
||||||
|
const [availableGeoServerSourceIds, setAvailableGeoServerSourceIds] = useState<
|
||||||
|
GeoServerWaterNetworkSourceId[]
|
||||||
|
>(() => [...REQUIRED_GEOSERVER_SOURCE_IDS]);
|
||||||
|
const [geoServerLayerRefreshRevision, setGeoServerLayerRefreshRevision] = useState(0);
|
||||||
|
const geoServerLayerProbeInFlightRef = useRef(false);
|
||||||
const scadaCollectionRef = useRef<FeatureCollection<Point, ScadaDevice>>({
|
const scadaCollectionRef = useRef<FeatureCollection<Point, ScadaDevice>>({
|
||||||
type: "FeatureCollection",
|
type: "FeatureCollection",
|
||||||
features: []
|
features: []
|
||||||
@@ -121,15 +148,80 @@ export function useWorkbenchMap({
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
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)
|
||||||
|
.then((availability) => {
|
||||||
|
if (lifecycleAbortController.signal.aborted) return;
|
||||||
|
setAvailableGeoServerSourceIds((current) =>
|
||||||
|
current?.join("|") === availability.availableSourceIds.join("|")
|
||||||
|
? current
|
||||||
|
: availability.availableSourceIds
|
||||||
|
);
|
||||||
|
|
||||||
|
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]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
impactVisibleRef.current = impactVisible;
|
impactVisibleRef.current = impactVisible;
|
||||||
}, [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(() => {
|
useEffect(() => {
|
||||||
if (!containerRef.current || mapRef.current) {
|
if (!containerRef.current || mapRef.current) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setMapReady(false);
|
||||||
|
setMapError(null);
|
||||||
const mapboxToken = env.TJWATER_MAPBOX_ACCESS_TOKEN || undefined;
|
const mapboxToken = env.TJWATER_MAPBOX_ACCESS_TOKEN || undefined;
|
||||||
const scadaAbortController = new AbortController();
|
const scadaAbortController = new AbortController();
|
||||||
updateSourceStatus(setSourceStatuses, "scada-api", "loading", "正在通过API加载SCADA设备。");
|
updateSourceStatus(setSourceStatuses, "scada-api", "loading", "正在通过API加载SCADA设备。");
|
||||||
@@ -155,10 +247,11 @@ export function useWorkbenchMap({
|
|||||||
(map.getSource(SCADA_SOURCE_ID) as GeoJSONSource | undefined)?.setData(
|
(map.getSource(SCADA_SOURCE_ID) as GeoJSONSource | undefined)?.setData(
|
||||||
scadaCollectionRef.current
|
scadaCollectionRef.current
|
||||||
);
|
);
|
||||||
const sources = createWaterNetworkSources();
|
const sources = createWaterNetworkSources(REQUIRED_GEOSERVER_SOURCE_IDS);
|
||||||
GEOSERVER_WATER_NETWORK_SOURCE_IDS.forEach((sourceId) =>
|
REQUIRED_GEOSERVER_SOURCE_IDS.forEach((sourceId) => {
|
||||||
map.addSource(sourceId, sources[sourceId])
|
const source = sources[sourceId];
|
||||||
);
|
if (source) map.addSource(sourceId, source);
|
||||||
|
});
|
||||||
map.addSource(SIMULATION_SOURCE_IDS.impactArea, simulationSources.impactArea);
|
map.addSource(SIMULATION_SOURCE_IDS.impactArea, simulationSources.impactArea);
|
||||||
map.addSource(SIMULATION_SOURCE_IDS.annotations, simulationSources.annotations);
|
map.addSource(SIMULATION_SOURCE_IDS.annotations, simulationSources.annotations);
|
||||||
await registerSupplyAssetImages(map);
|
await registerSupplyAssetImages(map);
|
||||||
@@ -170,18 +263,27 @@ export function useWorkbenchMap({
|
|||||||
simulationAnnotationLayers
|
simulationAnnotationLayers
|
||||||
.filter((layer) => layer.id === "simulation-impact-fill")
|
.filter((layer) => layer.id === "simulation-impact-fill")
|
||||||
.forEach((layer) => map.addLayer(layer));
|
.forEach((layer) => map.addLayer(layer));
|
||||||
waterNetworkBusinessLayers.forEach((layer) => map.addLayer(layer));
|
filterWaterNetworkLayersBySourceIds(
|
||||||
|
waterNetworkBusinessLayers,
|
||||||
|
REQUIRED_GEOSERVER_SOURCE_IDS
|
||||||
|
).forEach((layer) => map.addLayer(layer));
|
||||||
activeScadaBusinessLayers.forEach((layer) => map.addLayer(layer));
|
activeScadaBusinessLayers.forEach((layer) => map.addLayer(layer));
|
||||||
simulationAnnotationLayers
|
simulationAnnotationLayers
|
||||||
.filter((layer) => layer.type !== "symbol" && layer.id !== "simulation-impact-fill")
|
.filter((layer) => layer.type !== "symbol" && layer.id !== "simulation-impact-fill")
|
||||||
.forEach((layer) => map.addLayer(layer));
|
.forEach((layer) => map.addLayer(layer));
|
||||||
waterNetworkInteractionLayers.forEach((layer) => map.addLayer(layer));
|
filterWaterNetworkLayersBySourceIds(
|
||||||
|
waterNetworkInteractionLayers,
|
||||||
|
REQUIRED_GEOSERVER_SOURCE_IDS
|
||||||
|
).forEach((layer) => map.addLayer(layer));
|
||||||
scadaInteractionLayers.forEach((layer) => map.addLayer(layer));
|
scadaInteractionLayers.forEach((layer) => map.addLayer(layer));
|
||||||
createValueLabelLayers().forEach((layer) => map.addLayer(layer));
|
createValueLabelLayers().forEach((layer) => map.addLayer(layer));
|
||||||
simulationAnnotationLayers
|
simulationAnnotationLayers
|
||||||
.filter((layer) => layer.type === "symbol")
|
.filter((layer) => layer.type === "symbol")
|
||||||
.forEach((layer) => map.addLayer(layer));
|
.forEach((layer) => map.addLayer(layer));
|
||||||
waterNetworkHitLayers.forEach((layer) => map.addLayer(layer));
|
filterWaterNetworkLayersBySourceIds(
|
||||||
|
waterNetworkHitLayers,
|
||||||
|
REQUIRED_GEOSERVER_SOURCE_IDS
|
||||||
|
).forEach((layer) => map.addLayer(layer));
|
||||||
scadaHitLayers.forEach((layer) => map.addLayer(layer));
|
scadaHitLayers.forEach((layer) => map.addLayer(layer));
|
||||||
setSimulationLayersVisibility(map, impactVisibleRef.current);
|
setSimulationLayersVisibility(map, impactVisibleRef.current);
|
||||||
setMapReady(true);
|
setMapReady(true);
|
||||||
@@ -276,7 +378,14 @@ export function useWorkbenchMap({
|
|||||||
};
|
};
|
||||||
}, [containerRef, getAccessToken]);
|
}, [containerRef, getAccessToken]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const map = mapRef.current;
|
||||||
|
if (!mapReady || !map) return;
|
||||||
|
syncDataDependentGeoServerLayers(map, availableGeoServerSourceIds, layerVisibility);
|
||||||
|
}, [availableGeoServerSourceIds, layerVisibility, mapReady]);
|
||||||
|
|
||||||
useMapInteractions({
|
useMapInteractions({
|
||||||
|
availableSourceIds: availableGeoServerSourceIds,
|
||||||
mapRef,
|
mapRef,
|
||||||
mapReady,
|
mapReady,
|
||||||
onSelectFeature,
|
onSelectFeature,
|
||||||
@@ -301,11 +410,93 @@ export function useWorkbenchMap({
|
|||||||
mapReady,
|
mapReady,
|
||||||
mapError,
|
mapError,
|
||||||
sourceStatuses: Object.values(sourceStatuses),
|
sourceStatuses: Object.values(sourceStatuses),
|
||||||
|
availableSourceIds: AVAILABLE_WATER_NETWORK_SOURCE_IDS.filter(
|
||||||
|
(sourceId) =>
|
||||||
|
sourceId === "scada" ||
|
||||||
|
availableGeoServerSourceIds.includes(sourceId as GeoServerWaterNetworkSourceId)
|
||||||
|
),
|
||||||
getScadaFeatures,
|
getScadaFeatures,
|
||||||
|
refreshGeoServerLayers,
|
||||||
fitNetworkBounds: fitToNetworkBounds
|
fitNetworkBounds: fitToNetworkBounds
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function syncDataDependentGeoServerLayers(
|
||||||
|
map: MapLibreMap,
|
||||||
|
availableSourceIds: readonly GeoServerWaterNetworkSourceId[],
|
||||||
|
layerVisibility: Record<string, boolean>
|
||||||
|
) {
|
||||||
|
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);
|
||||||
|
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,
|
||||||
|
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>
|
||||||
|
) {
|
||||||
|
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(
|
function updateStatusFromSourceEvent(
|
||||||
event: MapSourceDataEvent,
|
event: MapSourceDataEvent,
|
||||||
status: WorkbenchSourceStatusValue,
|
status: WorkbenchSourceStatusValue,
|
||||||
@@ -360,6 +551,22 @@ function updateSourceStatus(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 } }) {
|
function getSourceGroupFromErrorEvent(event: { sourceId?: string; error?: { message?: string } }) {
|
||||||
if (event.sourceId && SOURCE_GROUP_BY_ID[event.sourceId]) {
|
if (event.sourceId && SOURCE_GROUP_BY_ID[event.sourceId]) {
|
||||||
return SOURCE_GROUP_BY_ID[event.sourceId];
|
return SOURCE_GROUP_BY_ID[event.sourceId];
|
||||||
|
|||||||
@@ -156,6 +156,9 @@ export function MapWorkbenchPage({
|
|||||||
const handleSelectFeature = useCallback((feature: DetailFeature) => {
|
const handleSelectFeature = useCallback((feature: DetailFeature) => {
|
||||||
setDetailFeature(feature);
|
setDetailFeature(feature);
|
||||||
}, []);
|
}, []);
|
||||||
|
const handleClearSelection = useCallback(() => {
|
||||||
|
setDetailFeature(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const agent = useWorkbenchAgent({
|
const agent = useWorkbenchAgent({
|
||||||
onUiEnvelope: handleAgentUiEnvelope,
|
onUiEnvelope: handleAgentUiEnvelope,
|
||||||
@@ -259,9 +262,20 @@ export function MapWorkbenchPage({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const { mapRef, mapReady, mapError, sourceStatuses, fitNetworkBounds, getScadaFeatures } = useWorkbenchMap({
|
const {
|
||||||
|
mapRef,
|
||||||
|
mapReady,
|
||||||
|
mapError,
|
||||||
|
sourceStatuses,
|
||||||
|
availableSourceIds,
|
||||||
|
fitNetworkBounds,
|
||||||
|
getScadaFeatures,
|
||||||
|
refreshGeoServerLayers
|
||||||
|
} = useWorkbenchMap({
|
||||||
containerRef: mapContainerRef,
|
containerRef: mapContainerRef,
|
||||||
impactVisible,
|
impactVisible,
|
||||||
|
layerVisibility,
|
||||||
|
onClearSelection: handleClearSelection,
|
||||||
onSelectFeature: handleSelectFeature,
|
onSelectFeature: handleSelectFeature,
|
||||||
selectedFeature: detailFeature,
|
selectedFeature: detailFeature,
|
||||||
getAccessToken
|
getAccessToken
|
||||||
@@ -273,7 +287,8 @@ export function MapWorkbenchPage({
|
|||||||
rightPanelOpen,
|
rightPanelOpen,
|
||||||
conditionPanelExpanded: rightPanelExpanded,
|
conditionPanelExpanded: rightPanelExpanded,
|
||||||
agentPanelWidth,
|
agentPanelWidth,
|
||||||
getScadaFeatures
|
getScadaFeatures,
|
||||||
|
availableSourceIds
|
||||||
});
|
});
|
||||||
|
|
||||||
const activeAgentUiResults = useMemo(
|
const activeAgentUiResults = useMemo(
|
||||||
@@ -426,8 +441,8 @@ export function MapWorkbenchPage({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const layerControlItems = useMemo(
|
const layerControlItems = useMemo(
|
||||||
() => createLayerControlItems(layerVisibility),
|
() => createLayerControlItems(layerVisibility, availableSourceIds),
|
||||||
[layerVisibility]
|
[availableSourceIds, layerVisibility]
|
||||||
);
|
);
|
||||||
function handleToggleLayer(layerControlId: string, visible: boolean) {
|
function handleToggleLayer(layerControlId: string, visible: boolean) {
|
||||||
const map = mapRef.current;
|
const map = mapRef.current;
|
||||||
@@ -492,8 +507,15 @@ export function MapWorkbenchPage({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!refreshGeoServerLayers()) {
|
||||||
|
showMapNotice({ tone: "warning", message: "业务图层正在检查,请稍候。" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
map.triggerRepaint();
|
map.triggerRepaint();
|
||||||
showMapNotice({ tone: "success", message: "已请求地图重新渲染,业务瓦片将在视图变化时刷新。" });
|
showMapNotice({
|
||||||
|
tone: "success",
|
||||||
|
message: "已开始重新检查业务图层,并请求地图重新渲染。"
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleShowDataStatus() {
|
function handleShowDataStatus() {
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import {
|
||||||
|
createGeoServerLayerHitsUrl,
|
||||||
|
fetchGeoServerLayerFeatureCount,
|
||||||
|
resolveGeoServerLayerAvailability
|
||||||
|
} from "./geoserver-layer-availability";
|
||||||
|
|
||||||
|
describe("GeoServer data-dependent layer availability", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds a WFS hits request for the canonical workspace layer", () => {
|
||||||
|
const url = createGeoServerLayerHitsUrl("pumps");
|
||||||
|
|
||||||
|
expect(url.pathname).toBe("/geoserver/tjwater_next/ows");
|
||||||
|
expect(url.searchParams.get("service")).toBe("WFS");
|
||||||
|
expect(url.searchParams.get("typeNames")).toBe("tjwater_next:pumps");
|
||||||
|
expect(url.searchParams.get("resultType")).toBe("hits");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads numberMatched without downloading layer features", async () => {
|
||||||
|
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(
|
||||||
|
'<wfs:FeatureCollection numberMatched="12" numberReturned="0" />',
|
||||||
|
{ status: 200 }
|
||||||
|
)));
|
||||||
|
|
||||||
|
await expect(fetchGeoServerLayerFeatureCount("pumps")).resolves.toBe(12);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats a published WFS layer with an unknown count as available", async () => {
|
||||||
|
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(
|
||||||
|
'<wfs:FeatureCollection numberMatched="unknown" numberReturned="0" />',
|
||||||
|
{ status: 200 }
|
||||||
|
)));
|
||||||
|
|
||||||
|
await expect(fetchGeoServerLayerFeatureCount("pumps")).resolves.toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps only optional layers that contain features", async () => {
|
||||||
|
const fetchMock = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce(new Response('<wfs:FeatureCollection numberMatched="3" />'))
|
||||||
|
.mockResolvedValueOnce(new Response('<wfs:FeatureCollection numberMatched="0" />'));
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
|
||||||
|
await expect(resolveGeoServerLayerAvailability()).resolves.toEqual({
|
||||||
|
availableSourceIds: ["pipes", "junctions", "valves", "reservoirs", "pumps"],
|
||||||
|
emptySourceIds: ["tanks"],
|
||||||
|
failedSourceIds: []
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats an unpublished GeoServer feature type as empty", async () => {
|
||||||
|
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(
|
||||||
|
"<ows:ExceptionText>Feature type tjwater_next:pumps unknown</ows:ExceptionText>",
|
||||||
|
{ status: 400 }
|
||||||
|
)));
|
||||||
|
|
||||||
|
await expect(fetchGeoServerLayerFeatureCount("pumps")).resolves.toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits invalid optional layers without blocking required layers", async () => {
|
||||||
|
const fetchMock = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce(new Response("missing", { status: 503 }))
|
||||||
|
.mockResolvedValueOnce(new Response("<ServiceException />"));
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
|
||||||
|
await expect(resolveGeoServerLayerAvailability()).resolves.toEqual({
|
||||||
|
availableSourceIds: ["pipes", "junctions", "valves", "reservoirs"],
|
||||||
|
emptySourceIds: [],
|
||||||
|
failedSourceIds: ["pumps", "tanks"]
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import { GEOSERVER_WORKSPACE, MAP_URL } from "./geoserver-config";
|
||||||
|
import {
|
||||||
|
GEOSERVER_WATER_NETWORK_SOURCE_IDS,
|
||||||
|
SOURCE_LAYERS,
|
||||||
|
SUPPLY_LAYER_CATALOG,
|
||||||
|
type GeoServerWaterNetworkSourceId
|
||||||
|
} from "./sources";
|
||||||
|
|
||||||
|
type GeoServerSupplyLayerCatalogItem = Extract<
|
||||||
|
(typeof SUPPLY_LAYER_CATALOG)[number],
|
||||||
|
{ sourceLayer: string }
|
||||||
|
>;
|
||||||
|
|
||||||
|
export type DataDependentGeoServerSourceId = Extract<
|
||||||
|
GeoServerSupplyLayerCatalogItem,
|
||||||
|
{ availability: "probe" }
|
||||||
|
>["id"];
|
||||||
|
|
||||||
|
export const DATA_DEPENDENT_GEOSERVER_SOURCE_IDS: DataDependentGeoServerSourceId[] = SUPPLY_LAYER_CATALOG.filter(
|
||||||
|
(layer): layer is Extract<GeoServerSupplyLayerCatalogItem, { availability: "probe" }> =>
|
||||||
|
layer.sourceLayer !== undefined && layer.availability === "probe"
|
||||||
|
).map((layer) => layer.id);
|
||||||
|
|
||||||
|
export type GeoServerLayerAvailability = {
|
||||||
|
availableSourceIds: GeoServerWaterNetworkSourceId[];
|
||||||
|
emptySourceIds: DataDependentGeoServerSourceId[];
|
||||||
|
failedSourceIds: DataDependentGeoServerSourceId[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const REQUIRED_GEOSERVER_SOURCE_IDS: GeoServerWaterNetworkSourceId[] = SUPPLY_LAYER_CATALOG.filter(
|
||||||
|
(layer): layer is Extract<GeoServerSupplyLayerCatalogItem, { availability: "required" }> =>
|
||||||
|
layer.sourceLayer !== undefined && layer.availability === "required"
|
||||||
|
).map((layer) => layer.id);
|
||||||
|
|
||||||
|
export function createGeoServerLayerHitsUrl(sourceId: DataDependentGeoServerSourceId) {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
service: "WFS",
|
||||||
|
version: "2.0.0",
|
||||||
|
request: "GetFeature",
|
||||||
|
typeNames: `${GEOSERVER_WORKSPACE}:${SOURCE_LAYERS[sourceId]}`,
|
||||||
|
resultType: "hits"
|
||||||
|
});
|
||||||
|
|
||||||
|
return new URL(`${MAP_URL}/${GEOSERVER_WORKSPACE}/ows?${params.toString()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchGeoServerLayerFeatureCount(
|
||||||
|
sourceId: DataDependentGeoServerSourceId,
|
||||||
|
signal?: AbortSignal
|
||||||
|
) {
|
||||||
|
const response = await fetch(createGeoServerLayerHitsUrl(sourceId), {
|
||||||
|
headers: { Accept: "application/xml, text/xml" },
|
||||||
|
signal
|
||||||
|
});
|
||||||
|
const payload = await response.text();
|
||||||
|
if (!response.ok) {
|
||||||
|
if (/Feature type\s+\S+\s+unknown/i.test(payload)) return 0;
|
||||||
|
throw new Error(`GeoServer ${sourceId} 图层探测失败:HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const match = payload.match(/\bnumberMatched\s*=\s*["'](\d+|unknown)["']/i);
|
||||||
|
if (!match) {
|
||||||
|
throw new Error(`GeoServer ${sourceId} 图层探测响应缺少 numberMatched`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (match[1].toLowerCase() === "unknown") return 1;
|
||||||
|
|
||||||
|
const count = Number(match[1]);
|
||||||
|
if (!Number.isSafeInteger(count) || count < 0) {
|
||||||
|
throw new Error(`GeoServer ${sourceId} 图层探测返回无效数量`);
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveGeoServerLayerAvailability(
|
||||||
|
signal?: AbortSignal
|
||||||
|
): Promise<GeoServerLayerAvailability> {
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
DATA_DEPENDENT_GEOSERVER_SOURCE_IDS.map(async (sourceId) => ({
|
||||||
|
sourceId,
|
||||||
|
count: await fetchGeoServerLayerFeatureCount(sourceId, signal)
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
const availableOptionalSourceIds: DataDependentGeoServerSourceId[] = [];
|
||||||
|
const emptySourceIds: DataDependentGeoServerSourceId[] = [];
|
||||||
|
const failedSourceIds: DataDependentGeoServerSourceId[] = [];
|
||||||
|
|
||||||
|
results.forEach((result, index) => {
|
||||||
|
const sourceId = DATA_DEPENDENT_GEOSERVER_SOURCE_IDS[index];
|
||||||
|
if (result.status === "rejected") {
|
||||||
|
failedSourceIds.push(sourceId);
|
||||||
|
} else if (result.value.count > 0) {
|
||||||
|
availableOptionalSourceIds.push(sourceId);
|
||||||
|
} else {
|
||||||
|
emptySourceIds.push(sourceId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const availableSourceIdSet = new Set<GeoServerWaterNetworkSourceId>([
|
||||||
|
...REQUIRED_GEOSERVER_SOURCE_IDS,
|
||||||
|
...availableOptionalSourceIds
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
availableSourceIds: GEOSERVER_WATER_NETWORK_SOURCE_IDS.filter((sourceId) =>
|
||||||
|
availableSourceIdSet.has(sourceId)
|
||||||
|
),
|
||||||
|
emptySourceIds,
|
||||||
|
failedSourceIds
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import { waterNetworkBusinessLayers, waterNetworkLayers } from "./layers";
|
import {
|
||||||
|
filterWaterNetworkLayersBySourceIds,
|
||||||
|
waterNetworkBusinessLayers,
|
||||||
|
waterNetworkLayers
|
||||||
|
} from "./layers";
|
||||||
import { SUPPLY_LAYER_VISUALS } from "./map-layer-visuals";
|
import { SUPPLY_LAYER_VISUALS } from "./map-layer-visuals";
|
||||||
import { SUPPLY_ASSET_IMAGE_IDS } from "./supply-icons";
|
import { SUPPLY_ASSET_IMAGE_IDS } from "./supply-icons";
|
||||||
import {
|
import {
|
||||||
@@ -57,6 +61,19 @@ describe("supply network layer styling", () => {
|
|||||||
expect(ids.indexOf("pipes-hover")).toBeLessThan(ids.indexOf("pipes-hit"));
|
expect(ids.indexOf("pipes-hover")).toBeLessThan(ids.indexOf("pipes-hit"));
|
||||||
expect(ids.at(-1)).toBe("tanks-hit");
|
expect(ids.at(-1)).toBe("tanks-hit");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("removes every layer backed by an unavailable optional source", () => {
|
||||||
|
const filtered = filterWaterNetworkLayersBySourceIds(waterNetworkLayers, [
|
||||||
|
"pipes",
|
||||||
|
"junctions",
|
||||||
|
"valves",
|
||||||
|
"reservoirs"
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(filtered.some((layer) => "source" in layer && layer.source === "pumps")).toBe(false);
|
||||||
|
expect(filtered.some((layer) => "source" in layer && layer.source === "tanks")).toBe(false);
|
||||||
|
expect(filtered.some((layer) => "source" in layer && layer.source === "pipes")).toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
function getLayer(id: string) {
|
function getLayer(id: string) {
|
||||||
|
|||||||
@@ -2,7 +2,10 @@ import type { ExpressionSpecification, StyleSpecification } from "maplibre-gl";
|
|||||||
import { MAP_STYLE_TOKENS } from "./map-colors";
|
import { MAP_STYLE_TOKENS } from "./map-colors";
|
||||||
import { SUPPLY_LAYER_VISUALS } from "./map-layer-visuals";
|
import { SUPPLY_LAYER_VISUALS } from "./map-layer-visuals";
|
||||||
import { SCADA_HIT_LAYER_ID } from "./scada";
|
import { SCADA_HIT_LAYER_ID } from "./scada";
|
||||||
import { SOURCE_LAYERS } from "./sources";
|
import {
|
||||||
|
SOURCE_LAYERS,
|
||||||
|
type GeoServerWaterNetworkSourceId
|
||||||
|
} from "./sources";
|
||||||
import { SUPPLY_ASSET_IMAGE_IDS } from "./supply-icons";
|
import { SUPPLY_ASSET_IMAGE_IDS } from "./supply-icons";
|
||||||
import {
|
import {
|
||||||
createSupplyActiveOpacity,
|
createSupplyActiveOpacity,
|
||||||
@@ -475,3 +478,13 @@ export const waterNetworkBusinessLayers = layers.slice(0, interactionIndex) as L
|
|||||||
export const waterNetworkInteractionLayers = layers.slice(interactionIndex, hitIndex) as Layer[];
|
export const waterNetworkInteractionLayers = layers.slice(interactionIndex, hitIndex) as Layer[];
|
||||||
export const waterNetworkHitLayers = layers.slice(hitIndex) as Layer[];
|
export const waterNetworkHitLayers = layers.slice(hitIndex) as Layer[];
|
||||||
export const WORKBENCH_INTERACTION_BEFORE_ID = "pipes-hover-outline";
|
export const WORKBENCH_INTERACTION_BEFORE_ID = "pipes-hover-outline";
|
||||||
|
|
||||||
|
export function filterWaterNetworkLayersBySourceIds(
|
||||||
|
sourceLayers: StyleSpecification["layers"],
|
||||||
|
sourceIds: readonly GeoServerWaterNetworkSourceId[]
|
||||||
|
) {
|
||||||
|
const sourceIdSet = new Set<string>(sourceIds);
|
||||||
|
return sourceLayers.filter(
|
||||||
|
(layer) => !("source" in layer) || typeof layer.source !== "string" || sourceIdSet.has(layer.source)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -34,6 +34,25 @@ describe("workbench supply layer controls", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("omits data-dependent controls when their GeoServer layers are empty", () => {
|
||||||
|
const items = createLayerControlItems(INITIAL_LAYER_VISIBILITY, [
|
||||||
|
"pipes",
|
||||||
|
"junctions",
|
||||||
|
"valves",
|
||||||
|
"reservoirs",
|
||||||
|
"scada"
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(items.map((item) => item.id)).toEqual([
|
||||||
|
"pipes",
|
||||||
|
"junctions",
|
||||||
|
"valves",
|
||||||
|
"reservoirs",
|
||||||
|
"scada",
|
||||||
|
"simulation"
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it("shows only current visible business icons in the legend", () => {
|
it("shows only current visible business icons in the legend", () => {
|
||||||
expect(MAP_LEGEND_ITEMS.map((item) => item.id)).toEqual([
|
expect(MAP_LEGEND_ITEMS.map((item) => item.id)).toEqual([
|
||||||
"major-pipe",
|
"major-pipe",
|
||||||
|
|||||||
@@ -92,9 +92,12 @@ export const BASE_LAYER_OPTIONS: BaseLayerOption[] = [
|
|||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
export function createLayerControlItems(layerVisibility: Record<string, boolean>): MapLayerControlItem[] {
|
export function createLayerControlItems(
|
||||||
|
layerVisibility: Record<string, boolean>,
|
||||||
|
availableSourceIds: readonly WaterNetworkSourceId[] = AVAILABLE_WATER_NETWORK_SOURCE_IDS
|
||||||
|
): MapLayerControlItem[] {
|
||||||
return [
|
return [
|
||||||
...AVAILABLE_WATER_NETWORK_SOURCE_IDS.map((sourceId) => ({
|
...availableSourceIds.map((sourceId) => ({
|
||||||
id: sourceId,
|
id: sourceId,
|
||||||
...SOURCE_CONTROL_LABELS[sourceId],
|
...SOURCE_CONTROL_LABELS[sourceId],
|
||||||
visible: layerVisibility[sourceId]
|
visible: layerVisibility[sourceId]
|
||||||
|
|||||||
@@ -20,14 +20,14 @@ describe("createWaterNetworkSources", () => {
|
|||||||
tanks: "tanks"
|
tanks: "tanks"
|
||||||
});
|
});
|
||||||
expect(WATER_NETWORK_SOURCE_IDS).toEqual(["pipes", "junctions", "valves", "reservoirs", "scada", "pumps", "tanks"]);
|
expect(WATER_NETWORK_SOURCE_IDS).toEqual(["pipes", "junctions", "valves", "reservoirs", "scada", "pumps", "tanks"]);
|
||||||
expect(SUPPLY_LAYER_CATALOG.map((layer) => [layer.id, layer.available])).toEqual([
|
expect(SUPPLY_LAYER_CATALOG.map((layer) => [layer.id, layer.available, layer.availability])).toEqual([
|
||||||
["pipes", true],
|
["pipes", true, "required"],
|
||||||
["junctions", true],
|
["junctions", true, "required"],
|
||||||
["valves", true],
|
["valves", true, "required"],
|
||||||
["reservoirs", true],
|
["reservoirs", true, "required"],
|
||||||
["scada", true],
|
["scada", true, "required"],
|
||||||
["pumps", true],
|
["pumps", true, "probe"],
|
||||||
["tanks", true]
|
["tanks", true, "probe"]
|
||||||
]);
|
]);
|
||||||
expect(SUPPLY_LAYER_CATALOG.find((layer) => layer.id === "valves")).toMatchObject({
|
expect(SUPPLY_LAYER_CATALOG.find((layer) => layer.id === "valves")).toMatchObject({
|
||||||
geometry: "point",
|
geometry: "point",
|
||||||
@@ -67,4 +67,10 @@ describe("createWaterNetworkSources", () => {
|
|||||||
promoteId: "device_id"
|
promoteId: "device_id"
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("creates only the requested GeoServer vector sources", () => {
|
||||||
|
const sources = createWaterNetworkSources(["pipes", "junctions"]);
|
||||||
|
|
||||||
|
expect(Object.keys(sources)).toEqual(["pipes", "junctions"]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ export type SupplyLayerCatalogItem = {
|
|||||||
sourceLayer?: (typeof SOURCE_LAYERS)[keyof typeof SOURCE_LAYERS];
|
sourceLayer?: (typeof SOURCE_LAYERS)[keyof typeof SOURCE_LAYERS];
|
||||||
geometry: "line" | "point";
|
geometry: "line" | "point";
|
||||||
available: boolean;
|
available: boolean;
|
||||||
|
availability: "required" | "probe";
|
||||||
label: string;
|
label: string;
|
||||||
icon: string;
|
icon: string;
|
||||||
};
|
};
|
||||||
@@ -49,6 +50,7 @@ export const SUPPLY_LAYER_CATALOG = [
|
|||||||
sourceLayer: SOURCE_LAYERS.pipes,
|
sourceLayer: SOURCE_LAYERS.pipes,
|
||||||
geometry: "line",
|
geometry: "line",
|
||||||
available: true,
|
available: true,
|
||||||
|
availability: "required",
|
||||||
label: "管线",
|
label: "管线",
|
||||||
icon: "pipe"
|
icon: "pipe"
|
||||||
},
|
},
|
||||||
@@ -57,6 +59,7 @@ export const SUPPLY_LAYER_CATALOG = [
|
|||||||
sourceLayer: SOURCE_LAYERS.junctions,
|
sourceLayer: SOURCE_LAYERS.junctions,
|
||||||
geometry: "point",
|
geometry: "point",
|
||||||
available: true,
|
available: true,
|
||||||
|
availability: "required",
|
||||||
label: "节点",
|
label: "节点",
|
||||||
icon: "junction"
|
icon: "junction"
|
||||||
},
|
},
|
||||||
@@ -65,6 +68,7 @@ export const SUPPLY_LAYER_CATALOG = [
|
|||||||
sourceLayer: SOURCE_LAYERS.valves,
|
sourceLayer: SOURCE_LAYERS.valves,
|
||||||
geometry: "point",
|
geometry: "point",
|
||||||
available: true,
|
available: true,
|
||||||
|
availability: "required",
|
||||||
label: "阀门",
|
label: "阀门",
|
||||||
icon: "valve"
|
icon: "valve"
|
||||||
},
|
},
|
||||||
@@ -73,6 +77,7 @@ export const SUPPLY_LAYER_CATALOG = [
|
|||||||
sourceLayer: SOURCE_LAYERS.reservoirs,
|
sourceLayer: SOURCE_LAYERS.reservoirs,
|
||||||
geometry: "point",
|
geometry: "point",
|
||||||
available: true,
|
available: true,
|
||||||
|
availability: "required",
|
||||||
label: "水库",
|
label: "水库",
|
||||||
icon: "reservoir"
|
icon: "reservoir"
|
||||||
},
|
},
|
||||||
@@ -81,6 +86,7 @@ export const SUPPLY_LAYER_CATALOG = [
|
|||||||
sourceLayer: undefined,
|
sourceLayer: undefined,
|
||||||
geometry: "point",
|
geometry: "point",
|
||||||
available: true,
|
available: true,
|
||||||
|
availability: "required",
|
||||||
label: "SCADA",
|
label: "SCADA",
|
||||||
icon: "scada-pressure"
|
icon: "scada-pressure"
|
||||||
},
|
},
|
||||||
@@ -89,6 +95,7 @@ export const SUPPLY_LAYER_CATALOG = [
|
|||||||
sourceLayer: SOURCE_LAYERS.pumps,
|
sourceLayer: SOURCE_LAYERS.pumps,
|
||||||
geometry: "point",
|
geometry: "point",
|
||||||
available: true,
|
available: true,
|
||||||
|
availability: "probe",
|
||||||
label: "水泵",
|
label: "水泵",
|
||||||
icon: "pump"
|
icon: "pump"
|
||||||
},
|
},
|
||||||
@@ -97,6 +104,7 @@ export const SUPPLY_LAYER_CATALOG = [
|
|||||||
sourceLayer: SOURCE_LAYERS.tanks,
|
sourceLayer: SOURCE_LAYERS.tanks,
|
||||||
geometry: "point",
|
geometry: "point",
|
||||||
available: true,
|
available: true,
|
||||||
|
availability: "probe",
|
||||||
label: "水箱",
|
label: "水箱",
|
||||||
icon: "tank"
|
icon: "tank"
|
||||||
}
|
}
|
||||||
@@ -119,14 +127,22 @@ export function isAvailableWaterNetworkSourceId(
|
|||||||
return (AVAILABLE_WATER_NETWORK_SOURCE_IDS as readonly string[]).includes(value);
|
return (AVAILABLE_WATER_NETWORK_SOURCE_IDS as readonly string[]).includes(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createWaterNetworkSources() {
|
export function createWaterNetworkSources(): Record<GeoServerWaterNetworkSourceId, VectorSourceSpecification>;
|
||||||
|
export function createWaterNetworkSources(
|
||||||
|
sourceIds: readonly GeoServerWaterNetworkSourceId[]
|
||||||
|
): Partial<Record<GeoServerWaterNetworkSourceId, VectorSourceSpecification>>;
|
||||||
|
export function createWaterNetworkSources(
|
||||||
|
sourceIds: readonly GeoServerWaterNetworkSourceId[] = GEOSERVER_WATER_NETWORK_SOURCE_IDS
|
||||||
|
) {
|
||||||
return Object.fromEntries(
|
return Object.fromEntries(
|
||||||
SUPPLY_LAYER_CATALOG.flatMap((layer) =>
|
SUPPLY_LAYER_CATALOG.flatMap((layer) =>
|
||||||
layer.available && layer.sourceLayer !== undefined
|
layer.available &&
|
||||||
|
layer.sourceLayer !== undefined &&
|
||||||
|
sourceIds.includes(layer.id as GeoServerWaterNetworkSourceId)
|
||||||
? [[layer.id, createGeoServerVectorSource(layer.sourceLayer)] as const]
|
? [[layer.id, createGeoServerVectorSource(layer.sourceLayer)] as const]
|
||||||
: []
|
: []
|
||||||
)
|
)
|
||||||
) as Record<GeoServerWaterNetworkSourceId, VectorSourceSpecification>;
|
) as Partial<Record<GeoServerWaterNetworkSourceId, VectorSourceSpecification>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function createGeoServerVectorSource(
|
function createGeoServerVectorSource(
|
||||||
|
|||||||
@@ -21,6 +21,43 @@ describe("WorkbenchMapController", () => {
|
|||||||
expect(map.removeFeatureState).not.toHaveBeenCalledWith(expect.anything(), "selected");
|
expect(map.removeFeatureState).not.toHaveBeenCalledWith(expect.anything(), "selected");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("clears a stale highlight without touching a removed source", async () => {
|
||||||
|
const map = createMap();
|
||||||
|
const controller = createController(map, pointCollection);
|
||||||
|
await controller.highlight({ sourceId: "junctions", featureId: "J-1" });
|
||||||
|
map.getSource.mockReturnValue(undefined);
|
||||||
|
|
||||||
|
controller.clearHighlight();
|
||||||
|
|
||||||
|
expect(map.removeFeatureState).not.toHaveBeenCalled();
|
||||||
|
expect(controller.getSnapshot().target).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores a resolved highlight after its source has been removed", async () => {
|
||||||
|
const map = createMap();
|
||||||
|
map.getSource.mockReturnValue(undefined);
|
||||||
|
const controller = createController(map, pointCollection);
|
||||||
|
|
||||||
|
await controller.highlight({ sourceId: "junctions", featureId: "J-1" });
|
||||||
|
|
||||||
|
expect(map.setFeatureState).not.toHaveBeenCalled();
|
||||||
|
expect(controller.getSnapshot().target).toBeNull();
|
||||||
|
expect(controller.getSnapshot().errorCode).toBe("FEATURE_NOT_FOUND");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not move to a cached feature after its source has been removed", async () => {
|
||||||
|
const map = createMap();
|
||||||
|
const controller = createController(map, pointCollection);
|
||||||
|
const target = { sourceId: "junctions" as const, featureId: "J-1" };
|
||||||
|
await controller.locateAndHighlight(target);
|
||||||
|
map.getSource.mockReturnValue(undefined);
|
||||||
|
|
||||||
|
await controller.locateAndHighlight(target);
|
||||||
|
|
||||||
|
expect(map.easeTo).toHaveBeenCalledTimes(1);
|
||||||
|
expect(controller.getSnapshot().errorCode).toBe("FEATURE_NOT_FOUND");
|
||||||
|
});
|
||||||
|
|
||||||
it("fits line bounds and uses responsive workbench padding", async () => {
|
it("fits line bounds and uses responsive workbench padding", async () => {
|
||||||
const map = createMap();
|
const map = createMap();
|
||||||
const padding = { top: 50, right: 420, bottom: 50, left: 320 };
|
const padding = { top: 50, right: 420, bottom: 50, left: 320 };
|
||||||
@@ -281,7 +318,7 @@ function createMap() {
|
|||||||
const images = new Set<string>();
|
const images = new Set<string>();
|
||||||
return {
|
return {
|
||||||
easeTo: vi.fn(), fitBounds: vi.fn(), setFeatureState: vi.fn(), removeFeatureState: vi.fn(),
|
easeTo: vi.fn(), fitBounds: vi.fn(), setFeatureState: vi.fn(), removeFeatureState: vi.fn(),
|
||||||
getSource: vi.fn(), getLayer: vi.fn(), addLayer: vi.fn(), setPaintProperty: vi.fn(),
|
getSource: vi.fn((_id: string): unknown => ({})), getLayer: vi.fn(), addLayer: vi.fn(), setPaintProperty: vi.fn(),
|
||||||
setLayoutProperty: vi.fn(), setFilter: vi.fn(), removeControl: vi.fn(), triggerRepaint: vi.fn(),
|
setLayoutProperty: vi.fn(), setFilter: vi.fn(), removeControl: vi.fn(), triggerRepaint: vi.fn(),
|
||||||
hasImage: vi.fn((id: string) => images.has(id)),
|
hasImage: vi.fn((id: string) => images.has(id)),
|
||||||
addImage: vi.fn((id: string) => {
|
addImage: vi.fn((id: string) => {
|
||||||
|
|||||||
@@ -121,15 +121,17 @@ export class WorkbenchMapController implements WorkbenchMapCommands {
|
|||||||
getSnapshot = () => this.state;
|
getSnapshot = () => this.state;
|
||||||
|
|
||||||
async zoomToFeature(target: FeatureTarget) {
|
async zoomToFeature(target: FeatureTarget) {
|
||||||
|
if (!this.requireTargetMap(target)) return;
|
||||||
const feature = await this.resolveTarget(target);
|
const feature = await this.resolveTarget(target);
|
||||||
const map = this.requireMap();
|
const map = this.requireTargetMap(target);
|
||||||
if (!feature || !map) return;
|
if (!feature || !map) return;
|
||||||
this.moveCameraToFeature(map, feature);
|
this.moveCameraToFeature(map, feature);
|
||||||
}
|
}
|
||||||
|
|
||||||
async locateAndHighlight(target: FeatureTarget) {
|
async locateAndHighlight(target: FeatureTarget) {
|
||||||
|
if (!this.requireTargetMap(target)) return;
|
||||||
const feature = await this.resolveTarget(target);
|
const feature = await this.resolveTarget(target);
|
||||||
const map = this.requireMap();
|
const map = this.requireTargetMap(target);
|
||||||
if (!feature || !map) return;
|
if (!feature || !map) return;
|
||||||
if (!this.moveCameraToFeature(map, feature)) return;
|
if (!this.moveCameraToFeature(map, feature)) return;
|
||||||
this.setHighlight(target);
|
this.setHighlight(target);
|
||||||
@@ -165,13 +167,14 @@ export class WorkbenchMapController implements WorkbenchMapCommands {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async highlight(target: FeatureTarget) {
|
async highlight(target: FeatureTarget) {
|
||||||
|
if (!this.requireTargetMap(target)) return;
|
||||||
const feature = await this.resolveTarget(target);
|
const feature = await this.resolveTarget(target);
|
||||||
if (feature) this.setHighlight(target);
|
if (feature) this.setHighlight(target);
|
||||||
}
|
}
|
||||||
|
|
||||||
clearHighlight = () => {
|
clearHighlight = () => {
|
||||||
const map = this.options.getMap();
|
const map = this.options.getMap();
|
||||||
if (map && this.highlightedTarget) {
|
if (map && this.highlightedTarget && map.getSource(this.highlightedTarget.sourceId)) {
|
||||||
map.removeFeatureState(toFeatureStateTarget(this.highlightedTarget), "highlighted");
|
map.removeFeatureState(toFeatureStateTarget(this.highlightedTarget), "highlighted");
|
||||||
}
|
}
|
||||||
this.highlightedTarget = null;
|
this.highlightedTarget = null;
|
||||||
@@ -493,9 +496,11 @@ export class WorkbenchMapController implements WorkbenchMapCommands {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private setHighlight(target: FeatureTarget) {
|
private setHighlight(target: FeatureTarget) {
|
||||||
const map = this.requireMap();
|
const map = this.requireTargetMap(target);
|
||||||
if (!map) return;
|
if (!map) return;
|
||||||
if (this.highlightedTarget) map.removeFeatureState(toFeatureStateTarget(this.highlightedTarget), "highlighted");
|
if (this.highlightedTarget && map.getSource(this.highlightedTarget.sourceId)) {
|
||||||
|
map.removeFeatureState(toFeatureStateTarget(this.highlightedTarget), "highlighted");
|
||||||
|
}
|
||||||
map.setFeatureState(toFeatureStateTarget(target), { highlighted: true });
|
map.setFeatureState(toFeatureStateTarget(target), { highlighted: true });
|
||||||
this.highlightedTarget = target;
|
this.highlightedTarget = target;
|
||||||
this.patchState({ target, errorCode: null });
|
this.patchState({ target, errorCode: null });
|
||||||
@@ -510,6 +515,16 @@ export class WorkbenchMapController implements WorkbenchMapCommands {
|
|||||||
return map;
|
return map;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private requireTargetMap(target: FeatureTarget) {
|
||||||
|
const map = this.requireMap();
|
||||||
|
if (!map) return null;
|
||||||
|
if (!map.getSource(target.sourceId)) {
|
||||||
|
this.setError("FEATURE_NOT_FOUND");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
private setError(errorCode: WorkbenchMapErrorCode) {
|
private setError(errorCode: WorkbenchMapErrorCode) {
|
||||||
this.patchState({ errorCode });
|
this.patchState({ errorCode });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,25 +1,8 @@
|
|||||||
import { expect, test } from "@playwright/test";
|
import { expect, test, type Page } from "@playwright/test";
|
||||||
import { mockScadaApi } from "./support/mock-scada-api";
|
import { mockScadaApi } from "./support/mock-scada-api";
|
||||||
|
|
||||||
test("loads SCADA from the backend API and aligns map features by device_id", async ({ page }) => {
|
test("loads SCADA from the backend API and aligns map features by device_id", async ({ page }) => {
|
||||||
await page.route("**/runtime-config.js", async (route) => {
|
await mockRuntimeAndMap(page, { pumps: 0, tanks: 0 });
|
||||||
await route.fulfill({
|
|
||||||
contentType: "application/javascript",
|
|
||||||
body: `globalThis.__TJWATER_CONFIG__ = {
|
|
||||||
TJWATER_AUTH_MODE: "disabled",
|
|
||||||
TJWATER_MAPBOX_ACCESS_TOKEN: "",
|
|
||||||
TJWATER_MAP_URL: "https://scada-map.invalid/geoserver",
|
|
||||||
TJWATER_GEOSERVER_WORKSPACE: "tjwater_next",
|
|
||||||
TJWATER_SERVER_API_BASE_URL: "https://tjwater-api.invalid",
|
|
||||||
TJWATER_AGENT_API_BASE_URL: "http://127.0.0.1:8787",
|
|
||||||
TJWATER_ENABLE_DEV_PANEL: "false",
|
|
||||||
TJWATER_ENABLE_MSW: "false"
|
|
||||||
};`
|
|
||||||
});
|
|
||||||
});
|
|
||||||
await page.route("https://scada-map.invalid/**", async (route) => {
|
|
||||||
await route.fulfill({ status: 204, body: "" });
|
|
||||||
});
|
|
||||||
await mockScadaApi(page, [{
|
await mockScadaApi(page, [{
|
||||||
device_id: "SCADA-1",
|
device_id: "SCADA-1",
|
||||||
device_type: "pressure",
|
device_type: "pressure",
|
||||||
@@ -50,4 +33,87 @@ test("loads SCADA from the backend API and aligns map features by device_id", as
|
|||||||
deviceId: String(feature.properties?.device_id)
|
deviceId: String(feature.properties?.device_id)
|
||||||
}));
|
}));
|
||||||
})).toContainEqual({ id: "SCADA-1", deviceId: "SCADA-1" });
|
})).toContainEqual({ id: "SCADA-1", deviceId: "SCADA-1" });
|
||||||
|
|
||||||
|
const layerTool = page.getByRole("button", { name: /图层:管理地图图层/ });
|
||||||
|
await expect(layerTool).toBeEnabled();
|
||||||
|
await layerTool.click();
|
||||||
|
await expect(page.getByRole("button", { name: /^管线/ })).toBeVisible();
|
||||||
|
await expect(page.getByRole("button", { name: /^水泵/ })).toHaveCount(0);
|
||||||
|
await expect(page.getByRole("button", { name: /^水箱/ })).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("shows data-dependent layer controls only when GeoServer reports features", async ({ page }) => {
|
||||||
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
|
await mockRuntimeAndMap(page, { pumps: 2, tanks: 1 });
|
||||||
|
await mockScadaApi(page);
|
||||||
|
|
||||||
|
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||||
|
|
||||||
|
const layerTool = page.getByRole("button", { name: /图层:管理地图图层/ });
|
||||||
|
await expect(layerTool).toBeEnabled();
|
||||||
|
await layerTool.click();
|
||||||
|
await expect(page.getByRole("button", { name: /^水泵/ })).toBeVisible();
|
||||||
|
await expect(page.getByRole("button", { name: /^水箱/ })).toBeVisible();
|
||||||
|
await expect.poll(() => page.evaluate(() => ({
|
||||||
|
pump: Boolean(globalThis.__waterNetworkMap?.getLayer("pumps-symbol")),
|
||||||
|
tank: Boolean(globalThis.__waterNetworkMap?.getLayer("tanks-symbol"))
|
||||||
|
}))).toEqual({ pump: true, tank: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("loads required map layers while optional layer probes are still pending", async ({ page }) => {
|
||||||
|
let releaseProbe = () => {};
|
||||||
|
const probeGate = new Promise<void>((resolve) => {
|
||||||
|
releaseProbe = resolve;
|
||||||
|
});
|
||||||
|
await mockRuntimeAndMap(page, { pumps: 1, tanks: 0 }, { probeGate });
|
||||||
|
await mockScadaApi(page);
|
||||||
|
|
||||||
|
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await expect.poll(() => page.evaluate(() => ({
|
||||||
|
pipes: Boolean(globalThis.__waterNetworkMap?.getLayer("pipes-casing")),
|
||||||
|
pumps: Boolean(globalThis.__waterNetworkMap?.getLayer("pumps-symbol"))
|
||||||
|
}))).toEqual({ pipes: true, pumps: false });
|
||||||
|
} finally {
|
||||||
|
releaseProbe();
|
||||||
|
}
|
||||||
|
|
||||||
|
await expect.poll(() => page.evaluate(() =>
|
||||||
|
Boolean(globalThis.__waterNetworkMap?.getLayer("pumps-symbol"))
|
||||||
|
)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function mockRuntimeAndMap(
|
||||||
|
page: Page,
|
||||||
|
featureCounts: { pumps: number; tanks: number },
|
||||||
|
options: { probeGate?: Promise<void> } = {}
|
||||||
|
) {
|
||||||
|
await page.route("**/runtime-config.js", async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
contentType: "application/javascript",
|
||||||
|
body: `globalThis.__TJWATER_CONFIG__ = {
|
||||||
|
TJWATER_AUTH_MODE: "disabled",
|
||||||
|
TJWATER_MAPBOX_ACCESS_TOKEN: "",
|
||||||
|
TJWATER_MAP_URL: "https://scada-map.invalid/geoserver",
|
||||||
|
TJWATER_GEOSERVER_WORKSPACE: "tjwater_next",
|
||||||
|
TJWATER_SERVER_API_BASE_URL: "https://tjwater-api.invalid",
|
||||||
|
TJWATER_AGENT_API_BASE_URL: "http://127.0.0.1:8787",
|
||||||
|
TJWATER_ENABLE_DEV_PANEL: "false",
|
||||||
|
TJWATER_ENABLE_MSW: "false"
|
||||||
|
};`
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await page.route("https://scada-map.invalid/**", async (route) => {
|
||||||
|
await route.fulfill({ status: 204, body: "" });
|
||||||
|
});
|
||||||
|
await page.route("https://scada-map.invalid/geoserver/tjwater_next/ows?**", async (route) => {
|
||||||
|
await options.probeGate;
|
||||||
|
const typeNames = new URL(route.request().url()).searchParams.get("typeNames");
|
||||||
|
const sourceId = typeNames?.endsWith(":pumps") ? "pumps" : "tanks";
|
||||||
|
await route.fulfill({
|
||||||
|
contentType: "application/xml",
|
||||||
|
body: `<wfs:FeatureCollection numberMatched="${featureCounts[sourceId]}" numberReturned="0" />`
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user