feat: expand intelligent investigation workspace
Generic Container CI/CD / test-build-publish (push) Successful in 1m52s
Frontend CI/CD / build-test-publish-and-deploy (push) Successful in 1m52s

This commit is contained in:
2026-10-30 17:30:19 +08:00
parent ddd03dbff6
commit c0e4231b83
223 changed files with 13155 additions and 5304 deletions
@@ -1,10 +0,0 @@
export const waitForAgentPresentation = async (id: string, signal: AbortSignal) => {
const deadline = Date.now() + 5000;
while (Date.now() < deadline) {
signal.throwIfAborted();
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
const element = [...document.querySelectorAll<HTMLElement>("[data-agent-ui-result]")].find((item) => item.dataset.agentUiResult === id);
if (element && element.getBoundingClientRect().height > 0) return;
}
throw new Error("PRESENTATION_NOT_VISIBLE");
};
+1 -8
View File
@@ -1,3 +1,4 @@
import { webMercatorToLngLat } from "@/features/map/core";
import type { Map as MapLibreMap, PaddingOptions } from "maplibre-gl";
import { getWorkbenchCameraPadding } from "../layout/workbench-layout";
import { WATER_NETWORK_GLOBAL_VIEW } from "./sources";
@@ -10,7 +11,6 @@ export type NetworkViewParams = {
bbox3857: WebMercatorBbox;
};
const WEB_MERCATOR_RADIUS = 6378137;
const GLOBAL_VIEW_MAX_ZOOM = 13;
const GLOBAL_VIEW_PADDING: CompletePaddingOptions = { top: 96, right: 48, bottom: 48, left: 48 };
export function getWorkbenchPadding(
@@ -108,10 +108,3 @@ function getLngLatBoundsFromBbox3857(bbox3857: WebMercatorBbox): LngLatBounds {
return [webMercatorToLngLat(minX, minY), webMercatorToLngLat(maxX, maxY)];
}
function webMercatorToLngLat(x: number, y: number): [number, number] {
const lng = (x / WEB_MERCATOR_RADIUS) * (180 / Math.PI);
const lat = (2 * Math.atan(Math.exp(y / WEB_MERCATOR_RADIUS)) - Math.PI / 2) * (180 / Math.PI);
return [lng, lat];
}
@@ -0,0 +1,163 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { InvestigationOverlay } from "../api/investigation-presentation";
import type { Map as MapLibreMap } from "maplibre-gl";
import { PRESENTATION_FEATURE_SOURCE } from "./presentation-highlight-style";
import {
InvestigationMapPresentation,
INVESTIGATION_OVERLAY_SOURCE
} from "./investigation-map-presentation";
const data: InvestigationOverlay["data"] = {
type: "FeatureCollection",
features: [
{ type: "Feature", properties: { device_type: "pressure", [PRESENTATION_FEATURE_SOURCE]: "scada" }, geometry: { type: "Point", coordinates: [121.48, 31.1] } }
]
};
function setup(reduced = false) {
vi.stubGlobal(
"matchMedia",
vi.fn(() => ({ matches: reduced }))
);
vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) =>
window.setTimeout(() => callback(0), 16)
);
vi.stubGlobal("cancelAnimationFrame", (id: number) => window.clearTimeout(id));
const visibility = new Map<string, unknown>();
const paint = new Map<string, unknown>();
const source = { setData: vi.fn() };
const map = {
getSource: vi.fn(() => source),
getLayer: vi.fn(() => ({})),
getLayoutProperty: vi.fn(
(id: string, property: string) => visibility.get(`${id}:${property}`) ?? "none"
),
setLayoutProperty: vi.fn((id: string, property: string, value: unknown) =>
visibility.set(`${id}:${property}`, value)
),
setPaintProperty: vi.fn((id: string, property: string, value: unknown) =>
paint.set(`${id}:${property}`, value)
),
fitBounds: vi.fn(),
stop: vi.fn(),
removeLayer: vi.fn(),
removeSource: vi.fn()
};
const scene = new InvestigationMapPresentation(
() => map as unknown as MapLibreMap,
() => ({ top: 24, right: 400, bottom: 128, left: 24 })
);
const overlay = {
action: "set_overlay",
overlay_id: "test-area",
title: "合成区域",
mode: "geometry",
evidence_id: "test",
visible: true,
data
} as const;
return { scene, map, paint, visibility, overlay, source };
}
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});
describe("investigation map motion", () => {
it("pulses selection opacity without changing native circle sizes, then restores its static style", () => {
vi.useFakeTimers();
const { scene, map, paint } = setup();
scene.highlight(data, true, false);
vi.advanceTimersByTime(32);
const id = "investigation-presentation-highlights-scada-pressure-selected";
expect(paint.get(`${id}:circle-stroke-opacity`)).toBeLessThan(1);
expect(map.setPaintProperty.mock.calls.some(([, property]) => property === "circle-radius")).toBe(false);
scene.stopPulse();
expect(paint.get(`${id}:circle-stroke-opacity`)).toBe(1);
const calls = map.setPaintProperty.mock.calls.length;
vi.advanceTimersByTime(100);
expect(map.setPaintProperty).toHaveBeenCalledTimes(calls);
});
it.each([false, true])(
"fits smoothly, respects reduced motion %s and stops the previous camera movement",
(reduced) => {
const { scene, map } = setup(reduced);
scene.highlight(data, false, true);
scene.fitHighlights();
expect(map.stop).toHaveBeenCalledTimes(2);
expect(map.fitBounds).toHaveBeenLastCalledWith(
[
[121.48, 31.1],
[121.48, 31.1]
],
expect.objectContaining({
duration: reduced ? 0 : 450,
linear: true,
maxZoom: 18,
padding: { top: 24, right: 400, bottom: 128, left: 24 }
})
);
expect(map.stop.mock.invocationCallOrder[1]).toBeLessThan(
map.fitBounds.mock.invocationCallOrder[1]
);
}
);
it("fades overlays and cancels an outdated hide when the user shows them again", () => {
vi.useFakeTimers();
const { scene, paint, visibility, overlay } = setup();
const id = `${INVESTIGATION_OVERLAY_SOURCE}-point`;
scene.setOverlay(overlay, false);
expect(paint.get(`${id}:circle-opacity`)).toBe(0);
vi.advanceTimersByTime(20);
expect(paint.get(`${id}:circle-opacity`)).toBe(1);
expect(paint.get(`${id}:circle-opacity-transition`)).toEqual({ duration: 180, delay: 0 });
scene.showOverlay(false);
expect(paint.get(`${id}:circle-opacity`)).toBe(0);
expect(visibility.get(`${id}:visibility`)).toBe("visible");
vi.advanceTimersByTime(100);
scene.showOverlay(true);
vi.advanceTimersByTime(200);
expect(visibility.get(`${id}:visibility`)).toBe("visible");
expect(paint.get(`${id}:circle-opacity`)).toBe(1);
scene.showOverlay(false);
vi.advanceTimersByTime(181);
expect(visibility.get(`${id}:visibility`)).toBe("none");
});
it("hides immediately with reduced motion and cancels callbacks during clear or destroy", () => {
vi.useFakeTimers();
const reduced = setup(true);
reduced.scene.setOverlay(reduced.overlay, false);
reduced.scene.showOverlay(false);
expect(reduced.visibility.get(`${INVESTIGATION_OVERLAY_SOURCE}-point:visibility`)).toBe("none");
const { scene, map, overlay } = setup();
scene.setOverlay(overlay, false);
scene.clearOverlay();
const calls = map.setPaintProperty.mock.calls.length;
vi.advanceTimersByTime(500);
expect(map.setPaintProperty).toHaveBeenCalledTimes(calls);
scene.setOverlay(overlay, false);
scene.destroy();
const afterDestroy = map.setPaintProperty.mock.calls.length;
vi.advanceTimersByTime(500);
expect(map.setPaintProperty).toHaveBeenCalledTimes(afterDestroy);
});
});
describe("presentation ownership", () => {
it("renders and clears only the owning surface's SCADA data and map sources", () => {
const { map, source } = setup(true);
const scene = new InvestigationMapPresentation(() => map as unknown as MapLibreMap, () => ({ top: 24, right: 24, bottom: 24, left: 24 }), "chat");
const result = scene.renderScadaAnalysis({ type: "FeatureCollection", features: [
{ type: "Feature", properties: { device_id: "D1" }, geometry: { type: "Point", coordinates: [121, 31] } }
] }, [{ device_id: "D1", level: "high" }, { device_id: "missing", level: "low" }]);
expect(result).toEqual({ rendered_ids: ["D1"], missing_ids: ["missing"], level_counts: { high: 1, medium: 0, low: 0, unrated: 0 }, fitted: true });
expect(source.setData).toHaveBeenCalledWith(expect.objectContaining({ features: [expect.objectContaining({ properties: expect.objectContaining({ analysis_level: "high" }) })] }));
scene.clear(); scene.destroy();
expect(map.removeSource.mock.calls.map(([id]) => id)).toEqual(["chat-presentation-highlights", "chat-presentation-overlay", "chat-presentation-scada"]);
expect(map.removeLayer.mock.calls.every(([id]) => id.startsWith("chat-presentation-"))).toBe(true);
expect(source.setData).toHaveBeenLastCalledWith({ type: "FeatureCollection", features: [] });
});
});
@@ -1,17 +1,17 @@
import type { FeatureCollection, Geometry, Position } from "geojson";
import type { GeoJSONSource, Map as MapLibreMap, PaddingOptions, LayerSpecification } from "maplibre-gl";
import type { InvestigationOverlay } from "../api/investigation-presentation";
import { createScadaAnalysisCollection, createEmptyScadaAnalysisCollection, scadaAnalysisLayers, SCADA_ANALYSIS_SOURCE_ID, type ScadaAnalysisItem } from "./scada-analysis";
import { MAP_STYLE_TOKENS } from "./map-colors";
import { createPresentationHighlightLayers, presentationFeatureSource, PRESENTATION_FEATURE_SOURCE } from "./presentation-highlight-style";
export const INVESTIGATION_HIGHLIGHT_SOURCE = "investigation-presentation-highlights";
export const INVESTIGATION_OVERLAY_SOURCE = "investigation-presentation-overlay";
const empty = (): FeatureCollection => ({ type: "FeatureCollection", features: [] });
const color = MAP_STYLE_TOKENS.state.selected;
type OverlayOpacity = "fill-opacity" | "circle-opacity" | "circle-stroke-opacity" | "heatmap-opacity" | "line-opacity";
type OverlayOpacityValues = Partial<Record<OverlayOpacity, number>>;
const layers: LayerSpecification[] = [
{ id: `${INVESTIGATION_HIGHLIGHT_SOURCE}-fill`, source: INVESTIGATION_HIGHLIGHT_SOURCE, type: "fill", filter: ["==", ["geometry-type"], "Polygon"], paint: { "fill-color": color, "fill-opacity": 0.18 } },
{ id: `${INVESTIGATION_HIGHLIGHT_SOURCE}-line`, source: INVESTIGATION_HIGHLIGHT_SOURCE, type: "line", filter: ["!=", ["geometry-type"], "Point"], paint: { "line-color": color, "line-width": 5, "line-opacity": 0.85 } },
{ id: `${INVESTIGATION_HIGHLIGHT_SOURCE}-point`, source: INVESTIGATION_HIGHLIGHT_SOURCE, type: "circle", filter: ["==", ["geometry-type"], "Point"], paint: { "circle-color": color, "circle-radius": 11, "circle-opacity": 0.65, "circle-stroke-color": MAP_STYLE_TOKENS.canvas.casing, "circle-stroke-width": 2 } },
{ id: `${INVESTIGATION_OVERLAY_SOURCE}-fill`, source: INVESTIGATION_OVERLAY_SOURCE, type: "fill", filter: ["==", ["geometry-type"], "Polygon"], paint: { "fill-color": MAP_STYLE_TOKENS.supply.waterQuality, "fill-opacity": 0.16 } },
{ id: `${INVESTIGATION_OVERLAY_SOURCE}-outline`, source: INVESTIGATION_OVERLAY_SOURCE, type: "line", filter: ["==", ["geometry-type"], "Polygon"], paint: { "line-color": MAP_STYLE_TOKENS.supply.waterQuality, "line-width": 2, "line-dasharray": [2, 2] } },
{ id: `${INVESTIGATION_OVERLAY_SOURCE}-line`, source: INVESTIGATION_OVERLAY_SOURCE, type: "line", filter: ["==", ["geometry-type"], "LineString"], paint: { "line-color": MAP_STYLE_TOKENS.supply.waterQuality, "line-width": 3 } },
@@ -38,16 +38,33 @@ export class InvestigationMapPresentation {
private pulseDeadline: number | null = null;
private highlighted = empty();
private overlay: InvestigationOverlay | null = null;
private overlayFrame: number | null = null;
private overlayHideTimer: number | null = null;
constructor(private getMap: () => MapLibreMap | null, private getPadding: () => PaddingOptions) {}
private readonly highlightSource: string;
private readonly overlaySource: string;
private readonly scadaSource: string;
private readonly scadaLayers: LayerSpecification[];
private readonly layers: LayerSpecification[];
constructor(private getMap: () => MapLibreMap | null, private getPadding: () => PaddingOptions, scope = "investigation") {
this.highlightSource = `${scope}-presentation-highlights`;
this.overlaySource = `${scope}-presentation-overlay`;
this.scadaSource = `${scope}-presentation-scada`;
this.scadaLayers = scadaAnalysisLayers.map((layer) => ({ ...layer, id: layer.id.replace(SCADA_ANALYSIS_SOURCE_ID, this.scadaSource), source: this.scadaSource } as LayerSpecification));
this.layers = layers.map((layer) => ({ ...layer,
id: layer.id.replace(INVESTIGATION_HIGHLIGHT_SOURCE, this.highlightSource).replace(INVESTIGATION_OVERLAY_SOURCE, this.overlaySource),
source: String("source" in layer ? layer.source : "").replace(INVESTIGATION_HIGHLIGHT_SOURCE, this.highlightSource).replace(INVESTIGATION_OVERLAY_SOURCE, this.overlaySource)
} as LayerSpecification));
}
private ensure() {
const map = this.getMap();
if (!map) throw new Error("MAP_NOT_READY");
for (const id of [INVESTIGATION_HIGHLIGHT_SOURCE, INVESTIGATION_OVERLAY_SOURCE]) {
for (const id of [this.highlightSource, this.overlaySource]) {
if (!map.getSource(id)) map.addSource(id, { type: "geojson", data: empty() });
}
for (const layer of layers) {
for (const layer of this.layers) {
if (!map.getLayer(layer.id)) map.addLayer({ ...layer, layout: { visibility: "none" } });
}
return map;
@@ -55,17 +72,31 @@ export class InvestigationMapPresentation {
highlight(data: FeatureCollection, pulse: boolean, fit: boolean) {
this.stopPulse();
const nativeLayers = this.getMap()?.getStyle?.()?.layers;
for (const value of new Set(data.features.map((feature) => feature.properties?.[PRESENTATION_FEATURE_SOURCE]))) {
const source = presentationFeatureSource(value);
if (!source) continue;
for (const layer of createPresentationHighlightLayers(source, this.highlightSource, nativeLayers)) {
const index = this.layers.findIndex((current) => current.id === layer.id);
if (index < 0) this.layers.push(layer);
else {
this.layers[index] = layer;
const map = this.getMap();
if (map?.getLayer(layer.id) && "paint" in layer)
for (const [property, value] of Object.entries(layer.paint ?? {}))
map.setPaintProperty(layer.id, property as Parameters<MapLibreMap["setPaintProperty"]>[1], value);
}
}
}
const map = this.ensure();
this.highlighted = data;
(map.getSource(INVESTIGATION_HIGHLIGHT_SOURCE) as GeoJSONSource).setData(data);
this.visibility(INVESTIGATION_HIGHLIGHT_SOURCE, data.features.length > 0);
(map.getSource(this.highlightSource) as GeoJSONSource).setData(data);
this.visibility(this.highlightSource, data.features.length > 0);
if (fit) this.fit(data);
if (pulse && !window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
const animate = (time: number) => {
if (!map.getLayer(`${INVESTIGATION_HIGHLIGHT_SOURCE}-point`)) return;
const wave = (Math.sin(time / 300) + 1) / 2;
map.setPaintProperty(`${INVESTIGATION_HIGHLIGHT_SOURCE}-point`, "circle-radius", 9 + wave * 5);
map.setPaintProperty(`${INVESTIGATION_HIGHLIGHT_SOURCE}-line`, "line-opacity", 0.45 + wave * 0.4);
this.highlightOpacity(0.45 + wave * 0.55);
this.pulseFrame = requestAnimationFrame(animate);
};
this.pulseFrame = requestAnimationFrame(animate);
@@ -78,40 +109,94 @@ export class InvestigationMapPresentation {
this.pulseFrame = null;
if (this.pulseDeadline !== null) window.clearTimeout(this.pulseDeadline);
this.pulseDeadline = null;
this.highlightOpacity(1);
}
private highlightOpacity(opacity: number) {
const map = this.getMap();
if (map?.getLayer(`${INVESTIGATION_HIGHLIGHT_SOURCE}-point`)) map.setPaintProperty(`${INVESTIGATION_HIGHLIGHT_SOURCE}-point`, "circle-radius", 11);
if (map?.getLayer(`${INVESTIGATION_HIGHLIGHT_SOURCE}-line`)) map.setPaintProperty(`${INVESTIGATION_HIGHLIGHT_SOURCE}-line`, "line-opacity", 0.85);
for (const layer of this.layers) {
if (!("source" in layer) || layer.source !== this.highlightSource || !map?.getLayer(layer.id)) continue;
if (layer.type === "line") map.setPaintProperty(layer.id, "line-opacity", opacity);
else if (layer.type === "circle") map.setPaintProperty(layer.id, "circle-stroke-opacity", opacity);
}
}
clearHighlights() {
this.stopPulse();
this.highlighted = empty();
(this.getMap()?.getSource(INVESTIGATION_HIGHLIGHT_SOURCE) as GeoJSONSource | undefined)?.setData(empty());
this.visibility(INVESTIGATION_HIGHLIGHT_SOURCE, false);
(this.getMap()?.getSource(this.highlightSource) as GeoJSONSource | undefined)?.setData(empty());
this.visibility(this.highlightSource, false);
}
setOverlay(overlay: InvestigationOverlay, fit: boolean) {
const map = this.ensure();
this.overlay = overlay;
(map.getSource(INVESTIGATION_OVERLAY_SOURCE) as GeoJSONSource).setData(overlay.data);
(map.getSource(this.overlaySource) as GeoJSONSource).setData(overlay.data);
this.showOverlay(overlay.visible);
if (fit) this.fit(overlay.data);
}
private cancelOverlayTransition() {
if (this.overlayFrame !== null) cancelAnimationFrame(this.overlayFrame);
if (this.overlayHideTimer !== null) window.clearTimeout(this.overlayHideTimer);
this.overlayFrame = null;
this.overlayHideTimer = null;
}
showOverlay(visible: boolean) {
this.cancelOverlayTransition();
if (!this.overlay) return;
this.overlay = { ...this.overlay, visible };
const map = this.getMap();
for (const layer of layers.filter((item) => "source" in item && item.source === INVESTIGATION_OVERLAY_SOURCE)) {
if (map?.getLayer(layer.id)) map.setLayoutProperty(layer.id, "visibility", visible && (this.overlay.mode === "heatmap") === (layer.type === "heatmap") ? "visible" : "none");
if (!map) return;
const duration = window.matchMedia("(prefers-reduced-motion: reduce)").matches ? 0 : 180;
const entering: Array<{ id: string; opacity: OverlayOpacityValues }> = [];
const hiding: string[] = [];
const paint = (id: string, opacity: OverlayOpacityValues, shown: boolean, milliseconds: number) => {
if (!map.getLayer(id)) return;
for (const [property, value] of Object.entries(opacity) as Array<[OverlayOpacity, number]>) {
map.setPaintProperty(id, `${property}-transition` as const, { duration: milliseconds, delay: 0 });
map.setPaintProperty(id, property, shown ? value : 0);
}
};
for (const layer of this.layers.filter((item) => "source" in item && item.source === this.overlaySource)) {
if (!map.getLayer(layer.id)) continue;
const opacity: OverlayOpacityValues = layer.type === "fill" ? { "fill-opacity": 0.16 }
: layer.type === "circle" ? { "circle-opacity": 1, "circle-stroke-opacity": 1 }
: layer.type === "heatmap" ? { "heatmap-opacity": 0.65 } : { "line-opacity": 1 };
const shown = visible && (this.overlay.mode === "heatmap") === (layer.type === "heatmap");
const wasHidden = map.getLayoutProperty(layer.id, "visibility") === "none";
if (shown) {
if (duration && wasHidden) {
paint(layer.id, opacity, false, 0);
map.setLayoutProperty(layer.id, "visibility", "visible");
entering.push({ id: layer.id, opacity });
} else {
map.setLayoutProperty(layer.id, "visibility", "visible");
paint(layer.id, opacity, true, duration);
}
} else {
paint(layer.id, opacity, false, duration);
if (!duration || wasHidden) map.setLayoutProperty(layer.id, "visibility", "none");
else hiding.push(layer.id);
}
}
if (entering.length) this.overlayFrame = requestAnimationFrame(() => {
this.overlayFrame = null;
for (const layer of entering) paint(layer.id, layer.opacity, true, duration);
});
if (hiding.length) this.overlayHideTimer = window.setTimeout(() => {
this.overlayHideTimer = null;
for (const id of hiding) if (map.getLayer(id)) map.setLayoutProperty(id, "visibility", "none");
}, duration);
}
clearOverlay(id?: string) {
if (id && id !== this.overlay?.overlay_id) return;
this.cancelOverlayTransition();
this.overlay = null;
(this.getMap()?.getSource(INVESTIGATION_OVERLAY_SOURCE) as GeoJSONSource | undefined)?.setData(empty());
this.visibility(INVESTIGATION_OVERLAY_SOURCE, false);
(this.getMap()?.getSource(this.overlaySource) as GeoJSONSource | undefined)?.setData(empty());
this.visibility(this.overlaySource, false);
}
fitOverlay() { if (this.overlay) this.fit(this.overlay.data); }
@@ -119,7 +204,7 @@ export class InvestigationMapPresentation {
private visibility(source: string, visible: boolean) {
const map = this.getMap();
for (const layer of layers.filter((item) => "source" in item && item.source === source)) {
for (const layer of this.layers.filter((item) => "source" in item && item.source === source)) {
if (map?.getLayer(layer.id)) map.setLayoutProperty(layer.id, "visibility", visible ? "visible" : "none");
}
}
@@ -128,15 +213,40 @@ export class InvestigationMapPresentation {
const positions = data.features.flatMap((feature) => feature.geometry ? investigationGeometryCoordinates(feature.geometry) : []);
if (!positions.length) return;
const bounds = positions.reduce((value, point) => [Math.min(value[0], point[0]), Math.min(value[1], point[1]), Math.max(value[2], point[0]), Math.max(value[3], point[1])], [Infinity, Infinity, -Infinity, -Infinity]);
this.getMap()?.fitBounds([[bounds[0], bounds[1]], [bounds[2], bounds[3]]], { padding: this.getPadding(), maxZoom: 18, duration: 0 });
const map = this.getMap();
if (!map) return;
map.stop();
map.fitBounds([[bounds[0], bounds[1]], [bounds[2], bounds[3]]], {
padding: this.getPadding(), maxZoom: 18, linear: true,
duration: window.matchMedia("(prefers-reduced-motion: reduce)").matches ? 0 : 450,
easing: (progress: number) => 1 - (1 - progress) ** 3
});
}
clear() { this.clearHighlights(); this.clearOverlay(); }
renderScadaAnalysis(data: FeatureCollection, items: ScadaAnalysisItem[], fit = true) {
const map = this.getMap();
if (!map) throw new Error("MAP_NOT_READY");
const next = createScadaAnalysisCollection(data, items);
if (!next.result.rendered_ids.length) throw new Error("SCADA_FEATURES_NOT_FOUND");
if (!map.getSource(this.scadaSource)) map.addSource(this.scadaSource, { type: "geojson", data: createEmptyScadaAnalysisCollection() });
for (const layer of this.scadaLayers) if (!map.getLayer(layer.id)) map.addLayer(layer);
(map.getSource(this.scadaSource) as GeoJSONSource).setData(next.collection);
if (fit) this.fit(next.collection);
return { ...next.result, fitted: fit };
}
clearScadaAnalysis() {
(this.getMap()?.getSource(this.scadaSource) as GeoJSONSource | undefined)?.setData(createEmptyScadaAnalysisCollection());
return { cleared: true as const };
}
clear() { this.clearHighlights(); this.clearOverlay(); this.clearScadaAnalysis(); }
destroy() {
this.cancelOverlayTransition();
this.stopPulse();
const map = this.getMap();
if (!map) return;
for (const layer of [...layers].reverse()) if (map.getLayer(layer.id)) map.removeLayer(layer.id);
for (const id of [INVESTIGATION_HIGHLIGHT_SOURCE, INVESTIGATION_OVERLAY_SOURCE]) if (map.getSource(id)) map.removeSource(id);
for (const layer of [...this.layers, ...this.scadaLayers].reverse()) if (map.getLayer(layer.id)) map.removeLayer(layer.id);
for (const id of [this.highlightSource, this.overlaySource, this.scadaSource]) if (map.getSource(id)) map.removeSource(id);
}
}
@@ -0,0 +1,129 @@
import { describe, expect, it, vi } from "vitest";
import type { FeatureCollection } from "geojson";
import { presentationCommandSchema } from "@/features/agent";
import {
executePresentationCommand,
type PresentationPorts
} from "./presentation-command-executor";
import { parseInvestigationDevJsonl } from "../utils/investigation-dev-preview";
import { investigationPresentationEventSchema } from "../api/investigation-presentation";
const chart = {
title: "压力",
chart_type: "line",
x_data: ["10:00"],
series: [{ name: "D1", data: [0.4] }]
};
const data: FeatureCollection = {
type: "FeatureCollection",
features: [
{
type: "Feature",
properties: { id: "D1" },
geometry: { type: "Point", coordinates: [121, 31] }
}
]
};
const commands = [
{ action: "locate_features", layer: "scada", feature_ids: ["D1", "missing"] },
{ action: "zoom_to_map", center: [121, 31], zoom: 14 },
{ action: "highlight_features", layer: "scada", feature_ids: ["D1", "missing"], effect: "pulse" },
{ action: "clear_highlights" },
{ action: "open_chart", chart_id: "pressure", chart, evidence_refs: ["E1"] },
{ action: "close_chart", chart_id: "pressure" },
{
action: "set_overlay",
overlay_id: "points",
title: "证据",
mode: "geometry",
evidence_id: "E1",
data
},
{ action: "clear_overlay", overlay_id: "points" },
{ action: "render_scada_analysis", items: [{ device_id: "D1", level: "high" }] },
{ action: "clear_scada_analysis" }
];
function ports(): PresentationPorts {
return {
controller: { queryPresentationFeatures: vi.fn(async () => data) },
scene: {
highlight: vi.fn(),
clearHighlights: vi.fn(),
setOverlay: vi.fn(),
clearOverlay: vi.fn(),
renderScadaAnalysis: vi.fn(() => ({
rendered_ids: ["D1"],
missing_ids: [],
fitted: true as const,
level_counts: { high: 1, medium: 0, low: 0, unrated: 0 }
})),
clearScadaAnalysis: vi.fn(() => ({ cleared: true as const }))
},
cameraAllowed: () => false,
zoom: vi.fn(async () => {}),
openChart: vi.fn(async () => {}),
closeChart: vi.fn(async () => {}),
overlayChanged: vi.fn(),
highlightsChanged: vi.fn()
};
}
describe("shared presentation execution", () => {
it.each(commands)(
"accepts the same $action payload through chat, Dev and task events",
async (input) => {
const chat = presentationCommandSchema.parse(input);
const preview = parseInvestigationDevJsonl(
JSON.stringify({ record_type: "presentation", command: input })
).commands[0];
const task = investigationPresentationEventSchema.parse({
task_id: "71000000-0000-4000-8000-000000000001",
run_id: "71000000-0000-4000-8000-000000000002",
execution_generation: 1,
command: input
}).command;
const results = await Promise.all(
[chat, preview, task].map((command) =>
executePresentationCommand(command, new AbortController().signal, ports())
)
);
expect(results[1]).toEqual(results[0]);
expect(results[2]).toEqual(results[0]);
}
);
it("reports actual matching IDs and respects paused automatic camera following", async () => {
const target = ports();
expect(
await executePresentationCommand(
presentationCommandSchema.parse(commands[2]),
new AbortController().signal,
target
)
).toEqual({ highlighted_ids: ["D1"], missing_ids: ["missing"], fitted: false });
expect(target.scene.highlight).toHaveBeenCalledWith(data, true, false);
});
it("rejects empty targets and cancelled actions before any scene mutation", async () => {
const target = ports();
vi.mocked(target.controller.queryPresentationFeatures).mockResolvedValueOnce({
type: "FeatureCollection",
features: []
});
await expect(
executePresentationCommand(
presentationCommandSchema.parse(commands[0]),
new AbortController().signal,
target
)
).rejects.toThrow("FEATURE_NOT_FOUND");
const controller = new AbortController();
controller.abort();
await expect(
executePresentationCommand(
presentationCommandSchema.parse(commands[3]),
controller.signal,
target
)
).rejects.toThrow();
expect(target.scene.highlight).not.toHaveBeenCalled();
expect(target.scene.clearHighlights).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,97 @@
import type { FeatureCollection } from "geojson";
import type { PresentationCommand } from "@/features/agent";
import type { WorkbenchMapController } from "./workbench-map-controller";
import type { InvestigationMapPresentation } from "./investigation-map-presentation";
type Chart = Extract<PresentationCommand, { action: "open_chart" }>;
type Overlay = Extract<PresentationCommand, { action: "set_overlay" }>;
export type PresentationPorts = {
controller: Pick<WorkbenchMapController, "queryPresentationFeatures">;
scene: Pick<
InvestigationMapPresentation,
| "highlight"
| "clearHighlights"
| "setOverlay"
| "clearOverlay"
| "renderScadaAnalysis"
| "clearScadaAnalysis"
>;
cameraAllowed: () => boolean;
zoom: (center: [number, number], zoom: number, signal: AbortSignal) => Promise<void>;
openChart: (chart: Chart, signal: AbortSignal) => Promise<void>;
closeChart: (id: string, signal: AbortSignal) => Promise<void>;
overlayChanged: (overlay: Overlay | null, id?: string) => void;
highlightsChanged: (layers: string[]) => void;
};
/** One dispatcher for chat, task following and explicit Dev previews. Ownership lives in its ports. */
export async function executePresentationCommand(
command: PresentationCommand,
signal: AbortSignal,
ports: PresentationPorts
): Promise<Record<string, unknown>> {
signal.throwIfAborted();
switch (command.action) {
case "locate_features":
case "highlight_features": {
const data: FeatureCollection = await ports.controller.queryPresentationFeatures(
command.layer,
command.feature_ids,
signal
);
signal.throwIfAborted();
const found = new Set(
data.features.map((feature) =>
String(feature.properties?.id ?? feature.properties?.device_id ?? feature.id)
)
);
const matched = [...new Set(command.feature_ids)].filter((id) => found.has(id));
if (!matched.length) throw new Error("FEATURE_NOT_FOUND");
const fitted = command.action === "locate_features" || ports.cameraAllowed();
ports.scene.highlight(
data,
command.action === "highlight_features" && command.effect === "pulse",
fitted
);
ports.highlightsChanged([command.layer]);
const missing_ids = [...new Set(command.feature_ids)].filter((id) => !found.has(id));
return command.action === "locate_features"
? { located_ids: matched, missing_ids, fitted: true }
: { highlighted_ids: matched, missing_ids, fitted };
}
case "zoom_to_map":
await ports.zoom(command.center, command.zoom, signal);
return { fitted: true };
case "clear_highlights":
ports.scene.clearHighlights();
ports.highlightsChanged([]);
return { cleared: true };
case "open_chart":
await ports.openChart(command, signal);
signal.throwIfAborted();
return { displayed: true, series_count: command.chart.series.length };
case "close_chart":
await ports.closeChart(command.chart_id, signal);
signal.throwIfAborted();
return { closed: true };
case "set_overlay":
ports.scene.setOverlay({ ...command, visible: true }, ports.cameraAllowed());
ports.overlayChanged(command);
return { displayed: true, feature_count: command.data.features.length };
case "clear_overlay":
ports.scene.clearOverlay(command.overlay_id);
ports.overlayChanged(null, command.overlay_id);
return { cleared: true };
case "render_scada_analysis": {
const data = await ports.controller.queryPresentationFeatures(
"scada",
command.items.map((item) => item.device_id),
signal
);
signal.throwIfAborted();
return ports.scene.renderScadaAnalysis(data, command.items);
}
case "clear_scada_analysis":
return ports.scene.clearScadaAnalysis();
}
}
@@ -0,0 +1,53 @@
import { describe, expect, it } from "vitest";
import { convertFilter, featureFilter } from "@maplibre/maplibre-gl-style-spec";
import type { LayerSpecification } from "maplibre-gl";
import { createPresentationHighlightLayers, PRESENTATION_FEATURE_SOURCE } from "./presentation-highlight-style";
import { waterNetworkLayers } from "./layers";
import { scadaInteractionLayers } from "./scada";
import { WATER_NETWORK_SOURCE_IDS } from "./sources";
const native = [...waterNetworkLayers, ...scadaInteractionLayers];
describe("presentation selection style", () => {
it.each(WATER_NETWORK_SOURCE_IDS)("uses map-debug selection paint and zoom rules for %s", (source) => {
const selected = native.filter((layer) => "source" in layer && layer.source === source && /-selected(?:-|$)/.test(layer.id));
expect(selected.length).toBeGreaterThan(0);
const original = structuredClone(selected);
const actual = createPresentationHighlightLayers(source, "chat-owned", native);
expect(actual).toHaveLength(selected.length);
actual.forEach((layer, index) => {
const reference = selected[index];
if ((layer.type !== "line" && layer.type !== "circle") || (reference.type !== "line" && reference.type !== "circle")) throw new Error("missing selection fixture");
expect(layer).toMatchObject({ id: `chat-owned-${reference.id}`, source: "chat-owned", minzoom: reference.minzoom });
expect(layer).not.toHaveProperty("source-layer");
expect(layer.filter).toEqual(reference.filter !== undefined
? ["all", ["==", ["get", PRESENTATION_FEATURE_SOURCE], source], convertFilter(reference.filter)]
: ["==", ["get", PRESENTATION_FEATURE_SOURCE], source]);
if ((layer.type === "line" || layer.type === "circle") && (reference.type === "line" || reference.type === "circle")) {
for (const [key, value] of Object.entries(reference.paint ?? {})) {
expect((layer.paint as Record<string, unknown>)[key]).toEqual(key.endsWith("opacity") ? 1 : value);
}
}
});
expect(selected).toEqual(original);
});
it("preserves legacy geometry filters when combining source ownership", () => {
const native: LayerSpecification[] = [{ id: "pipes-selected", source: "pipes", type: "line", filter: ["==", "$type", "LineString"], paint: { "line-width": 5 } }];
const layer = createPresentationHighlightLayers("pipes", "chat-owned", native)[0];
if (layer.type !== "line") throw new Error("missing line fixture");
const filter = featureFilter(layer.filter, "test").filter;
expect(filter({ zoom: 14 }, { type: 2, properties: { [PRESENTATION_FEATURE_SOURCE]: "pipes" } })).toBe(true);
expect(filter({ zoom: 14 }, { type: 1, properties: { [PRESENTATION_FEATURE_SOURCE]: "pipes" } })).toBe(false);
expect(filter({ zoom: 14 }, { type: 2, properties: { [PRESENTATION_FEATURE_SOURCE]: "junctions" } })).toBe(false);
});
it("uses current map overrides and keeps source scopes separate", () => {
const selected = scadaInteractionLayers.find((layer) => layer.type === "circle" && layer.id === "scada-pressure-selected")!;
if (selected.type !== "circle") throw new Error("missing circle fixture");
const current = { ...selected, paint: { ...selected.paint, "circle-radius": 20 } };
const chat = createPresentationHighlightLayers("scada", "chat-owned", [current]);
const preview = createPresentationHighlightLayers("scada", "preview-owned", [current]);
expect(chat[0]).toMatchObject({ source: "chat-owned", paint: { "circle-radius": 20 } });
expect(preview[0]).toMatchObject({ source: "preview-owned", paint: { "circle-radius": 20 } });
expect(chat[0].id).not.toBe(preview[0].id);
expect(selected.paint?.["circle-radius"]).not.toBe(20);
});
});
@@ -0,0 +1,38 @@
import type { FilterSpecification, LayerSpecification } from "maplibre-gl";
import { convertFilter } from "@maplibre/maplibre-gl-style-spec";
import { waterNetworkLayers } from "./layers";
import { scadaInteractionLayers } from "./scada";
import { WATER_NETWORK_SOURCE_IDS, type WaterNetworkSourceId } from "./sources";
export const PRESENTATION_FEATURE_SOURCE = "__tjwater_presentation_source";
const sourceIds = new Set<string>(WATER_NETWORK_SOURCE_IDS);
const defaults = [...waterNetworkLayers, ...scadaInteractionLayers];
export function presentationFeatureSource(value: unknown): WaterNetworkSourceId | null {
return typeof value === "string" && sourceIds.has(value) ? value as WaterNetworkSourceId : null;
}
/** Reuse map-debug selection paint, filters and zoom rules in an owned GeoJSON source. */
export function createPresentationHighlightLayers(
sourceId: WaterNetworkSourceId,
overlaySource: string,
nativeLayers: readonly LayerSpecification[] = defaults
): LayerSpecification[] {
return nativeLayers.filter((layer) => "source" in layer && layer.source === sourceId &&
/-selected(?:-|$)/.test(layer.id) && (layer.type === "line" || layer.type === "circle")
).map((original) => {
const layer = structuredClone(original);
if (layer.type !== "line" && layer.type !== "circle") throw new Error("INVALID_SELECTION_LAYER");
const identity: FilterSpecification = ["==", ["get", PRESENTATION_FEATURE_SOURCE], sourceId];
// GeoJSON has no vector source-layer, and ownership replaces feature-state
// gating. Keep the native device/geometry filters and visual expressions.
delete layer["source-layer"];
layer.source = overlaySource;
layer.id = `${overlaySource}-${original.id}`;
layer.filter = layer.filter !== undefined ? ["all", identity, convertFilter(layer.filter)] : identity;
layer.layout = { ...layer.layout, visibility: "none" };
if (layer.type === "line") layer.paint = { ...layer.paint, "line-opacity": 1 };
else layer.paint = { ...layer.paint, "circle-opacity": 1, "circle-stroke-opacity": 1 };
return layer;
});
}
@@ -0,0 +1,115 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { Map as MapLibreMap } from "maplibre-gl";
import type { InvestigationMapPresentation } from "./investigation-map-presentation";
import { executePresentationCommand } from "./presentation-command-executor";
import { presentationHistoryPorts, renderPresentationSnapshot } from "./presentation-history-ports";
import { PresentationViewHistory } from "../utils/presentation-view-history";
import type { FeatureCollection } from "geojson";
const data: FeatureCollection = {
type: "FeatureCollection",
features: [
{
type: "Feature",
properties: { device_id: "P1" },
geometry: { type: "Point", coordinates: [121.48, 31.1] }
}
]
};
afterEach(() => vi.unstubAllGlobals());
describe("presentation snapshots", () => {
it("captures returned geometry and camera, receives new actions offscreen and restores history without querying again", async () => {
vi.stubGlobal("matchMedia", () => ({ matches: false }));
const history = new PresentationViewHistory();
const map = {
getCenter: () => ({ toArray: () => [120, 30] }),
getZoom: () => 14,
getPitch: () => 0,
getBearing: () => 0,
cameraForBounds: vi.fn(() => ({ center: [121.48, 31.1], zoom: 18, bearing: 0 })),
stop: vi.fn(),
easeTo: vi.fn()
};
const scene = {
clear: vi.fn(),
highlight: vi.fn(),
setOverlay: vi.fn(),
renderScadaAnalysis: vi.fn(),
clearHighlights: vi.fn(),
clearOverlay: vi.fn(),
clearScadaAnalysis: vi.fn()
};
const controller = { queryPresentationFeatures: vi.fn(async () => data) };
const ports = presentationHistoryPorts({
controller,
scene: scene as unknown as InvestigationMapPresentation,
map: () => map as unknown as MapLibreMap,
padding: () => ({ top: 24, right: 24, bottom: 24, left: 24 }),
update: (updater) => history.update(updater(history.current)),
rendering: () => history.selectedId === null,
cameraAllowed: () => true,
frame: async () => undefined,
waitForChart: vi.fn(async () => undefined)
});
const command = { action: "locate_features", layer: "scada", feature_ids: ["P1"] } as const;
await executePresentationCommand(
{ ...command, feature_ids: [...command.feature_ids] },
new AbortController().signal,
ports
);
const id = history.record("定位", "live", null);
history.select(id);
// Changing the source data later cannot rewrite the captured geometry.
data.features[0].properties = { device_id: "P2" };
await executePresentationCommand(
{
action: "open_chart",
chart_id: "later",
evidence_refs: [],
chart: {
title: "最新",
chart_type: "line",
x_data: ["08:00"],
series: [{ name: "压力", data: [32] }]
}
},
new AbortController().signal,
ports
);
await executePresentationCommand(
{ action: "clear_highlights" },
new AbortController().signal,
ports
);
expect(history.visible.charts).toHaveLength(0);
expect(history.current.charts).toHaveLength(1);
expect(history.current.highlights.features).toHaveLength(0);
expect(scene.clearHighlights).not.toHaveBeenCalled();
expect(scene.highlight).toHaveBeenCalledTimes(1);
renderPresentationSnapshot(
scene as unknown as InvestigationMapPresentation,
map as unknown as MapLibreMap,
history.visible
);
expect(scene.highlight).toHaveBeenLastCalledWith(
expect.objectContaining({
features: [expect.objectContaining({ properties: { device_id: "P1" } })]
}),
false,
false
);
expect(map.easeTo).toHaveBeenLastCalledWith(
expect.objectContaining({ center: [121.48, 31.1], zoom: 18, duration: 450 })
);
expect(controller.queryPresentationFeatures).toHaveBeenCalledTimes(1);
history.select(null);
renderPresentationSnapshot(
scene as unknown as InvestigationMapPresentation,
map as unknown as MapLibreMap,
history.visible
);
expect(scene.highlight).toHaveBeenCalledTimes(2);
expect(controller.queryPresentationFeatures).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,189 @@
import type { FeatureCollection } from "geojson";
import { LngLat, type Map as MapLibreMap, type PaddingOptions } from "maplibre-gl";
import type { PresentationPorts } from "./presentation-command-executor";
import {
investigationGeometryCoordinates,
type InvestigationMapPresentation
} from "./investigation-map-presentation";
import { createScadaAnalysisCollection } from "./scada-analysis";
import type { WorkbenchMapController } from "./workbench-map-controller";
import type { PresentationCamera, PresentationView } from "../utils/presentation-view-history";
export function capturePresentationCamera(map: MapLibreMap | null): PresentationCamera | null {
return map
? {
center: map.getCenter().toArray() as [number, number],
zoom: map.getZoom(),
bearing: map.getBearing(),
pitch: map.getPitch()
}
: null;
}
export function fitPresentationCamera(
map: MapLibreMap | null,
data: FeatureCollection,
padding: PaddingOptions
): PresentationCamera | null {
if (!map) return null;
const positions = data.features.flatMap((feature) =>
feature.geometry ? investigationGeometryCoordinates(feature.geometry) : []
);
if (!positions.length) return capturePresentationCamera(map);
const bounds = positions.reduce(
(value, point) => [
Math.min(value[0], point[0]),
Math.min(value[1], point[1]),
Math.max(value[2], point[0]),
Math.max(value[3], point[1])
],
[Infinity, Infinity, -Infinity, -Infinity]
);
const camera = map.cameraForBounds(
[
[bounds[0], bounds[1]],
[bounds[2], bounds[3]]
],
{ padding, maxZoom: 18 }
);
return camera
? {
center: camera.center
? (LngLat.convert(camera.center).toArray() as [number, number])
: map.getCenter().toArray(),
zoom: camera.zoom ?? map.getZoom(),
bearing: camera.bearing ?? map.getBearing(),
pitch: map.getPitch()
}
: capturePresentationCamera(map);
}
type Options = {
controller: Pick<WorkbenchMapController, "queryPresentationFeatures">;
scene: InvestigationMapPresentation;
map: () => MapLibreMap | null;
padding: () => PaddingOptions;
update: (updater: (view: PresentationView) => PresentationView) => void;
rendering: () => boolean;
cameraAllowed: () => boolean;
waitForChart: (id: string, signal: AbortSignal) => Promise<void>;
frame: (signal: AbortSignal) => Promise<void>;
};
/** Live commands keep updating current while the visible renderer is pinned to a past snapshot. */
export function presentationHistoryPorts(options: Options): PresentationPorts {
const { scene, update, rendering } = options;
return {
controller: options.controller,
cameraAllowed: options.cameraAllowed,
scene: {
highlight(data, pulse, fit) {
update((view) => ({
...view,
highlights: structuredClone(data),
camera: fit ? fitPresentationCamera(options.map(), data, options.padding()) : view.camera
}));
if (rendering()) scene.highlight(data, pulse, fit);
},
clearHighlights() {
update((view) => ({
...view,
highlights: { type: "FeatureCollection", features: [] },
highlightLayers: []
}));
if (rendering()) scene.clearHighlights();
},
setOverlay(overlay, fit) {
update((view) => ({
...view,
overlay: structuredClone(overlay),
camera: fit
? fitPresentationCamera(options.map(), overlay.data, options.padding())
: view.camera
}));
if (rendering()) scene.setOverlay(overlay, fit);
},
clearOverlay(id) {
update((view) => ({
...view,
overlay: !id || view.overlay?.overlay_id === id ? null : view.overlay
}));
if (rendering()) scene.clearOverlay(id);
},
renderScadaAnalysis(data, items) {
const next = createScadaAnalysisCollection(data, items);
if (!next.result.rendered_ids.length) throw new Error("SCADA_FEATURES_NOT_FOUND");
update((view) => ({
...view,
scada: { data: structuredClone(data), items: structuredClone(items) },
camera: fitPresentationCamera(options.map(), next.collection, options.padding())
}));
return rendering()
? scene.renderScadaAnalysis(data, items)
: { ...next.result, fitted: false };
},
clearScadaAnalysis() {
update((view) => ({ ...view, scada: null }));
return rendering() ? scene.clearScadaAnalysis() : { cleared: true as const };
}
},
highlightsChanged: (highlightLayers) => update((view) => ({ ...view, highlightLayers })),
overlayChanged: () => undefined,
zoom: async (center, zoom, signal) => {
const map = options.map();
if (!map) throw new Error("MAP_NOT_READY");
update((view) => ({
...view,
camera: {
center: [...center],
zoom,
bearing: view.camera?.bearing ?? map.getBearing(),
pitch: view.camera?.pitch ?? map.getPitch()
}
}));
if (rendering()) {
map.stop();
map.easeTo({
center,
zoom,
duration: window.matchMedia("(prefers-reduced-motion: reduce)").matches ? 0 : 450
});
}
await options.frame(signal);
},
openChart: async (chart, signal) => {
update((view) => ({
...view,
charts: [
...view.charts.filter((item) => item.chart_id !== chart.chart_id),
structuredClone(chart)
]
}));
if (rendering()) await options.waitForChart(chart.chart_id, signal);
else await options.frame(signal);
},
closeChart: async (id, signal) => {
update((view) => ({ ...view, charts: view.charts.filter((chart) => chart.chart_id !== id) }));
await options.frame(signal);
}
};
}
/** Restore captured geometry directly; history navigation never calls an original query or command. */
export function renderPresentationSnapshot(
scene: InvestigationMapPresentation,
map: MapLibreMap,
snapshot: PresentationView
) {
scene.clear();
if (snapshot.highlights.features.length) scene.highlight(snapshot.highlights, false, false);
if (snapshot.overlay) scene.setOverlay(snapshot.overlay, false);
if (snapshot.scada) scene.renderScadaAnalysis(snapshot.scada.data, snapshot.scada.items, false);
if (snapshot.camera) {
map.stop();
map.easeTo({
...snapshot.camera,
duration: window.matchMedia("(prefers-reduced-motion: reduce)").matches ? 0 : 450
});
}
}
@@ -7,8 +7,19 @@ import {
showFlowOverlay
} from "./flow-overlay";
import { WorkbenchMapController } from "./workbench-map-controller";
import { PRESENTATION_FEATURE_SOURCE } from "./presentation-highlight-style";
describe("WorkbenchMapController", () => {
it("tags presentation geometry with its verified source without changing manual state or cached features", async () => {
const map = createMap();
const controller = createController(map, pointCollection);
const data = await controller.queryPresentationFeatures("junctions", ["J-1"], new AbortController().signal);
expect(data.features[0].properties).toEqual({ id: "J-1", [PRESENTATION_FEATURE_SOURCE]: "junctions" });
expect(pointCollection.features[0].properties).toEqual({ id: "J-1" });
expect(map.setFeatureState).not.toHaveBeenCalled();
expect(map.easeTo).not.toHaveBeenCalled();
expect(controller.getSnapshot().targets).toEqual([]);
});
it("clears every controller overlay, preserves layer styles, and invalidates pending analysis", async () => {
const map = createMap();
map.getLayer.mockReturnValue({} as never);
@@ -1,6 +1,7 @@
import type { Feature, FeatureCollection, Geometry, Position } from "geojson";
import type { GeoJSONSource, Map as MapLibreMap, PaddingOptions } from "maplibre-gl";
import { SOURCE_LAYERS, type WaterNetworkSourceId } from "./sources";
import { PRESENTATION_FEATURE_SOURCE } from "./presentation-highlight-style";
import {
LAYER_GROUP_GEOMETRIES,
applyLayerGroupStyle as applyStyle,
@@ -152,14 +153,16 @@ export class WorkbenchMapController implements WorkbenchMapCommands {
}
/** Resolve a task-owned overlay without changing the user's selection or controller highlight. */
async queryPresentationFeatures(sourceId: WaterNetworkSourceId, featureIds: string[], signal: AbortSignal) {
async queryPresentationFeatures(sourceId: WaterNetworkSourceId, featureIds: string[], signal: AbortSignal): Promise<FeatureCollection> {
const map = this.options.getMap();
if (!map || !this.options.isReady()) throw new Error("MAP_NOT_READY");
if (!map.getSource(sourceId) || this.availableSourceIds && !this.availableSourceIds.has(sourceId)) throw new Error("FEATURE_NOT_FOUND");
const ids = new Set(featureIds);
const collection = await this.fetchFeatures(sourceId, [...ids], signal);
signal.throwIfAborted();
return { ...collection, features: collection.features.filter((feature) => ids.has(String(feature.properties?.id ?? feature.properties?.device_id ?? feature.id))) };
return { ...collection, features: collection.features
.filter((feature) => ids.has(String(feature.properties?.id ?? feature.properties?.device_id ?? feature.id)))
.map((feature) => ({ ...feature, properties: { ...feature.properties, [PRESENTATION_FEATURE_SOURCE]: sourceId } })) };
}
private moveCameraToFeature(map: MapLibreMap, feature: Feature, durationOverride?: number) {