Author SHA1 Message Date
jiang bf44cc0f25 fix: synchronize dynamic GeoServer layer lifecycle
Generic Container CI/CD / test-build-publish (push) Successful in 59s
Frontend CI/CD / build-test-publish-and-deploy (push) Successful in 59s
Optional sources became dynamic while dependent labels and controller state remained static, allowing stale requests to restore removed layers. Register dependent layers through the same lifecycle and invalidate target request generations before release.
2026-09-11 18:25:41 +08:00
jiang 31acd33a2e fix: probe all non-core GeoServer layers
Extend availability checks beyond pumps and tanks so unpublished valve and reservoir layers cannot leave stale controls.
2026-09-11 17:00:54 +08:00
jiang cf699d6207 fix: hide unavailable GeoServer layers 2026-09-11 16:40:24 +08:00
15 changed files with 998 additions and 67 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,9 @@ export function useWorkbenchMapController({
controller.getSnapshot, controller.getSnapshot,
controller.getSnapshot controller.getSnapshot
); );
useEffect(() => {
if (availableSourceIds) controller.syncAvailableSources(availableSourceIds);
}, [availableSourceIds, controller]);
useEffect(() => () => controller.destroy(), [controller]); useEffect(() => () => controller.destroy(), [controller]);
return { controller, state }; return { controller, state };
} }
+231 -10
View File
@@ -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,30 @@ 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)); filterWaterNetworkLayersBySourceIds(
createValueLabelLayers(),
REQUIRED_GEOSERVER_SOURCE_IDS
).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 +381,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 +413,104 @@ 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,
createValueLabelLayers(),
[
...simulationAnnotationLayers.filter((layer) => layer.type === "symbol"),
...waterNetworkHitLayers,
...scadaHitLayers
].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 +565,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,80 @@
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="0" />'))
.mockResolvedValueOnce(new Response('<wfs:FeatureCollection numberMatched="0" />'))
.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", "pumps"],
emptySourceIds: ["valves", "reservoirs", "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 />"))
.mockResolvedValueOnce(new Response("missing", { status: 503 }))
.mockResolvedValueOnce(new Response("<ServiceException />"));
vi.stubGlobal("fetch", fetchMock);
await expect(resolveGeoServerLayerAvailability()).resolves.toEqual({
availableSourceIds: ["pipes", "junctions"],
emptySourceIds: [],
failedSourceIds: ["valves", "reservoirs", "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, "probe"],
["reservoirs", true], ["reservoirs", true, "probe"],
["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: "probe",
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: "probe",
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,192 @@ 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("drops cached features and presentation state when a dynamic source disappears", async () => {
const map = createMap();
map.getLayer.mockReturnValue({} as never);
let collection = pointCollection;
const fetchFeatures = vi.fn(async () => collection);
const controller = new WorkbenchMapController({
getMap: () => map as unknown as MapLibreMap,
isReady: () => true,
getPadding: () => ({ top: 0, right: 0, bottom: 0, left: 0 }),
fetchFeatures
});
const target = { sourceId: "valves" as const, featureId: "V-1" };
await controller.locateAndHighlight(target);
controller.applyLayerGroupStyle("valves", { iconMultiplier: 1.5 });
await controller.showValueLabel({
sourceId: "valves",
property: "elevation",
precision: 1,
showValue: true,
showId: false,
textSize: 12,
textColor: "#0F172A"
});
controller.syncAvailableSources(["pipes", "junctions", "scada"]);
expect(controller.getSnapshot()).toMatchObject({
target: null,
valueLabels: {},
styledGroups: []
});
collection = {
...pointCollection,
features: [{
...pointCollection.features[0],
geometry: { type: "Point", coordinates: [121.9, 29.1] }
}]
};
map.easeTo.mockClear();
await controller.locateAndHighlight(target);
expect(fetchFeatures).toHaveBeenCalledTimes(2);
expect(map.easeTo).toHaveBeenCalledWith(expect.objectContaining({ center: [121.9, 29.1] }));
});
it("ignores an in-flight dynamic-source result from an earlier availability generation", async () => {
const map = createMap();
let resolveFeatures = (_collection: FeatureCollection) => {};
const featureResponse = new Promise<FeatureCollection>((resolve) => {
resolveFeatures = resolve;
});
const controller = new WorkbenchMapController({
getMap: () => map as unknown as MapLibreMap,
isReady: () => true,
getPadding: () => ({ top: 0, right: 0, bottom: 0, left: 0 }),
fetchFeatures: async () => featureResponse
});
controller.syncAvailableSources(["pipes", "junctions", "valves", "scada"]);
const locating = controller.locateAndHighlight({ sourceId: "valves", featureId: "V-1" });
controller.syncAvailableSources(["pipes", "junctions", "scada"]);
controller.syncAvailableSources(["pipes", "junctions", "valves", "scada"]);
resolveFeatures(pointCollection);
await locating;
expect(map.easeTo).not.toHaveBeenCalled();
expect(map.setFeatureState).not.toHaveBeenCalled();
expect(controller.getSnapshot().target).toBeNull();
});
it("keeps a newer cached target when an older dynamic-source request resolves", async () => {
const map = createMap();
let resolveDynamicFeatures = (_collection: FeatureCollection) => {};
const dynamicFeatureResponse = new Promise<FeatureCollection>((resolve) => {
resolveDynamicFeatures = resolve;
});
const fetchFeatures = vi.fn(async (sourceId: string) =>
sourceId === "valves" ? dynamicFeatureResponse : pointCollection
);
const controller = new WorkbenchMapController({
getMap: () => map as unknown as MapLibreMap,
isReady: () => true,
getPadding: () => ({ top: 0, right: 0, bottom: 0, left: 0 }),
fetchFeatures
});
const cachedTarget = { sourceId: "junctions" as const, featureId: "J-1" };
await controller.locateAndHighlight(cachedTarget);
const locatingDynamicTarget = controller.locateAndHighlight({ sourceId: "valves", featureId: "V-1" });
await controller.locateAndHighlight(cachedTarget);
expect(controller.getSnapshot()).toMatchObject({ target: cachedTarget, pending: false });
resolveDynamicFeatures(pointCollection);
await locatingDynamicTarget;
expect(map.easeTo).toHaveBeenCalledTimes(2);
expect(controller.getSnapshot()).toMatchObject({ target: cachedTarget, pending: false });
});
it("does not restore a target after its in-flight request is cleared", async () => {
const map = createMap();
let resolveFeatures = (_collection: FeatureCollection) => {};
const featureResponse = new Promise<FeatureCollection>((resolve) => {
resolveFeatures = resolve;
});
const controller = new WorkbenchMapController({
getMap: () => map as unknown as MapLibreMap,
isReady: () => true,
getPadding: () => ({ top: 0, right: 0, bottom: 0, left: 0 }),
fetchFeatures: async () => featureResponse
});
const locating = controller.locateAndHighlight({ sourceId: "valves", featureId: "V-1" });
controller.clearHighlight();
resolveFeatures(pointCollection);
await locating;
expect(map.easeTo).not.toHaveBeenCalled();
expect(map.setFeatureState).not.toHaveBeenCalled();
expect(controller.getSnapshot()).toMatchObject({ target: null, pending: false });
});
it("reports an unavailable target when its source disappears in flight", async () => {
const map = createMap();
let resolveFeatures = (_collection: FeatureCollection) => {};
const featureResponse = new Promise<FeatureCollection>((resolve) => {
resolveFeatures = resolve;
});
const controller = new WorkbenchMapController({
getMap: () => map as unknown as MapLibreMap,
isReady: () => true,
getPadding: () => ({ top: 0, right: 0, bottom: 0, left: 0 }),
fetchFeatures: async () => featureResponse
});
controller.syncAvailableSources(["pipes", "junctions", "valves", "scada"]);
const locating = controller.locateAndHighlight({ sourceId: "valves", featureId: "V-1" });
controller.syncAvailableSources(["pipes", "junctions", "scada"]);
resolveFeatures(pointCollection);
await locating;
expect(controller.getSnapshot()).toMatchObject({
target: null,
pending: false,
errorCode: "WFS_UNAVAILABLE"
});
});
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 +467,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) => {
@@ -27,6 +27,7 @@ import {
normalizeMapFeatureCollection, normalizeMapFeatureCollection,
parseMapFeatureQuery parseMapFeatureQuery
} from "./map-feature-query"; } from "./map-feature-query";
import { DATA_DEPENDENT_GEOSERVER_SOURCE_IDS } from "./geoserver-layer-availability";
import { import {
applyValueLabelStyle, applyValueLabelStyle,
clearValueLabels, clearValueLabels,
@@ -110,6 +111,10 @@ export class WorkbenchMapController implements WorkbenchMapCommands {
private flowState: FlowOverlayState = createFlowOverlayState(); private flowState: FlowOverlayState = createFlowOverlayState();
private flowUpdateRevision = 0; private flowUpdateRevision = 0;
private scadaAnalysisRevision = 0; private scadaAnalysisRevision = 0;
private targetRequestRevision = 0;
private pendingTargetSourceId: WaterNetworkSourceId | null = null;
private availableSourceIds: ReadonlySet<WaterNetworkSourceId> | null = null;
private sourceAvailabilityGenerations = new Map<WaterNetworkSourceId, number>();
constructor(private readonly options: WorkbenchMapControllerOptions) {} constructor(private readonly options: WorkbenchMapControllerOptions) {}
@@ -121,15 +126,21 @@ export class WorkbenchMapController implements WorkbenchMapCommands {
getSnapshot = () => this.state; getSnapshot = () => this.state;
async zoomToFeature(target: FeatureTarget) { async zoomToFeature(target: FeatureTarget) {
const feature = await this.resolveTarget(target); const requestRevision = this.beginTargetCommand();
const map = this.requireMap(); if (!this.requireTargetMap(target)) return;
const feature = await this.resolveTarget(target, requestRevision);
if (requestRevision !== this.targetRequestRevision) return;
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) {
const feature = await this.resolveTarget(target); const requestRevision = this.beginTargetCommand();
const map = this.requireMap(); if (!this.requireTargetMap(target)) return;
const feature = await this.resolveTarget(target, requestRevision);
if (requestRevision !== this.targetRequestRevision) return;
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,19 +176,90 @@ export class WorkbenchMapController implements WorkbenchMapCommands {
} }
async highlight(target: FeatureTarget) { async highlight(target: FeatureTarget) {
const feature = await this.resolveTarget(target); const requestRevision = this.beginTargetCommand();
if (!this.requireTargetMap(target)) return;
const feature = await this.resolveTarget(target, requestRevision);
if (requestRevision !== this.targetRequestRevision) return;
if (feature) this.setHighlight(target); if (feature) this.setHighlight(target);
} }
clearHighlight = () => { clearHighlight = () => {
this.beginTargetCommand();
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;
this.patchState({ target: null, errorCode: null }); this.patchState({ target: null, errorCode: null });
}; };
syncAvailableSources(sourceIds: readonly WaterNetworkSourceId[]) {
const availableSourceIds = new Set<WaterNetworkSourceId>(sourceIds);
if (this.availableSourceIds) {
DATA_DEPENDENT_GEOSERVER_SOURCE_IDS.forEach((sourceId) => {
if (this.availableSourceIds?.has(sourceId) && !availableSourceIds.has(sourceId)) {
this.sourceAvailabilityGenerations.set(
sourceId,
(this.sourceAvailabilityGenerations.get(sourceId) ?? 0) + 1
);
}
});
}
this.availableSourceIds = availableSourceIds;
DATA_DEPENDENT_GEOSERVER_SOURCE_IDS.forEach((sourceId) => {
if (availableSourceIds.has(sourceId)) return;
const cachePrefix = `${sourceId}:`;
for (const key of this.cachedFeatures.keys()) {
if (key.startsWith(cachePrefix)) this.cachedFeatures.delete(key);
}
});
const targetUnavailable = Boolean(
this.highlightedTarget && !availableSourceIds.has(this.highlightedTarget.sourceId)
);
const pendingTargetUnavailable = Boolean(
this.pendingTargetSourceId && !availableSourceIds.has(this.pendingTargetSourceId)
);
if (pendingTargetUnavailable) {
this.targetRequestRevision += 1;
this.pendingTargetSourceId = null;
}
if (targetUnavailable && this.highlightedTarget) {
const map = this.options.getMap();
if (map?.getSource(this.highlightedTarget.sourceId)) {
map.removeFeatureState(toFeatureStateTarget(this.highlightedTarget), "highlighted");
}
this.highlightedTarget = null;
}
const valueLabelEntries = Object.entries(this.state.valueLabels);
const availableValueLabelEntries = valueLabelEntries.filter(([sourceId]) =>
availableSourceIds.has(sourceId as WaterNetworkSourceId)
);
const valueLabelsChanged = availableValueLabelEntries.length !== valueLabelEntries.length;
const styledGroups = this.state.styledGroups.filter((groupId) =>
availableSourceIds.has(groupId)
);
const styledGroupsChanged = styledGroups.length !== this.state.styledGroups.length;
if (
!targetUnavailable &&
!pendingTargetUnavailable &&
!valueLabelsChanged &&
!styledGroupsChanged
) return;
this.patchState({
target: targetUnavailable ? null : this.state.target,
pending: pendingTargetUnavailable ? false : this.state.pending,
errorCode: pendingTargetUnavailable ? "WFS_UNAVAILABLE" : this.state.errorCode,
valueLabels: valueLabelsChanged
? Object.fromEntries(availableValueLabelEntries) as WorkbenchMapControllerState["valueLabels"]
: this.state.valueLabels,
styledGroups
});
}
async setFlowVisible(visible: boolean) { async setFlowVisible(visible: boolean) {
const map = this.requireMap(); const map = this.requireMap();
if (!map) return; if (!map) return;
@@ -442,29 +524,52 @@ export class WorkbenchMapController implements WorkbenchMapCommands {
destroy() { destroy() {
this.flowUpdateRevision += 1; this.flowUpdateRevision += 1;
this.targetRequestRevision += 1;
this.pendingTargetSourceId = null;
const map = this.options.getMap(); const map = this.options.getMap();
if (map) hideFlowOverlay(map, this.flowState); if (map) hideFlowOverlay(map, this.flowState);
this.listeners.clear(); this.listeners.clear();
} }
private async resolveTarget(target: FeatureTarget) { private beginTargetCommand() {
const requestRevision = ++this.targetRequestRevision;
if (this.pendingTargetSourceId) {
this.pendingTargetSourceId = null;
this.patchState({ pending: false });
}
return requestRevision;
}
private async resolveTarget(target: FeatureTarget, requestRevision: number) {
const map = this.requireMap(); const map = this.requireMap();
if (!map) return null; if (!map) return null;
const key = `${target.sourceId}:${target.featureId}`; const key = `${target.sourceId}:${target.featureId}`;
const cached = this.cachedFeatures.get(key); const dataDependent = DATA_DEPENDENT_GEOSERVER_SOURCE_IDS.includes(
target.sourceId as (typeof DATA_DEPENDENT_GEOSERVER_SOURCE_IDS)[number]
);
const cached = dataDependent ? undefined : this.cachedFeatures.get(key);
if (cached) return cached; if (cached) return cached;
const sourceAvailabilityGeneration = this.sourceAvailabilityGenerations.get(target.sourceId) ?? 0;
this.pendingTargetSourceId = target.sourceId;
this.patchState({ pending: true, errorCode: null }); this.patchState({ pending: true, errorCode: null });
try { try {
const collection = await this.fetchFeatures(target.sourceId, [target.featureId]); const collection = await this.fetchFeatures(target.sourceId, [target.featureId]);
if (
requestRevision !== this.targetRequestRevision ||
sourceAvailabilityGeneration !== (this.sourceAvailabilityGenerations.get(target.sourceId) ?? 0)
) return null;
this.pendingTargetSourceId = null;
const feature = collection.features[0]; const feature = collection.features[0];
if (!feature) { if (!feature) {
this.patchState({ pending: false, errorCode: "FEATURE_NOT_FOUND" }); this.patchState({ pending: false, errorCode: "FEATURE_NOT_FOUND" });
return null; return null;
} }
this.cachedFeatures.set(key, feature); if (!dataDependent) this.cachedFeatures.set(key, feature);
this.patchState({ pending: false }); this.patchState({ pending: false });
return feature; return feature;
} catch { } catch {
if (requestRevision !== this.targetRequestRevision) return null;
this.pendingTargetSourceId = null;
this.patchState({ this.patchState({
pending: false, pending: false,
errorCode: target.sourceId === "scada" ? "SCADA_API_UNAVAILABLE" : "WFS_UNAVAILABLE" errorCode: target.sourceId === "scada" ? "SCADA_API_UNAVAILABLE" : "WFS_UNAVAILABLE"
@@ -493,9 +598,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 +617,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 });
} }
+127 -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, { valves: 0, reservoirs: 0, 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,129 @@ 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: /^节点/ })).toBeVisible();
await expect(page.getByRole("button", { name: /^阀门/ })).toHaveCount(0);
await expect(page.getByRole("button", { name: /^水库/ })).toHaveCount(0);
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, { valves: 1, reservoirs: 1, 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(page.getByRole("button", { name: /^水泵/ })).toBeVisible();
await expect(page.getByRole("button", { name: /^水箱/ })).toBeVisible();
await expect.poll(() => page.evaluate(() => ({
valve: Boolean(globalThis.__waterNetworkMap?.getLayer("valves-symbol")),
valveLabel: Boolean(globalThis.__waterNetworkMap?.getLayer("workbench-value-label-valves")),
reservoir: Boolean(globalThis.__waterNetworkMap?.getLayer("reservoirs-symbol")),
reservoirLabel: Boolean(globalThis.__waterNetworkMap?.getLayer("workbench-value-label-reservoirs")),
pump: Boolean(globalThis.__waterNetworkMap?.getLayer("pumps-symbol")),
tank: Boolean(globalThis.__waterNetworkMap?.getLayer("tanks-symbol"))
}))).toEqual({
valve: true,
valveLabel: true,
reservoir: true,
reservoirLabel: true,
pump: true,
tank: true
});
await expect.poll(() => page.evaluate(() => {
const layerIds = globalThis.__waterNetworkMap?.getStyle().layers.map((layer) => layer.id) ?? [];
return {
valveLabel: layerIds.indexOf("workbench-value-label-valves"),
simulationLabel: layerIds.indexOf("simulation-burst-label")
};
})).toMatchObject({
valveLabel: expect.any(Number),
simulationLabel: expect.any(Number)
});
const layerOrder = await page.evaluate(() => {
const layerIds = globalThis.__waterNetworkMap?.getStyle().layers.map((layer) => layer.id) ?? [];
return [
layerIds.indexOf("workbench-value-label-valves"),
layerIds.indexOf("simulation-burst-label")
];
});
expect(layerOrder[0]).toBeGreaterThanOrEqual(0);
expect(layerOrder[0]).toBeLessThan(layerOrder[1]);
});
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,
{ valves: 0, reservoirs: 0, 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: Record<"valves" | "reservoirs" | "pumps" | "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?.split(":").at(-1);
const featureCount = sourceId && sourceId in featureCounts
? featureCounts[sourceId as keyof typeof featureCounts]
: 0;
await route.fulfill({
contentType: "application/xml",
body: `<wfs:FeatureCollection numberMatched="${featureCount}" numberReturned="0" />`
});
});
}