feat: extend map targeting and overlay controls

Support batch feature targeting and clear map highlights and overlays from the More panel. Remove misleading action chevrons and unused configuration export entries.

Validated the isolated patch with lint, typecheck, 36 focused tests and 5 browser cases using mocked data.
This commit is contained in:
2026-10-28 14:47:33 +08:00
parent e57e0f099f
commit 5b710d1247
17 changed files with 666 additions and 202 deletions
@@ -9,6 +9,112 @@ import {
import { WorkbenchMapController } from "./workbench-map-controller";
describe("WorkbenchMapController", () => {
it("clears every controller overlay, preserves layer styles, and invalidates pending analysis", 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)
});
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,
flowVisible: false, pending: false, valueLabels: {}, styledGroups: ["pipes"]
});
expect(map.removeFeatureState).toHaveBeenCalledWith(expect.objectContaining({ id: "J-1" }), "highlighted");
expect(map.setLayoutProperty).toHaveBeenCalledWith(FLOW_LINE_LAYER_ID, "visibility", "none");
expect(map.easeTo).not.toHaveBeenCalled();
});
it("queries a batch once, fits all matches, and replaces and clears every highlight", async () => {
const map = createMap();
const collection: FeatureCollection = { type: "FeatureCollection", features: [
...pointCollection.features,
{ type: "Feature", properties: { id: "J-2" }, geometry: { type: "Point", coordinates: [121, 29] } }
] };
const fetchFeatures = vi.fn(async () => collection);
const controller = new WorkbenchMapController({
getMap: () => map as unknown as MapLibreMap, isReady: () => true,
getPadding: () => ({ top: 48, right: 48, bottom: 48, left: 48 }), fetchFeatures
});
await expect(controller.locateFeatures("junctions", ["J-1", "J-2", "J-1", "missing"])).resolves.toEqual({
located_ids: ["J-1", "J-2"], missing_ids: ["missing"], fitted: true
});
expect(fetchFeatures).toHaveBeenCalledExactlyOnceWith("junctions", ["J-1", "J-2", "missing"], undefined);
expect(map.fitBounds).toHaveBeenCalledWith([[120.7, 28], [121, 29]], expect.objectContaining({ maxZoom: 18 }));
for (const id of ["J-1", "J-2"]) {
expect(map.setFeatureState).toHaveBeenCalledWith({ source: "junctions", sourceLayer: "junctions", id }, { highlighted: true });
}
expect(controller.getSnapshot().targets).toHaveLength(2);
await controller.highlight({ sourceId: "junctions", featureId: "J-1" });
for (const id of ["J-1", "J-2"]) {
expect(map.removeFeatureState).toHaveBeenCalledWith({ source: "junctions", sourceLayer: "junctions", id }, "highlighted");
}
await controller.highlight({ sourceId: "junctions", featureIds: ["J-1", "J-2"] });
map.removeFeatureState.mockClear();
controller.clearHighlight();
expect(map.removeFeatureState).toHaveBeenCalledTimes(2);
expect(controller.getSnapshot()).toMatchObject({ targets: [], target: null, targetResult: null });
});
it("keeps highlight-only and zoom-only batch behavior separate", async () => {
const map = createMap();
const controller = createController(map, lineCollection);
const target = { sourceId: "pipes" as const, featureIds: ["P-1", "missing"] };
await controller.highlight(target);
expect(map.fitBounds).not.toHaveBeenCalled();
expect(map.easeTo).not.toHaveBeenCalled();
expect(controller.getSnapshot().targetResult).toMatchObject({ featureIds: ["P-1"], missingIds: ["missing"] });
map.setFeatureState.mockClear();
await controller.zoomToFeature(target);
expect(map.fitBounds).toHaveBeenCalledOnce();
expect(map.setFeatureState).not.toHaveBeenCalled();
expect(map.removeFeatureState).not.toHaveBeenCalled();
expect(controller.getSnapshot().targets).toEqual([{ sourceId: "pipes", featureId: "P-1" }]);
});
it("cancels an in-flight batch without applying late highlights or moving the camera", async () => {
const map = createMap();
const abort = new AbortController();
let resolveFeatures = (_value: FeatureCollection) => {};
const fetchFeatures = vi.fn(() => 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
});
const locating = controller.locateFeatures("junctions", ["J-1", "J-2"], abort.signal);
abort.abort();
resolveFeatures(pointCollection);
await expect(locating).rejects.toThrow();
expect(fetchFeatures).toHaveBeenCalledWith("junctions", ["J-1", "J-2"], abort.signal);
expect(map.fitBounds).not.toHaveBeenCalled();
expect(map.setFeatureState).not.toHaveBeenCalled();
expect(controller.getSnapshot()).toMatchObject({ pending: false, targets: [] });
});
it("rejects empty and oversized batches without querying or moving", async () => {
const map = createMap();
const controller = createController(map, pointCollection);
for (const ids of [[], [" "], Array.from({ length: 101 }, (_, i) => `J-${i}`)]) {
await expect(controller.locateFeatures("junctions", ids)).rejects.toThrow("INVALID_FEATURE_IDS");
}
expect(map.fitBounds).not.toHaveBeenCalled();
expect(map.setFeatureState).not.toHaveBeenCalled();
});
it("returns only resolved IDs and rejects a missing target rather than fitting the whole network", async () => {
const map = createMap();
const controller = createController(map, pointCollection);