fix: hide unavailable GeoServer layers

This commit is contained in:
2026-09-11 16:40:24 +08:00
parent f9c71cc076
commit cf699d6207
15 changed files with 683 additions and 60 deletions
@@ -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];
+27 -5
View File
@@ -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
};
}
+18 -1
View File
@@ -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) {
+14 -1
View File
@@ -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]
+14 -8
View File
@@ -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"]);
});
}); });
+19 -3
View File
@@ -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 });
} }
+85 -19
View File
@@ -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" />`
});
});
}