fix: deduplicate workbench requests and remove unused map paths
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import type { Feature, FeatureCollection, Point } from "geojson";
|
||||
import type { ExpressionSpecification, Map as MapLibreMap, StyleSpecification } from "maplibre-gl";
|
||||
import type { ExpressionSpecification, StyleSpecification } from "maplibre-gl";
|
||||
import { MAP_STYLE_TOKENS } from "./map-colors";
|
||||
|
||||
export const SCADA_ANALYSIS_SOURCE_ID = "agent-scada-analysis";
|
||||
@@ -121,19 +121,6 @@ export const scadaAnalysisLayers: Layer[] = [
|
||||
}
|
||||
];
|
||||
|
||||
export function ensureScadaAnalysisLayers(
|
||||
map: Pick<MapLibreMap, "addLayer" | "getLayer">
|
||||
): void {
|
||||
scadaAnalysisLayers.forEach((layer, index) => {
|
||||
if (map.getLayer(layer.id)) return;
|
||||
const beforeId = scadaAnalysisLayers
|
||||
.slice(index + 1)
|
||||
.map((candidate) => candidate.id)
|
||||
.find((candidateId) => Boolean(map.getLayer(candidateId)));
|
||||
map.addLayer(layer, beforeId);
|
||||
});
|
||||
}
|
||||
|
||||
export function createEmptyScadaAnalysisCollection(): FeatureCollection<Point> {
|
||||
return { type: "FeatureCollection", features: [] };
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ import type { StyleSpecification, VectorSourceSpecification } from "maplibre-gl"
|
||||
|
||||
import { GEOSERVER_WORKSPACE, MAP_URL } from "./geoserver-config";
|
||||
import { MAP_STYLE_TOKENS } from "./map-colors";
|
||||
import { SCADA_ANALYSIS_SOURCE_ID, createEmptyScadaAnalysisCollection } from "./scada-analysis";
|
||||
import { SCADA_SOURCE_ID } from "./scada";
|
||||
|
||||
export const WATER_NETWORK_GLOBAL_VIEW = {
|
||||
@@ -325,10 +324,6 @@ export function createBaseStyle(mapboxToken?: string, tiandituToken?: string): S
|
||||
type: "geojson",
|
||||
promoteId: "device_id",
|
||||
data: { type: "FeatureCollection", features: [] }
|
||||
},
|
||||
[SCADA_ANALYSIS_SOURCE_ID]: {
|
||||
type: "geojson",
|
||||
data: createEmptyScadaAnalysisCollection()
|
||||
}
|
||||
};
|
||||
const layers: StyleSpecification["layers"] = [
|
||||
|
||||
@@ -20,29 +20,23 @@ describe("WorkbenchMapController", () => {
|
||||
expect(map.easeTo).not.toHaveBeenCalled();
|
||||
expect(controller.getSnapshot().targets).toEqual([]);
|
||||
});
|
||||
it("clears every controller overlay, preserves layer styles, and invalidates pending analysis", async () => {
|
||||
it("clears controller overlays and preserves layer styles", async () => {
|
||||
const map = createMap();
|
||||
map.getLayer.mockReturnValue({} as never);
|
||||
const setData = vi.fn();
|
||||
map.getSource.mockReturnValue({ setData });
|
||||
let resolveFeatures = (_value: FeatureCollection) => {};
|
||||
const pendingAnalysis = 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: (source) => source === "scada" ? pendingAnalysis : Promise.resolve(pointCollection)
|
||||
fetchFeatures: async () => pointCollection
|
||||
});
|
||||
await controller.highlight({ sourceId: "junctions", featureId: "J-1" });
|
||||
controller.applyLayerGroupStyle("pipes", { color: "#123456" });
|
||||
const showingFlow = controller.setFlowVisible(true);
|
||||
const analysis = controller.renderScadaAnalysis([{ device_id: "J-1", level: "high" }]);
|
||||
controller.clearOverlays();
|
||||
await showingFlow;
|
||||
resolveFeatures(pointCollection);
|
||||
await expect(analysis).rejects.toThrow("ACTION_SUPERSEDED");
|
||||
expect(setData).toHaveBeenCalledWith({ type: "FeatureCollection", features: [] });
|
||||
expect(controller.getSnapshot()).toMatchObject({
|
||||
target: null, targets: [], targetResult: null, scadaAnalysis: null,
|
||||
target: null, targets: [], targetResult: null,
|
||||
flowVisible: false, pending: false, valueLabels: {}, styledGroups: ["pipes"]
|
||||
});
|
||||
expect(map.removeFeatureState).toHaveBeenCalledWith(expect.objectContaining({ id: "J-1" }), "highlighted");
|
||||
@@ -552,37 +546,7 @@ describe("WorkbenchMapController", () => {
|
||||
expect(map.removeFeatureState).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("renders SCADA analysis through the registered GeoJSON source", async () => {
|
||||
const map = createMap();
|
||||
const source = { setData: vi.fn() };
|
||||
map.getSource.mockReturnValue(source);
|
||||
const controller = createController(map, {
|
||||
type: "FeatureCollection",
|
||||
features: [
|
||||
{
|
||||
type: "Feature",
|
||||
geometry: { type: "Point", coordinates: [120.7, 28] },
|
||||
properties: { device_id: "MP01" }
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
await expect(
|
||||
controller.renderScadaAnalysis([{ device_id: "MP01", level: "high" }])
|
||||
).resolves.toMatchObject({
|
||||
rendered_ids: ["MP01"],
|
||||
fitted: true
|
||||
});
|
||||
expect(source.setData).toHaveBeenCalled();
|
||||
expect(controller.getSnapshot()).toMatchObject({
|
||||
errorCode: null,
|
||||
pending: false,
|
||||
scadaAnalysis: {
|
||||
rendered_ids: ["MP01"],
|
||||
fitted: true
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const pointCollection: FeatureCollection = { type: "FeatureCollection", features: [{ type: "Feature", geometry: { type: "Point", coordinates: [120.7, 28] }, properties: { id: "J-1" } }] };
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Feature, FeatureCollection, Geometry, Position } from "geojson";
|
||||
import type { GeoJSONSource, Map as MapLibreMap, PaddingOptions } from "maplibre-gl";
|
||||
import type { Map as MapLibreMap, PaddingOptions } from "maplibre-gl";
|
||||
import { SOURCE_LAYERS, type WaterNetworkSourceId } from "./sources";
|
||||
import { PRESENTATION_FEATURE_SOURCE } from "./presentation-highlight-style";
|
||||
import {
|
||||
@@ -36,14 +36,7 @@ import {
|
||||
type ValueLabelSourceId,
|
||||
type ValueLabelStyle
|
||||
} from "./value-label";
|
||||
import {
|
||||
SCADA_ANALYSIS_SOURCE_ID,
|
||||
createEmptyScadaAnalysisCollection,
|
||||
createScadaAnalysisCollection,
|
||||
ensureScadaAnalysisLayers,
|
||||
type ScadaAnalysisItem,
|
||||
type ScadaAnalysisRenderResult
|
||||
} from "./scada-analysis";
|
||||
|
||||
|
||||
export type FeatureTarget = { sourceId: WaterNetworkSourceId; featureId: string };
|
||||
export type FeatureBatchTarget = { sourceId: WaterNetworkSourceId; featureIds: string[] };
|
||||
@@ -56,7 +49,6 @@ export type WorkbenchMapErrorCode =
|
||||
| "INVALID_FEATURE_IDS"
|
||||
| "WFS_UNAVAILABLE"
|
||||
| "SCADA_API_UNAVAILABLE"
|
||||
| "SCADA_FEATURES_NOT_FOUND"
|
||||
| "ACTION_SUPERSEDED"
|
||||
| "FLOW_UNAVAILABLE"
|
||||
| "INVALID_FLOW_CALCULATION"
|
||||
@@ -72,7 +64,6 @@ export type WorkbenchMapControllerState = {
|
||||
flowVisible: boolean;
|
||||
flowSummary: PipeFlowSummary;
|
||||
styledGroups: LayerGroupId[];
|
||||
scadaAnalysis: ScadaAnalysisRenderResult | null;
|
||||
};
|
||||
|
||||
export type ValueLabelRequest = Omit<ValueLabelStyle, "unit"> & { unit?: string };
|
||||
@@ -89,8 +80,6 @@ export type WorkbenchMapCommands = {
|
||||
clearValueLabel: (sourceId?: ValueLabelSourceId) => void;
|
||||
applyLayerGroupStyle: (groupId: LayerGroupId, patch: LayerGroupStylePatch) => void;
|
||||
resetLayerGroupStyle: (groupId: LayerGroupId) => void;
|
||||
renderScadaAnalysis: (items: ScadaAnalysisItem[], signal?: AbortSignal) => Promise<ScadaAnalysisRenderResult>;
|
||||
clearScadaAnalysis: () => { cleared: true };
|
||||
resetAll: () => void;
|
||||
};
|
||||
|
||||
@@ -114,15 +103,13 @@ export class WorkbenchMapController implements WorkbenchMapCommands {
|
||||
errorCode: null,
|
||||
flowVisible: false,
|
||||
flowSummary: createEmptyPipeFlowSummary(),
|
||||
styledGroups: [],
|
||||
scadaAnalysis: null
|
||||
styledGroups: []
|
||||
};
|
||||
private listeners = new Set<() => void>();
|
||||
private cachedFeatures = new Map<string, Feature>();
|
||||
private flowState: FlowOverlayState = createFlowOverlayState();
|
||||
private flowUpdateRevision = 0;
|
||||
private flowVisibilityRevision = 0;
|
||||
private scadaAnalysisRevision = 0;
|
||||
private targetRequestRevision = 0;
|
||||
private pendingTargetSourceId: WaterNetworkSourceId | null = null;
|
||||
private availableSourceIds: ReadonlySet<WaterNetworkSourceId> | null = null;
|
||||
@@ -399,125 +386,13 @@ export class WorkbenchMapController implements WorkbenchMapCommands {
|
||||
this.patchState({ styledGroups: this.state.styledGroups.filter((item) => item !== groupId), errorCode: null });
|
||||
}
|
||||
|
||||
async renderScadaAnalysis(
|
||||
items: ScadaAnalysisItem[],
|
||||
signal?: AbortSignal
|
||||
): Promise<ScadaAnalysisRenderResult> {
|
||||
const revision = ++this.scadaAnalysisRevision;
|
||||
const map = this.options.getMap();
|
||||
if (!map || !this.options.isReady()) {
|
||||
this.setError("MAP_NOT_READY");
|
||||
throw new Error("MAP_NOT_READY");
|
||||
}
|
||||
this.patchState({ pending: true, errorCode: null });
|
||||
|
||||
let sourceCollection: FeatureCollection;
|
||||
try {
|
||||
sourceCollection = await this.fetchFeatures(
|
||||
"scada",
|
||||
items.map((item) => item.device_id),
|
||||
signal
|
||||
);
|
||||
} catch {
|
||||
if (revision === this.scadaAnalysisRevision) {
|
||||
this.patchState({ pending: false, errorCode: "SCADA_API_UNAVAILABLE" });
|
||||
}
|
||||
throw new Error(
|
||||
revision === this.scadaAnalysisRevision
|
||||
? "SCADA_API_UNAVAILABLE"
|
||||
: "ACTION_SUPERSEDED"
|
||||
);
|
||||
}
|
||||
if (revision !== this.scadaAnalysisRevision) {
|
||||
throw new Error("ACTION_SUPERSEDED");
|
||||
}
|
||||
|
||||
const next = createScadaAnalysisCollection(sourceCollection, items);
|
||||
if (next.collection.features.length === 0) {
|
||||
this.patchState({
|
||||
pending: false,
|
||||
errorCode: "SCADA_FEATURES_NOT_FOUND"
|
||||
});
|
||||
throw new Error("SCADA_FEATURES_NOT_FOUND");
|
||||
}
|
||||
const source = map.getSource(
|
||||
SCADA_ANALYSIS_SOURCE_ID
|
||||
) as GeoJSONSource | undefined;
|
||||
if (!source) {
|
||||
this.patchState({ pending: false, errorCode: "MAP_NOT_READY" });
|
||||
throw new Error("MAP_NOT_READY");
|
||||
}
|
||||
|
||||
try {
|
||||
ensureScadaAnalysisLayers(map);
|
||||
source.setData(next.collection);
|
||||
} catch {
|
||||
this.patchState({ pending: false, errorCode: "MAP_NOT_READY" });
|
||||
throw new Error("MAP_NOT_READY");
|
||||
}
|
||||
|
||||
const result: ScadaAnalysisRenderResult = {
|
||||
...next.result,
|
||||
fitted: true
|
||||
};
|
||||
this.patchState({
|
||||
pending: false,
|
||||
errorCode: null,
|
||||
scadaAnalysis: result
|
||||
});
|
||||
const coordinates = next.collection.features.map(
|
||||
(feature) => feature.geometry.coordinates
|
||||
);
|
||||
if (coordinates.length === 1) {
|
||||
map.easeTo({
|
||||
center: [coordinates[0][0], coordinates[0][1]],
|
||||
zoom: 18,
|
||||
padding: this.options.getPadding(),
|
||||
duration: this.options.reducedMotion?.() ? 0 : 600
|
||||
});
|
||||
} else {
|
||||
const bounds = coordinates.reduce<[number, number, number, number]>(
|
||||
(value, coordinate) => [
|
||||
Math.min(value[0], coordinate[0]),
|
||||
Math.min(value[1], coordinate[1]),
|
||||
Math.max(value[2], coordinate[0]),
|
||||
Math.max(value[3], coordinate[1])
|
||||
],
|
||||
[Infinity, Infinity, -Infinity, -Infinity]
|
||||
);
|
||||
map.fitBounds(
|
||||
[
|
||||
[bounds[0], bounds[1]],
|
||||
[bounds[2], bounds[3]]
|
||||
],
|
||||
{
|
||||
padding: this.options.getPadding(),
|
||||
maxZoom: 18,
|
||||
duration: this.options.reducedMotion?.() ? 0 : 600
|
||||
}
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
clearScadaAnalysis() {
|
||||
this.scadaAnalysisRevision += 1;
|
||||
const map = this.options.getMap();
|
||||
(map?.getSource(SCADA_ANALYSIS_SOURCE_ID) as GeoJSONSource | undefined)?.setData(
|
||||
createEmptyScadaAnalysisCollection()
|
||||
);
|
||||
this.patchState({ scadaAnalysis: null, pending: false, errorCode: null });
|
||||
return { cleared: true as const };
|
||||
}
|
||||
|
||||
clearOverlays = () => {
|
||||
this.clearHighlight();
|
||||
this.clearValueLabel();
|
||||
this.clearScadaAnalysis();
|
||||
this.flowVisibilityRevision += 1;
|
||||
const map = this.options.getMap();
|
||||
if (map) hideFlowOverlay(map, this.flowState);
|
||||
this.patchState({ flowVisible: false });
|
||||
this.patchState({ flowVisible: false, pending: false, errorCode: null });
|
||||
};
|
||||
|
||||
resetAll = () => {
|
||||
@@ -536,8 +411,7 @@ export class WorkbenchMapController implements WorkbenchMapCommands {
|
||||
errorCode: null,
|
||||
flowVisible: false,
|
||||
flowSummary: createEmptyPipeFlowSummary(),
|
||||
styledGroups: [],
|
||||
scadaAnalysis: null
|
||||
styledGroups: []
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user