feat: expand intelligent investigation workspace
This commit is contained in:
@@ -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,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) {
|
||||
|
||||
Reference in New Issue
Block a user