diff --git a/src/features/workbench/hooks/use-map-interactions.ts b/src/features/workbench/hooks/use-map-interactions.ts index 17adf8e..bd69084 100644 --- a/src/features/workbench/hooks/use-map-interactions.ts +++ b/src/features/workbench/hooks/use-map-interactions.ts @@ -47,6 +47,7 @@ export function clearMapFeatureInteractionState( } type UseMapInteractionsOptions = { + availableSourceIds: readonly WaterNetworkSourceId[]; mapRef: RefObject; mapReady: boolean; onSelectFeature: (feature: DetailFeature) => void; @@ -54,6 +55,7 @@ type UseMapInteractionsOptions = { }; export function useMapInteractions({ + availableSourceIds, mapRef, mapReady, onSelectFeature, @@ -102,7 +104,7 @@ export function useMapInteractions({ 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("mouseleave", hitLayerIds, handleMouseLeave); map.on("click", hitLayerIds, handleClick); @@ -113,17 +115,21 @@ export function useMapInteractions({ map.off("mouseleave", hitLayerIds, handleMouseLeave); map.off("click", hitLayerIds, handleClick); }; - }, [mapRef, mapReady, onSelectFeature, selectedFeature]); + }, [availableSourceIds, mapRef, mapReady, onSelectFeature, selectedFeature]); useEffect(() => { const map = mapRef.current; if (!mapReady || !map) return; 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 () => { - 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) { diff --git a/src/features/workbench/hooks/use-workbench-map-controller.ts b/src/features/workbench/hooks/use-workbench-map-controller.ts index d3d6b1a..dd9bcc9 100644 --- a/src/features/workbench/hooks/use-workbench-map-controller.ts +++ b/src/features/workbench/hooks/use-workbench-map-controller.ts @@ -3,6 +3,7 @@ import type { Map as MapLibreMap } from "maplibre-gl"; import type { ScadaDevice } from "../api/scada-client"; import { useEffect, useRef, useSyncExternalStore, type RefObject } from "react"; import { getResponsiveWorkbenchPadding } from "../map/camera"; +import type { WaterNetworkSourceId } from "../map/sources"; import { WorkbenchMapController } from "../map/workbench-map-controller"; export function useWorkbenchMapController({ @@ -12,7 +13,8 @@ export function useWorkbenchMapController({ rightPanelOpen, conditionPanelExpanded = false, agentPanelWidth, - getScadaFeatures + getScadaFeatures, + availableSourceIds }: { mapRef: RefObject; mapReady: boolean; @@ -21,6 +23,7 @@ export function useWorkbenchMapController({ conditionPanelExpanded?: boolean; agentPanelWidth?: number; getScadaFeatures?: (deviceIds?: string[], signal?: AbortSignal) => Promise>; + availableSourceIds?: readonly WaterNetworkSourceId[]; }) { const valuesRef = useRef({ mapReady, @@ -63,6 +66,12 @@ export function useWorkbenchMapController({ 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]); return { controller, state }; } diff --git a/src/features/workbench/hooks/use-workbench-map.ts b/src/features/workbench/hooks/use-workbench-map.ts index c2283e9..87a9287 100644 --- a/src/features/workbench/hooks/use-workbench-map.ts +++ b/src/features/workbench/hooks/use-workbench-map.ts @@ -1,7 +1,12 @@ import "maplibre-gl/dist/maplibre-gl.css"; 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 type { AccessTokenProvider } from "@/shared/auth/keycloak-auth"; import { env } from "@/shared/config/env"; @@ -14,10 +19,16 @@ import { } from "../map/annotation-layers"; import { fitNetworkBounds } from "../map/camera"; import { + filterWaterNetworkLayersBySourceIds, waterNetworkBusinessLayers, waterNetworkHitLayers, waterNetworkInteractionLayers } 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 { createValueLabelLayers } from "../map/value-label"; import { setSimulationLayersVisibility } from "../map/simulation-layers"; @@ -26,7 +37,9 @@ import { GEOSERVER_WATER_NETWORK_SOURCE_IDS, createBaseStyle, createWaterNetworkSources, - WATER_NETWORK_GLOBAL_VIEW + WATER_NETWORK_GLOBAL_VIEW, + type GeoServerWaterNetworkSourceId, + type WaterNetworkSourceId } from "../map/sources"; import { SCADA_SOURCE_ID, @@ -49,6 +62,8 @@ declare global { type UseWorkbenchMapOptions = { containerRef: RefObject; impactVisible: boolean; + layerVisibility: Record; + onClearSelection: () => void; onSelectFeature: (feature: DetailFeature) => void; selectedFeature: DetailFeature | null; getAccessToken?: AccessTokenProvider; @@ -59,7 +74,9 @@ type UseWorkbenchMapResult = { mapReady: boolean; mapError: string | null; sourceStatuses: WorkbenchSourceStatus[]; + availableSourceIds: WaterNetworkSourceId[]; getScadaFeatures: (deviceIds?: string[], signal?: AbortSignal) => Promise>; + refreshGeoServerLayers: () => boolean; fitNetworkBounds: () => void; }; @@ -75,6 +92,7 @@ export type WorkbenchSourceStatus = { const SOURCE_STATUS_LABELS: Record = { "mapbox-base": "Mapbox 底图", "geoserver-mvt": "GeoServer MVT", + "geoserver-catalog": "GeoServer 图层目录", "scada-api": "SCADA API", "scada-icons": "SCADA 图标", "annotation-source": "业务标注源" @@ -90,9 +108,13 @@ const SOURCE_GROUP_BY_ID: Record = { [SIMULATION_SOURCE_IDS.annotations]: "annotation-source" }; +const GEOSERVER_LAYER_PROBE_TIMEOUT_MS = 8_000; + export function useWorkbenchMap({ containerRef, impactVisible, + layerVisibility, + onClearSelection, onSelectFeature, selectedFeature, getAccessToken @@ -102,6 +124,11 @@ export function useWorkbenchMap({ const [mapReady, setMapReady] = useState(false); const [mapError, setMapError] = useState(null); const [sourceStatuses, setSourceStatuses] = useState>({}); + const [availableGeoServerSourceIds, setAvailableGeoServerSourceIds] = useState< + GeoServerWaterNetworkSourceId[] + >(() => [...REQUIRED_GEOSERVER_SOURCE_IDS]); + const [geoServerLayerRefreshRevision, setGeoServerLayerRefreshRevision] = useState(0); + const geoServerLayerProbeInFlightRef = useRef(false); const scadaCollectionRef = useRef>({ type: "FeatureCollection", 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(() => { impactVisibleRef.current = 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(() => { if (!containerRef.current || mapRef.current) { return; } + setMapReady(false); + setMapError(null); const mapboxToken = env.TJWATER_MAPBOX_ACCESS_TOKEN || undefined; const scadaAbortController = new AbortController(); updateSourceStatus(setSourceStatuses, "scada-api", "loading", "正在通过API加载SCADA设备。"); @@ -155,10 +247,11 @@ export function useWorkbenchMap({ (map.getSource(SCADA_SOURCE_ID) as GeoJSONSource | undefined)?.setData( scadaCollectionRef.current ); - const sources = createWaterNetworkSources(); - GEOSERVER_WATER_NETWORK_SOURCE_IDS.forEach((sourceId) => - map.addSource(sourceId, sources[sourceId]) - ); + const sources = createWaterNetworkSources(REQUIRED_GEOSERVER_SOURCE_IDS); + REQUIRED_GEOSERVER_SOURCE_IDS.forEach((sourceId) => { + const source = sources[sourceId]; + if (source) map.addSource(sourceId, source); + }); map.addSource(SIMULATION_SOURCE_IDS.impactArea, simulationSources.impactArea); map.addSource(SIMULATION_SOURCE_IDS.annotations, simulationSources.annotations); await registerSupplyAssetImages(map); @@ -170,18 +263,27 @@ export function useWorkbenchMap({ simulationAnnotationLayers .filter((layer) => layer.id === "simulation-impact-fill") .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)); simulationAnnotationLayers .filter((layer) => layer.type !== "symbol" && layer.id !== "simulation-impact-fill") .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)); createValueLabelLayers().forEach((layer) => map.addLayer(layer)); simulationAnnotationLayers .filter((layer) => layer.type === "symbol") .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)); setSimulationLayersVisibility(map, impactVisibleRef.current); setMapReady(true); @@ -276,7 +378,14 @@ export function useWorkbenchMap({ }; }, [containerRef, getAccessToken]); + useEffect(() => { + const map = mapRef.current; + if (!mapReady || !map) return; + syncDataDependentGeoServerLayers(map, availableGeoServerSourceIds, layerVisibility); + }, [availableGeoServerSourceIds, layerVisibility, mapReady]); + useMapInteractions({ + availableSourceIds: availableGeoServerSourceIds, mapRef, mapReady, onSelectFeature, @@ -301,11 +410,93 @@ export function useWorkbenchMap({ mapReady, mapError, sourceStatuses: Object.values(sourceStatuses), + availableSourceIds: AVAILABLE_WATER_NETWORK_SOURCE_IDS.filter( + (sourceId) => + sourceId === "scada" || + availableGeoServerSourceIds.includes(sourceId as GeoServerWaterNetworkSourceId) + ), getScadaFeatures, + refreshGeoServerLayers, fitNetworkBounds: fitToNetworkBounds }; } +function syncDataDependentGeoServerLayers( + map: MapLibreMap, + availableSourceIds: readonly GeoServerWaterNetworkSourceId[], + layerVisibility: Record +) { + const availableSourceIdSet = new Set(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, + layerVisibility: Record +) { + 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( event: MapSourceDataEvent, status: WorkbenchSourceStatusValue, @@ -360,6 +551,22 @@ function updateSourceStatus( }); } +function clearSourceStatus( + setSourceStatuses: ( + updater: ( + current: Record + ) => Record + ) => 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 } }) { if (event.sourceId && SOURCE_GROUP_BY_ID[event.sourceId]) { return SOURCE_GROUP_BY_ID[event.sourceId]; diff --git a/src/features/workbench/map-workbench-page.tsx b/src/features/workbench/map-workbench-page.tsx index e56c97e..8595b5d 100644 --- a/src/features/workbench/map-workbench-page.tsx +++ b/src/features/workbench/map-workbench-page.tsx @@ -156,6 +156,9 @@ export function MapWorkbenchPage({ const handleSelectFeature = useCallback((feature: DetailFeature) => { setDetailFeature(feature); }, []); + const handleClearSelection = useCallback(() => { + setDetailFeature(null); + }, []); const agent = useWorkbenchAgent({ 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, impactVisible, + layerVisibility, + onClearSelection: handleClearSelection, onSelectFeature: handleSelectFeature, selectedFeature: detailFeature, getAccessToken @@ -273,7 +287,8 @@ export function MapWorkbenchPage({ rightPanelOpen, conditionPanelExpanded: rightPanelExpanded, agentPanelWidth, - getScadaFeatures + getScadaFeatures, + availableSourceIds }); const activeAgentUiResults = useMemo( @@ -426,8 +441,8 @@ export function MapWorkbenchPage({ ); const layerControlItems = useMemo( - () => createLayerControlItems(layerVisibility), - [layerVisibility] + () => createLayerControlItems(layerVisibility, availableSourceIds), + [availableSourceIds, layerVisibility] ); function handleToggleLayer(layerControlId: string, visible: boolean) { const map = mapRef.current; @@ -492,8 +507,15 @@ export function MapWorkbenchPage({ return; } + if (!refreshGeoServerLayers()) { + showMapNotice({ tone: "warning", message: "业务图层正在检查,请稍候。" }); + return; + } map.triggerRepaint(); - showMapNotice({ tone: "success", message: "已请求地图重新渲染,业务瓦片将在视图变化时刷新。" }); + showMapNotice({ + tone: "success", + message: "已开始重新检查业务图层,并请求地图重新渲染。" + }); } function handleShowDataStatus() { diff --git a/src/features/workbench/map/geoserver-layer-availability.test.ts b/src/features/workbench/map/geoserver-layer-availability.test.ts new file mode 100644 index 0000000..3be4d96 --- /dev/null +++ b/src/features/workbench/map/geoserver-layer-availability.test.ts @@ -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( + '', + { 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( + '', + { 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('')) + .mockResolvedValueOnce(new Response('')); + 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( + "Feature type tjwater_next:pumps unknown", + { 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("")); + vi.stubGlobal("fetch", fetchMock); + + await expect(resolveGeoServerLayerAvailability()).resolves.toEqual({ + availableSourceIds: ["pipes", "junctions", "valves", "reservoirs"], + emptySourceIds: [], + failedSourceIds: ["pumps", "tanks"] + }); + }); +}); diff --git a/src/features/workbench/map/geoserver-layer-availability.ts b/src/features/workbench/map/geoserver-layer-availability.ts new file mode 100644 index 0000000..b34c0c7 --- /dev/null +++ b/src/features/workbench/map/geoserver-layer-availability.ts @@ -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 => + 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 => + 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 { + 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([ + ...REQUIRED_GEOSERVER_SOURCE_IDS, + ...availableOptionalSourceIds + ]); + + return { + availableSourceIds: GEOSERVER_WATER_NETWORK_SOURCE_IDS.filter((sourceId) => + availableSourceIdSet.has(sourceId) + ), + emptySourceIds, + failedSourceIds + }; +} diff --git a/src/features/workbench/map/layers.test.ts b/src/features/workbench/map/layers.test.ts index db212c1..4458ff4 100644 --- a/src/features/workbench/map/layers.test.ts +++ b/src/features/workbench/map/layers.test.ts @@ -1,6 +1,10 @@ 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_ASSET_IMAGE_IDS } from "./supply-icons"; import { @@ -57,6 +61,19 @@ describe("supply network layer styling", () => { expect(ids.indexOf("pipes-hover")).toBeLessThan(ids.indexOf("pipes-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) { diff --git a/src/features/workbench/map/layers.ts b/src/features/workbench/map/layers.ts index 3b9260d..440ba8d 100644 --- a/src/features/workbench/map/layers.ts +++ b/src/features/workbench/map/layers.ts @@ -2,7 +2,10 @@ import type { ExpressionSpecification, StyleSpecification } from "maplibre-gl"; import { MAP_STYLE_TOKENS } from "./map-colors"; import { SUPPLY_LAYER_VISUALS } from "./map-layer-visuals"; 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 { 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 waterNetworkHitLayers = layers.slice(hitIndex) as Layer[]; export const WORKBENCH_INTERACTION_BEFORE_ID = "pipes-hover-outline"; + +export function filterWaterNetworkLayersBySourceIds( + sourceLayers: StyleSpecification["layers"], + sourceIds: readonly GeoServerWaterNetworkSourceId[] +) { + const sourceIdSet = new Set(sourceIds); + return sourceLayers.filter( + (layer) => !("source" in layer) || typeof layer.source !== "string" || sourceIdSet.has(layer.source) + ); +} diff --git a/src/features/workbench/map/map-control-config.test.ts b/src/features/workbench/map/map-control-config.test.ts index 8c61c43..6b7ac4c 100644 --- a/src/features/workbench/map/map-control-config.test.ts +++ b/src/features/workbench/map/map-control-config.test.ts @@ -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", () => { expect(MAP_LEGEND_ITEMS.map((item) => item.id)).toEqual([ "major-pipe", diff --git a/src/features/workbench/map/map-control-config.ts b/src/features/workbench/map/map-control-config.ts index 7c325da..b08ffde 100644 --- a/src/features/workbench/map/map-control-config.ts +++ b/src/features/workbench/map/map-control-config.ts @@ -92,9 +92,12 @@ export const BASE_LAYER_OPTIONS: BaseLayerOption[] = [ } ]; -export function createLayerControlItems(layerVisibility: Record): MapLayerControlItem[] { +export function createLayerControlItems( + layerVisibility: Record, + availableSourceIds: readonly WaterNetworkSourceId[] = AVAILABLE_WATER_NETWORK_SOURCE_IDS +): MapLayerControlItem[] { return [ - ...AVAILABLE_WATER_NETWORK_SOURCE_IDS.map((sourceId) => ({ + ...availableSourceIds.map((sourceId) => ({ id: sourceId, ...SOURCE_CONTROL_LABELS[sourceId], visible: layerVisibility[sourceId] diff --git a/src/features/workbench/map/sources.test.ts b/src/features/workbench/map/sources.test.ts index 65d6cc4..89c781c 100644 --- a/src/features/workbench/map/sources.test.ts +++ b/src/features/workbench/map/sources.test.ts @@ -20,14 +20,14 @@ describe("createWaterNetworkSources", () => { tanks: "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([ - ["pipes", true], - ["junctions", true], - ["valves", true], - ["reservoirs", true], - ["scada", true], - ["pumps", true], - ["tanks", true] + expect(SUPPLY_LAYER_CATALOG.map((layer) => [layer.id, layer.available, layer.availability])).toEqual([ + ["pipes", true, "required"], + ["junctions", true, "required"], + ["valves", true, "required"], + ["reservoirs", true, "required"], + ["scada", true, "required"], + ["pumps", true, "probe"], + ["tanks", true, "probe"] ]); expect(SUPPLY_LAYER_CATALOG.find((layer) => layer.id === "valves")).toMatchObject({ geometry: "point", @@ -67,4 +67,10 @@ describe("createWaterNetworkSources", () => { promoteId: "device_id" }); }); + + it("creates only the requested GeoServer vector sources", () => { + const sources = createWaterNetworkSources(["pipes", "junctions"]); + + expect(Object.keys(sources)).toEqual(["pipes", "junctions"]); + }); }); diff --git a/src/features/workbench/map/sources.ts b/src/features/workbench/map/sources.ts index 18b9555..bc537b5 100644 --- a/src/features/workbench/map/sources.ts +++ b/src/features/workbench/map/sources.ts @@ -39,6 +39,7 @@ export type SupplyLayerCatalogItem = { sourceLayer?: (typeof SOURCE_LAYERS)[keyof typeof SOURCE_LAYERS]; geometry: "line" | "point"; available: boolean; + availability: "required" | "probe"; label: string; icon: string; }; @@ -49,6 +50,7 @@ export const SUPPLY_LAYER_CATALOG = [ sourceLayer: SOURCE_LAYERS.pipes, geometry: "line", available: true, + availability: "required", label: "管线", icon: "pipe" }, @@ -57,6 +59,7 @@ export const SUPPLY_LAYER_CATALOG = [ sourceLayer: SOURCE_LAYERS.junctions, geometry: "point", available: true, + availability: "required", label: "节点", icon: "junction" }, @@ -65,6 +68,7 @@ export const SUPPLY_LAYER_CATALOG = [ sourceLayer: SOURCE_LAYERS.valves, geometry: "point", available: true, + availability: "required", label: "阀门", icon: "valve" }, @@ -73,6 +77,7 @@ export const SUPPLY_LAYER_CATALOG = [ sourceLayer: SOURCE_LAYERS.reservoirs, geometry: "point", available: true, + availability: "required", label: "水库", icon: "reservoir" }, @@ -81,6 +86,7 @@ export const SUPPLY_LAYER_CATALOG = [ sourceLayer: undefined, geometry: "point", available: true, + availability: "required", label: "SCADA", icon: "scada-pressure" }, @@ -89,6 +95,7 @@ export const SUPPLY_LAYER_CATALOG = [ sourceLayer: SOURCE_LAYERS.pumps, geometry: "point", available: true, + availability: "probe", label: "水泵", icon: "pump" }, @@ -97,6 +104,7 @@ export const SUPPLY_LAYER_CATALOG = [ sourceLayer: SOURCE_LAYERS.tanks, geometry: "point", available: true, + availability: "probe", label: "水箱", icon: "tank" } @@ -119,14 +127,22 @@ export function isAvailableWaterNetworkSourceId( return (AVAILABLE_WATER_NETWORK_SOURCE_IDS as readonly string[]).includes(value); } -export function createWaterNetworkSources() { +export function createWaterNetworkSources(): Record; +export function createWaterNetworkSources( + sourceIds: readonly GeoServerWaterNetworkSourceId[] +): Partial>; +export function createWaterNetworkSources( + sourceIds: readonly GeoServerWaterNetworkSourceId[] = GEOSERVER_WATER_NETWORK_SOURCE_IDS +) { return Object.fromEntries( 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] : [] ) - ) as Record; + ) as Partial>; } function createGeoServerVectorSource( diff --git a/src/features/workbench/map/workbench-map-controller.test.ts b/src/features/workbench/map/workbench-map-controller.test.ts index 7c54a81..8492273 100644 --- a/src/features/workbench/map/workbench-map-controller.test.ts +++ b/src/features/workbench/map/workbench-map-controller.test.ts @@ -21,6 +21,43 @@ describe("WorkbenchMapController", () => { 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 () => { const map = createMap(); const padding = { top: 50, right: 420, bottom: 50, left: 320 }; @@ -281,7 +318,7 @@ function createMap() { const images = new Set(); return { 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(), hasImage: vi.fn((id: string) => images.has(id)), addImage: vi.fn((id: string) => { diff --git a/src/features/workbench/map/workbench-map-controller.ts b/src/features/workbench/map/workbench-map-controller.ts index fb11d9b..224920b 100644 --- a/src/features/workbench/map/workbench-map-controller.ts +++ b/src/features/workbench/map/workbench-map-controller.ts @@ -121,15 +121,17 @@ export class WorkbenchMapController implements WorkbenchMapCommands { getSnapshot = () => this.state; async zoomToFeature(target: FeatureTarget) { + if (!this.requireTargetMap(target)) return; const feature = await this.resolveTarget(target); - const map = this.requireMap(); + const map = this.requireTargetMap(target); if (!feature || !map) return; this.moveCameraToFeature(map, feature); } async locateAndHighlight(target: FeatureTarget) { + if (!this.requireTargetMap(target)) return; const feature = await this.resolveTarget(target); - const map = this.requireMap(); + const map = this.requireTargetMap(target); if (!feature || !map) return; if (!this.moveCameraToFeature(map, feature)) return; this.setHighlight(target); @@ -165,13 +167,14 @@ export class WorkbenchMapController implements WorkbenchMapCommands { } async highlight(target: FeatureTarget) { + if (!this.requireTargetMap(target)) return; const feature = await this.resolveTarget(target); if (feature) this.setHighlight(target); } clearHighlight = () => { const map = this.options.getMap(); - if (map && this.highlightedTarget) { + if (map && this.highlightedTarget && map.getSource(this.highlightedTarget.sourceId)) { map.removeFeatureState(toFeatureStateTarget(this.highlightedTarget), "highlighted"); } this.highlightedTarget = null; @@ -493,9 +496,11 @@ export class WorkbenchMapController implements WorkbenchMapCommands { } private setHighlight(target: FeatureTarget) { - const map = this.requireMap(); + const map = this.requireTargetMap(target); 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 }); this.highlightedTarget = target; this.patchState({ target, errorCode: null }); @@ -510,6 +515,16 @@ export class WorkbenchMapController implements WorkbenchMapCommands { 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) { this.patchState({ errorCode }); } diff --git a/tests/browser/scada-api-source.e2e.ts b/tests/browser/scada-api-source.e2e.ts index b195eca..8dee4e9 100644 --- a/tests/browser/scada-api-source.e2e.ts +++ b/tests/browser/scada-api-source.e2e.ts @@ -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"; 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 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 mockRuntimeAndMap(page, { pumps: 0, tanks: 0 }); await mockScadaApi(page, [{ device_id: "SCADA-1", 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) })); })).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((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 } = {} +) { + 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: `` + }); + }); +}