feat: extend agent-driven map workbench

This commit is contained in:
2026-07-20 19:57:35 +08:00
parent 86e9b08235
commit 7fbd8a5618
63 changed files with 4506 additions and 457 deletions
+6 -6
View File
@@ -50,7 +50,7 @@ describe("workbench camera padding", () => {
});
describe("fitNetworkBounds", () => {
it("preserves the original global extent for initial and home views", () => {
it("uses the configured global extent for initial and home views", () => {
const { fitBounds, map } = createMap();
fitNetworkBounds(map, WATER_NETWORK_GLOBAL_VIEW);
@@ -58,13 +58,13 @@ describe("fitNetworkBounds", () => {
expect(fitBounds).toHaveBeenCalledTimes(1);
const [bounds, options] = fitBounds.mock.calls[0] ?? [];
expect(bounds[0][0]).toBeCloseTo(121.735034, 6);
expect(bounds[0][1]).toBeCloseTo(30.846365, 6);
expect(bounds[1][0]).toBeCloseTo(121.970518, 6);
expect(bounds[1][1]).toBeCloseTo(30.994738, 6);
expect(bounds[0][0]).toBeCloseTo(120.634831, 6);
expect(bounds[0][1]).toBeCloseTo(27.957404, 6);
expect(bounds[1][0]).toBeCloseTo(120.763461, 6);
expect(bounds[1][1]).toBeCloseTo(28.023994, 6);
expect(options).toMatchObject({
duration: 600,
maxZoom: 12,
maxZoom: 13,
padding: { top: 48, right: 48, bottom: 48, left: 48 }
});
expect(options).not.toHaveProperty("zoom");
+1 -1
View File
@@ -12,7 +12,7 @@ export type NetworkViewParams = {
};
const WEB_MERCATOR_RADIUS = 6378137;
const GLOBAL_VIEW_MAX_ZOOM = 12;
const GLOBAL_VIEW_MAX_ZOOM = 13;
const GLOBAL_VIEW_PADDING: PaddingOptions = { top: 48, right: 48, bottom: 48, left: 48 };
export function getWorkbenchPadding(leftPanelOpen: boolean, rightPanelOpen: boolean): PaddingOptions {
return getWorkbenchCameraPadding(1280, {
@@ -61,24 +61,22 @@ describe("drainage feature adapter", () => {
);
});
it("uses the promoted SCADA id for feature-state interactions", () => {
it("uses the promoted SCADA sensor id for feature-state interactions", () => {
const detail = toDetailFeature({
id: "4bfa834b-cbbb-5f35-9523-7487ac021ed2",
id: "SCADA-001",
sourceLayer: SOURCE_LAYERS.scada,
properties: {
scada_id: "4bfa834b-cbbb-5f35-9523-7487ac021ed2",
site_external_id: "36455",
name: "DY22东市南街环城南路西段交叉口",
external_id: "26825171",
kind: "water_quality"
sensor_id: "SCADA-001",
sensor_name: "DY22东市南街环城南路西段交叉口",
swmm_node: "J-1001"
}
} as unknown as MapGeoJSONFeature);
expect(detail).toMatchObject({
id: "4bfa834b-cbbb-5f35-9523-7487ac021ed2",
id: "SCADA-001",
layer: "scada",
title: "DY22东市南街环城南路西段交叉口",
subtitle: "water_quality · 设备 26825171"
subtitle: "综合监测点 · SWMM 节点 J-1001"
});
});
});
+3 -5
View File
@@ -3,12 +3,10 @@ import type { DetailFeature } from "../types";
import { formatValue } from "../utils/format-value";
import { SOURCE_LAYERS, type WaterNetworkSourceId } from "./sources";
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
export function getFeatureId(feature: MapGeoJSONFeature) {
const layer = getWaterNetworkSourceId(feature);
const raw = layer === "scada"
? feature.properties?.scada_id ?? (typeof feature.id === "string" && UUID_PATTERN.test(feature.id) ? feature.id : undefined)
? feature.properties?.sensor_id ?? feature.id
: feature.id ?? feature.properties?.id;
return raw === undefined || raw === null ? "" : String(raw);
@@ -72,8 +70,8 @@ function getFeaturePresentation(
};
case "scada":
return {
title: String(properties.name || `SCADA 测点 ${displayId}`),
subtitle: `${formatValue(properties.kind)} · 设备 ${formatValue(properties.external_id)}`
title: String(properties.sensor_name || `综合监测点 ${displayId}`),
subtitle: `综合监测点 · SWMM 节点 ${formatValue(properties.swmm_node)}`
};
}
}
@@ -0,0 +1,21 @@
import { describe, expect, it, vi } from "vitest";
import { createFlowOverlayState, stopFlowAnimation } from "./flow-overlay";
describe("flow overlay lifecycle", () => {
it("starts empty so conduit data can be loaded once per map session", () => {
expect(createFlowOverlayState()).toEqual({ data: null, animationFrameId: null, overlay: null });
});
it("cancels an active animation frame without clearing cached data", () => {
const cancelAnimationFrame = vi.fn();
vi.stubGlobal("window", { cancelAnimationFrame });
const state = createFlowOverlayState();
state.data = [];
state.animationFrameId = 42;
stopFlowAnimation(state);
expect(cancelAnimationFrame).toHaveBeenCalledWith(42);
expect(state.animationFrameId).toBeNull();
expect(state.data).toBeTruthy();
vi.unstubAllGlobals();
});
});
+99
View File
@@ -0,0 +1,99 @@
import type { Feature, FeatureCollection, LineString, MultiLineString } from "geojson";
import type { Map as MapLibreMap } from "maplibre-gl";
import type { MapboxOverlay } from "@deck.gl/mapbox";
import { WORKBENCH_INTERACTION_BEFORE_ID } from "./layers";
type FlowFeature = Feature<LineString | MultiLineString, { diameter?: number | string; [key: string]: unknown }>;
export type FlowOverlayState = {
data: FlowFeature[] | null;
animationFrameId: number | null;
overlay: MapboxOverlay | null;
};
export function createFlowOverlayState(): FlowOverlayState {
return { data: null, animationFrameId: null, overlay: null };
}
export async function showFlowOverlay(
map: MapLibreMap,
state: FlowOverlayState,
loadData: () => Promise<FeatureCollection>,
reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches
) {
if (!state.data) {
const collection = await loadData();
state.data = collection.features.filter(isFlowFeature);
}
const [{ MapboxOverlay }, { PathLayer }, { FlowPathExtension }] = await Promise.all([
import("@deck.gl/mapbox"),
import("@deck.gl/layers"),
import("./flow-path-extension")
]);
const extension = new FlowPathExtension();
let renderingError: Error | null = null;
const render = (phase: number) => {
const layer = new PathLayer<FlowFeature>({
id: "workbench-network-flow",
beforeId: WORKBENCH_INTERACTION_BEFORE_ID,
data: state.data ?? [],
getPath: (feature: FlowFeature) => feature.geometry.type === "LineString" ? feature.geometry.coordinates : feature.geometry.coordinates.flat(),
getColor: [15, 118, 110, 210],
getWidth: (feature: FlowFeature) => Math.min(5, Math.max(1, Number(feature.properties?.diameter ?? 200) / 180)),
widthUnits: "pixels",
widthMinPixels: 1,
widthMaxPixels: 5,
capRounded: true,
jointRounded: true,
pickable: false,
parameters: { depthTest: false },
extensions: [extension],
flowPhase: phase
} as never);
state.overlay?.setProps({
layers: [layer],
onError: (error) => {
renderingError = error;
}
});
};
if (!state.overlay) {
const overlay = new MapboxOverlay({ interleaved: true, layers: [] });
map.addControl(overlay);
state.overlay = overlay;
}
stopFlowAnimation(state);
render(0.2);
await new Promise<void>((resolve) => window.requestAnimationFrame(() => resolve()));
if (renderingError) throw renderingError;
if (!reducedMotion) {
const startedAt = performance.now();
const tick = (now: number) => {
render(((now - startedAt) / 1800) % 1);
state.animationFrameId = window.requestAnimationFrame(tick);
};
state.animationFrameId = window.requestAnimationFrame(tick);
}
}
export function hideFlowOverlay(map: MapLibreMap, state: FlowOverlayState) {
stopFlowAnimation(state);
if (state.overlay) {
map.removeControl(state.overlay as never);
state.overlay = null;
}
}
export function stopFlowAnimation(state: FlowOverlayState) {
if (state.animationFrameId !== null) {
window.cancelAnimationFrame(state.animationFrameId);
state.animationFrameId = null;
}
}
function isFlowFeature(feature: Feature): feature is FlowFeature {
return feature.geometry?.type === "LineString" || feature.geometry?.type === "MultiLineString";
}
@@ -0,0 +1,34 @@
import { LayerExtension, type Layer } from "@deck.gl/core";
const flowPathUniforms = {
name: "flowPath",
fs: `layout(std140) uniform flowPathUniforms { float phase; } flowPath;`,
uniformTypes: { phase: "f32" }
} as const;
export class FlowPathExtension extends LayerExtension {
static extensionName = "FlowPathExtension";
static defaultProps = { flowPhase: { type: "number", value: 0 } };
getShaders() {
return {
modules: [flowPathUniforms],
inject: {
"fs:DECKGL_FILTER_COLOR": `
float flowStripe = fract((geometry.uv.y * 0.16) - flowPath.phase);
float flowPulse = smoothstep(0.0, 0.18, flowStripe) * (1.0 - smoothstep(0.55, 0.82, flowStripe));
color.a *= 0.22 + flowPulse * 0.78;
`
}
};
}
draw(this: Layer) {
const state = this.state as unknown as {
model?: { shaderInputs?: { setProps: (props: { flowPath: { phase: number } }) => void } };
};
state.model?.shaderInputs?.setProps({
flowPath: { phase: Number((this.props as Record<string, unknown>).flowPhase ?? 0) }
});
}
}
@@ -0,0 +1,18 @@
import { describe, expect, it } from "vitest";
import { validateLayerGroupStylePatch } from "./layer-group-style";
describe("layer group style validation", () => {
it("accepts geometry-aware style patches", () => {
expect(validateLayerGroupStylePatch("conduits", { color: "#0F766E", opacity: 0.5, widthMultiplier: 3, dash: "solid" })).toBe(true);
expect(validateLayerGroupStylePatch("junctions", { fillColor: "#0369A1", strokeColor: "#FFFFFF", radiusMultiplier: 0.5 })).toBe(true);
expect(validateLayerGroupStylePatch("scada", { iconMultiplier: 2, opacity: 1 })).toBe(true);
});
it("rejects cross-geometry fields and out-of-range values", () => {
expect(validateLayerGroupStylePatch("conduits", { radiusMultiplier: 1 })).toBe(false);
expect(validateLayerGroupStylePatch("junctions", { widthMultiplier: 1 })).toBe(false);
expect(validateLayerGroupStylePatch("scada", { iconMultiplier: 2.1 })).toBe(false);
expect(validateLayerGroupStylePatch("conduits", { color: "red" })).toBe(false);
expect(validateLayerGroupStylePatch("unknown", {})).toBe(false);
});
});
+212
View File
@@ -0,0 +1,212 @@
import type { Map as MapLibreMap } from "maplibre-gl";
import { DRAINAGE_LAYER_VISUALS } from "./map-layer-visuals";
import { MAP_STYLE_TOKENS } from "./map-colors";
export type LineLayerGroupStylePatch = {
color?: string;
opacity?: number;
widthMultiplier?: number;
dash?: "original" | "solid" | "dashed";
};
export type PointLayerGroupStylePatch = {
fillColor?: string;
strokeColor?: string;
opacity?: number;
radiusMultiplier?: number;
strokeMultiplier?: number;
};
export type ScadaLayerGroupStylePatch = PointLayerGroupStylePatch & {
iconMultiplier?: number;
};
export type LayerGroupStylePatch = LineLayerGroupStylePatch | PointLayerGroupStylePatch | ScadaLayerGroupStylePatch;
export type LayerGroupGeometry = "line" | "point" | "scada";
export const LAYER_GROUP_GEOMETRIES = {
conduits: "line",
junctions: "point",
orifices: "line",
outfalls: "point",
pumps: "line",
scada: "scada"
} as const satisfies Record<string, LayerGroupGeometry>;
export type LayerGroupId = keyof typeof LAYER_GROUP_GEOMETRIES;
const ORIGINAL_DASH: Partial<Record<LayerGroupId, number[] | undefined>> = {
conduits: undefined,
orifices: [4, 2],
pumps: [6, 2, 1.5, 2]
};
const LINE_PREFIX: Record<"conduits" | "orifices" | "pumps", string> = {
conduits: "pipes",
orifices: "orifices",
pumps: "pumps"
};
export function validateLayerGroupStylePatch(groupId: string, patch: unknown): patch is LayerGroupStylePatch {
if (!(groupId in LAYER_GROUP_GEOMETRIES) || !patch || typeof patch !== "object" || Array.isArray(patch)) return false;
const geometry = LAYER_GROUP_GEOMETRIES[groupId as LayerGroupId];
const values = patch as Record<string, unknown>;
const allowed = geometry === "line"
? ["color", "opacity", "widthMultiplier", "dash"]
: geometry === "scada"
? ["fillColor", "strokeColor", "opacity", "radiusMultiplier", "strokeMultiplier", "iconMultiplier"]
: ["fillColor", "strokeColor", "opacity", "radiusMultiplier", "strokeMultiplier"];
if (Object.keys(values).some((key) => !allowed.includes(key))) return false;
if ("color" in values && !isColor(values.color)) return false;
if ("fillColor" in values && !isColor(values.fillColor)) return false;
if ("strokeColor" in values && !isColor(values.strokeColor)) return false;
if ("opacity" in values && !inRange(values.opacity, 0, 1)) return false;
if ("widthMultiplier" in values && !inRange(values.widthMultiplier, 0.5, 3)) return false;
if ("radiusMultiplier" in values && !inRange(values.radiusMultiplier, 0.5, 3)) return false;
if ("strokeMultiplier" in values && !inRange(values.strokeMultiplier, 0.5, 3)) return false;
if ("iconMultiplier" in values && !inRange(values.iconMultiplier, 0.5, 2)) return false;
if ("dash" in values && !["original", "solid", "dashed"].includes(String(values.dash))) return false;
return true;
}
export function applyLayerGroupStyle(map: MapLibreMap, groupId: LayerGroupId, patch: LayerGroupStylePatch) {
const geometry = LAYER_GROUP_GEOMETRIES[groupId];
if (geometry === "line") applyLineStyle(map, groupId as "conduits" | "orifices" | "pumps", patch as LineLayerGroupStylePatch);
else if (geometry === "scada") applyScadaStyle(map, patch as ScadaLayerGroupStylePatch);
else applyPointStyle(map, groupId as "junctions" | "outfalls", patch as PointLayerGroupStylePatch);
}
export function resetLayerGroupStyle(map: MapLibreMap, groupId: LayerGroupId) {
const geometry = LAYER_GROUP_GEOMETRIES[groupId];
if (geometry === "line") resetLineStyle(map, groupId as "conduits" | "orifices" | "pumps");
else if (geometry === "scada") resetScadaStyle(map);
else resetPointStyle(map, groupId as "junctions" | "outfalls");
}
function applyLineStyle(map: MapLibreMap, groupId: "conduits" | "orifices" | "pumps", patch: LineLayerGroupStylePatch) {
const prefix = LINE_PREFIX[groupId];
const coreId = groupId === "conduits" ? "pipes-flow" : groupId;
if (patch.color !== undefined) setPaint(map, coreId, "line-color", stateColor(patch.color));
if (patch.opacity !== undefined) setPaint(map, coreId, "line-opacity", patch.opacity);
if (patch.dash !== undefined) {
const dash = patch.dash === "solid" ? [1, 0] : patch.dash === "dashed" ? [3, 2] : ORIGINAL_DASH[groupId];
setPaint(map, coreId, "line-dasharray", dash);
}
if (patch.widthMultiplier !== undefined) {
const visuals = DRAINAGE_LAYER_VISUALS[groupId];
setPaint(map, coreId, "line-width", scale(visuals.width, patch.widthMultiplier));
setPaint(map, `${prefix}-casing`, "line-width", scale(visuals.casingWidth, patch.widthMultiplier));
setPaint(map, `${prefix}-hover-outline`, "line-width", scale(visuals.hoverOutlineWidth, patch.widthMultiplier));
setPaint(map, `${prefix}-hover`, "line-width", scale(visuals.hoverWidth, patch.widthMultiplier));
for (const id of [`${prefix}-selected-outer`, `${prefix}-selected-outline`, `${prefix}-selected-separator`]) {
setPaint(map, id, "line-gap-width", scale(visuals.selectedWidth, patch.widthMultiplier));
}
}
}
function resetLineStyle(map: MapLibreMap, groupId: "conduits" | "orifices" | "pumps") {
const prefix = LINE_PREFIX[groupId];
const coreId = groupId === "conduits" ? "pipes-flow" : groupId;
const visuals = DRAINAGE_LAYER_VISUALS[groupId];
const colors = {
conduits: MAP_STYLE_TOKENS.asset.conduit,
orifices: MAP_STYLE_TOKENS.asset.orifice,
pumps: MAP_STYLE_TOKENS.asset.pump
} as const;
setPaint(map, coreId, "line-color", stateColor(colors[groupId]));
setPaint(map, coreId, "line-opacity", MAP_STYLE_TOKENS.opacity.core);
setPaint(map, coreId, "line-dasharray", ORIGINAL_DASH[groupId]);
setPaint(map, coreId, "line-width", visuals.width);
setPaint(map, `${prefix}-casing`, "line-width", visuals.casingWidth);
setPaint(map, `${prefix}-hover-outline`, "line-width", visuals.hoverOutlineWidth);
setPaint(map, `${prefix}-hover`, "line-width", visuals.hoverWidth);
for (const id of [`${prefix}-selected-outer`, `${prefix}-selected-outline`, `${prefix}-selected-separator`]) {
setPaint(map, id, "line-gap-width", visuals.selectedWidth);
}
}
function applyPointStyle(map: MapLibreMap, groupId: "junctions" | "outfalls", patch: PointLayerGroupStylePatch) {
const coreId = groupId;
if (patch.fillColor !== undefined) setPaint(map, coreId, "circle-color", stateColor(patch.fillColor));
if (patch.strokeColor !== undefined) setPaint(map, coreId, "circle-stroke-color", stateColor(patch.strokeColor));
if (patch.opacity !== undefined) setPaint(map, coreId, "circle-opacity", patch.opacity);
if (patch.radiusMultiplier !== undefined) {
const visual = DRAINAGE_LAYER_VISUALS[groupId];
setPaint(map, coreId, "circle-radius", scale(visual.radius, patch.radiusMultiplier));
setPaint(map, `${groupId}-halo`, "circle-radius", scale(visual.haloRadius, patch.radiusMultiplier));
setPaint(map, `${groupId}-hover`, "circle-radius", scale(visual.hoverRadius, patch.radiusMultiplier));
setPaint(map, `${groupId}-selected`, "circle-radius", scale(visual.selectedRadius, patch.radiusMultiplier));
setPaint(map, `${groupId}-selected-outer`, "circle-radius", scale(visual.selectedRadius, patch.radiusMultiplier));
}
if (patch.strokeMultiplier !== undefined) {
setPaint(map, coreId, "circle-stroke-width", 2 * patch.strokeMultiplier);
}
}
function resetPointStyle(map: MapLibreMap, groupId: "junctions" | "outfalls") {
const visual = DRAINAGE_LAYER_VISUALS[groupId];
const colors = {
junctions: MAP_STYLE_TOKENS.asset.junction,
outfalls: MAP_STYLE_TOKENS.asset.outfall
} as const;
setPaint(map, groupId, "circle-color", groupId === "outfalls" ? MAP_STYLE_TOKENS.canvas.casing : stateColor(colors[groupId]));
setPaint(map, groupId, "circle-stroke-color", groupId === "outfalls" ? stateColor(colors[groupId]) : undefined);
setPaint(map, groupId, "circle-opacity", groupId === "outfalls" ? 1 : MAP_STYLE_TOKENS.opacity.core);
setPaint(map, groupId, "circle-radius", visual.radius);
setPaint(map, `${groupId}-halo`, "circle-radius", visual.haloRadius);
setPaint(map, `${groupId}-hover`, "circle-radius", visual.hoverRadius);
setPaint(map, `${groupId}-selected`, "circle-radius", visual.selectedRadius);
setPaint(map, `${groupId}-selected-outer`, "circle-radius", visual.selectedRadius);
setPaint(map, groupId, "circle-stroke-width", groupId === "outfalls" ? 2 : undefined);
}
function applyScadaStyle(map: MapLibreMap, patch: ScadaLayerGroupStylePatch) {
if (patch.iconMultiplier !== undefined) setLayout(map, "scada-symbol", "icon-size", 0.75 * patch.iconMultiplier);
if (patch.opacity !== undefined) {
setPaint(map, "scada-symbol", "icon-opacity", patch.opacity);
setPaint(map, "scada-fallback", "circle-opacity", patch.opacity);
}
if (patch.fillColor !== undefined) setPaint(map, "scada-fallback", "circle-color", patch.fillColor);
if (patch.strokeColor !== undefined) setPaint(map, "scada-fallback", "circle-stroke-color", patch.strokeColor);
if (patch.radiusMultiplier !== undefined) setPaint(map, "scada-fallback", "circle-radius", scale(["interpolate", ["linear"], ["zoom"], 12, 5, 20, 8], patch.radiusMultiplier));
if (patch.strokeMultiplier !== undefined) setPaint(map, "scada-fallback", "circle-stroke-width", 2 * patch.strokeMultiplier);
}
function resetScadaStyle(map: MapLibreMap) {
setLayout(map, "scada-symbol", "icon-size", 0.75);
setPaint(map, "scada-symbol", "icon-opacity", 1);
setPaint(map, "scada-fallback", "circle-color", "#0E7490");
setPaint(map, "scada-fallback", "circle-stroke-color", "#FFFFFF");
setPaint(map, "scada-fallback", "circle-opacity", 1);
setPaint(map, "scada-fallback", "circle-radius", ["interpolate", ["linear"], ["zoom"], 12, 5, 20, 8]);
setPaint(map, "scada-fallback", "circle-stroke-width", 2);
}
function scale(value: unknown, multiplier: number): unknown {
return ["*", value, multiplier];
}
function stateColor(color: string): unknown {
return [
"case",
["in", ["downcase", ["to-string", ["coalesce", ["get", "status"], ""]]], ["literal", ["closed", "off", "inactive"]]],
MAP_STYLE_TOKENS.state.inactive,
color
];
}
function setPaint(map: MapLibreMap, layerId: string, property: string, value: unknown) {
if (map.getLayer(layerId)) map.setPaintProperty(layerId, property, (value ?? null) as never);
}
function setLayout(map: MapLibreMap, layerId: string, property: string, value: unknown) {
if (map.getLayer(layerId)) map.setLayoutProperty(layerId, property, value as never);
}
function inRange(value: unknown, min: number, max: number): value is number {
return typeof value === "number" && Number.isFinite(value) && value >= min && value <= max;
}
function isColor(value: unknown): value is string {
return typeof value === "string" && /^#[\da-f]{6}$/i.test(value);
}
+6 -1
View File
@@ -82,7 +82,12 @@ describe("drainage network interaction layers", () => {
expect(paint?.["line-color"]).toBe(MAP_STYLE_TOKENS.state.selected);
expect(paint?.["line-gap-width"]).toBe(visuals.selectedWidth);
expect(paint?.["line-width"]).toBe(visuals.selectedBorderWidth);
expect(paint?.["line-opacity"]).toEqual(["case", ["boolean", ["feature-state", "selected"], false], 1, 0]);
expect(paint?.["line-opacity"]).toEqual([
"case",
["any", ["boolean", ["feature-state", "selected"], false], ["boolean", ["feature-state", "highlighted"], false]],
1,
0
]);
});
const expectedPointOutlines = [
+9 -2
View File
@@ -9,12 +9,19 @@ export const INTERACTIVE_HIT_LAYER_IDS = [
const hoveredOpacity: ExpressionSpecification = [
"case",
["all", ["boolean", ["feature-state", "hovered"], false], ["!", ["boolean", ["feature-state", "selected"], false]]],
[
"all",
["boolean", ["feature-state", "hovered"], false],
["!", ["any", ["boolean", ["feature-state", "selected"], false], ["boolean", ["feature-state", "highlighted"], false]]]
],
1,
0
];
const selectedOpacity: ExpressionSpecification = [
"case", ["boolean", ["feature-state", "selected"], false], 1, 0
"case",
["any", ["boolean", ["feature-state", "selected"], false], ["boolean", ["feature-state", "highlighted"], false]],
1,
0
];
const stateColor = (sourceColor: string): ExpressionSpecification => [
"case",
@@ -8,7 +8,7 @@ import {
} from "./map-control-config";
describe("workbench source layer controls", () => {
it("creates one business control for each Lingang source", () => {
it("creates one business control for each GeoServer source", () => {
const items = createLayerControlItems(INITIAL_LAYER_VISIBILITY);
expect(items.map((item) => item.id)).toEqual([
@@ -20,12 +20,16 @@ describe("workbench source layer controls", () => {
"scada"
]);
expect(items.slice(0, 5).map((item) => item.description)).toEqual([
"lingang:geo_conduits_mat",
"lingang:geo_junctions_mat",
"lingang:geo_orifices_mat",
"lingang:geo_outfalls_mat",
"lingang:geo_pumps_mat"
"wenzhou:geo_conduits_mat",
"wenzhou:geo_junctions_mat",
"wenzhou:geo_orifices_mat",
"wenzhou:geo_outfalls_mat",
"wenzhou:geo_pumps_mat"
]);
expect(items.at(-1)).toMatchObject({
label: "综合监测点",
description: "wenzhou:geo_scadas_mat"
});
});
it("finds every rendered layer that uses a source", () => {
+8 -9
View File
@@ -2,6 +2,7 @@ import type { Map as MapLibreMap } from "maplibre-gl";
import type { BaseLayerOption } from "@/features/map/core/components/base-layers-control";
import type { MapLayerControlItem } from "@/features/map/core/components/layer-control";
import type { MapLegendItem } from "@/features/map/core/components/legend";
import { GEOSERVER_WORKSPACE } from "../../../lib/config";
import { MAP_STYLE_TOKENS } from "./map-colors";
import {
SOURCE_LAYERS,
@@ -33,12 +34,12 @@ export const INITIAL_LAYER_VISIBILITY: Record<string, boolean> = {
};
const SOURCE_CONTROL_LABELS: Record<WaterNetworkSourceId, { label: string; description: string }> = {
conduits: { label: "管渠", description: `lingang:${SOURCE_LAYERS.conduits}` },
junctions: { label: "检查井", description: `lingang:${SOURCE_LAYERS.junctions}` },
orifices: { label: "孔口", description: `lingang:${SOURCE_LAYERS.orifices}` },
outfalls: { label: "排放口", description: `lingang:${SOURCE_LAYERS.outfalls}` },
pumps: { label: "泵", description: `lingang:${SOURCE_LAYERS.pumps}` },
scada: { label: "SCADA 测点", description: `lingang:${SOURCE_LAYERS.scada}` }
conduits: { label: "管渠", description: `${GEOSERVER_WORKSPACE}:${SOURCE_LAYERS.conduits}` },
junctions: { label: "检查井", description: `${GEOSERVER_WORKSPACE}:${SOURCE_LAYERS.junctions}` },
orifices: { label: "孔口", description: `${GEOSERVER_WORKSPACE}:${SOURCE_LAYERS.orifices}` },
outfalls: { label: "排放口", description: `${GEOSERVER_WORKSPACE}:${SOURCE_LAYERS.outfalls}` },
pumps: { label: "泵", description: `${GEOSERVER_WORKSPACE}:${SOURCE_LAYERS.pumps}` },
scada: { label: "综合监测点", description: `${GEOSERVER_WORKSPACE}:${SOURCE_LAYERS.scada}` }
};
export const MAP_LEGEND_ITEMS: MapLegendItem[] = [
@@ -47,9 +48,7 @@ export const MAP_LEGEND_ITEMS: MapLegendItem[] = [
{ id: "orifice", label: "孔口", color: MAP_STYLE_TOKENS.asset.orifice, shape: "dashed-line" },
{ id: "pump", label: "泵", color: MAP_STYLE_TOKENS.asset.pump, shape: "dash-dot-line" },
{ id: "outfall", label: "排放口", color: MAP_STYLE_TOKENS.asset.outfall, shape: "ring" },
{ id: "scada-quality", label: "水质仪", color: "#0F766E", imageSrc: "/icons/scada-water-quality.svg" },
{ id: "scada-level", label: "雷达液位计", color: "#2563EB", imageSrc: "/icons/scada-radar-level.svg" },
{ id: "scada-flow", label: "超声流量计", color: "#EA580C", imageSrc: "/icons/scada-ultrasonic-flow.svg" },
{ id: "scada-integrated", label: "综合监测点", color: "#0E7490", imageSrc: "/icons/scada-integrated-monitoring.svg" },
{ id: "inactive", label: "停用 / 关闭", color: MAP_STYLE_TOKENS.state.inactive, shape: "line" },
{ id: "impact-area", label: "风险影响范围", color: MAP_STYLE_TOKENS.state.risk, shape: "square" },
{ id: "incident", label: "事故 / 爆管", color: MAP_STYLE_TOKENS.state.incident, shape: "dot" },
@@ -0,0 +1,41 @@
import { describe, expect, it } from "vitest";
import {
MAX_CONDUIT_FEATURES,
MAP_FEATURE_ID_FIELDS,
createMapFeatureWfsUrl,
escapeCqlLiteral,
normalizeMapFeatureCollection,
parseMapFeatureQuery
} from "./map-feature-query";
describe("map feature WFS query", () => {
it("maps source identifiers to authoritative feature fields", () => {
expect(MAP_FEATURE_ID_FIELDS).toMatchObject({ conduits: "id", junctions: "id", scada: "sensor_id" });
expect(createMapFeatureWfsUrl({ sourceId: "scada", featureIds: ["S-1"] }).searchParams.get("cql_filter"))
.toBe("sensor_id IN ('S-1')");
});
it("escapes CQL literals and uses materialized WFS layers", () => {
expect(escapeCqlLiteral("a'b")).toBe("'a''b'");
const url = createMapFeatureWfsUrl({ sourceId: "conduits", featureIds: ["a'b"] });
expect(url.searchParams.get("typeNames")).toContain("geo_conduits_mat");
expect(url.searchParams.get("srsName")).toBe("EPSG:4326");
expect(url.searchParams.get("cql_filter")).toBe("id IN ('a''b')");
});
it("limits ids and permits full-network requests only for conduits", () => {
expect(parseMapFeatureQuery({ sourceId: "conduits" })).toEqual({ ok: true, value: { sourceId: "conduits" } });
expect(createMapFeatureWfsUrl({ sourceId: "conduits" }).searchParams.get("count")).toBe(String(MAX_CONDUIT_FEATURES));
expect(parseMapFeatureQuery({ sourceId: "junctions" })).toMatchObject({ ok: false, status: 400 });
expect(parseMapFeatureQuery({ sourceId: "unknown", featureIds: ["1"] })).toMatchObject({ ok: false, status: 422 });
expect(parseMapFeatureQuery({ sourceId: "conduits", featureIds: Array.from({ length: 101 }, (_, index) => String(index)) }))
.toMatchObject({ ok: false, status: 400 });
expect(parseMapFeatureQuery({ sourceId: "conduits", featureIds: ["x".repeat(129)] }))
.toMatchObject({ ok: false, status: 400 });
});
it("preserves an empty result and rejects malformed upstream data", () => {
expect(normalizeMapFeatureCollection({ type: "FeatureCollection", features: [] }, 100)).toEqual({ type: "FeatureCollection", features: [] });
expect(normalizeMapFeatureCollection({ features: [] }, 100)).toBeNull();
});
});
@@ -0,0 +1,88 @@
import type { FeatureCollection } from "geojson";
import { GEOSERVER_WORKSPACE, MAP_URL } from "../../../lib/config";
import { SOURCE_LAYERS, type WaterNetworkSourceId } from "./sources";
export const MAP_FEATURE_ID_FIELDS: Record<WaterNetworkSourceId, "id" | "sensor_id"> = {
conduits: "id",
junctions: "id",
orifices: "id",
outfalls: "id",
pumps: "id",
scada: "sensor_id"
};
export const MAX_MAP_FEATURE_IDS = 100;
export const MAX_MAP_FEATURE_ID_LENGTH = 128;
export const MAX_CONDUIT_FEATURES = 5000;
export type MapFeatureQuery = {
sourceId: WaterNetworkSourceId;
featureIds?: string[];
};
export type MapFeatureQueryValidation =
| { ok: true; value: MapFeatureQuery }
| { ok: false; code: "INVALID_REQUEST" | "UNSUPPORTED_SOURCE"; status: 400 | 422 };
export function parseMapFeatureQuery(body: unknown): MapFeatureQueryValidation {
if (!body || typeof body !== "object") {
return { ok: false, code: "INVALID_REQUEST", status: 400 };
}
const { sourceId, featureIds } = body as { sourceId?: unknown; featureIds?: unknown };
if (typeof sourceId !== "string" || !(sourceId in SOURCE_LAYERS)) {
return { ok: false, code: "UNSUPPORTED_SOURCE", status: 422 };
}
if (featureIds === undefined) {
return sourceId === "conduits"
? { ok: true, value: { sourceId } }
: { ok: false, code: "INVALID_REQUEST", status: 400 };
}
if (
!Array.isArray(featureIds) ||
featureIds.length < 1 ||
featureIds.length > MAX_MAP_FEATURE_IDS ||
!featureIds.every(
(id) => typeof id === "string" && id.trim().length > 0 && id.trim().length <= MAX_MAP_FEATURE_ID_LENGTH
)
) {
return { ok: false, code: "INVALID_REQUEST", status: 400 };
}
return {
ok: true,
value: { sourceId: sourceId as WaterNetworkSourceId, featureIds: featureIds.map((id) => id.trim()) }
};
}
export function escapeCqlLiteral(value: string) {
return `'${value.replaceAll("'", "''")}'`;
}
export function createMapFeatureWfsUrl(query: MapFeatureQuery) {
const idField = MAP_FEATURE_ID_FIELDS[query.sourceId];
const params = new URLSearchParams({
service: "WFS",
version: "2.0.0",
request: "GetFeature",
typeNames: `${GEOSERVER_WORKSPACE}:${SOURCE_LAYERS[query.sourceId]}`,
outputFormat: "application/json",
srsName: "EPSG:4326",
count: String(query.featureIds ? MAX_MAP_FEATURE_IDS : MAX_CONDUIT_FEATURES)
});
if (query.featureIds) {
params.set("cql_filter", `${idField} IN (${query.featureIds.map(escapeCqlLiteral).join(",")})`);
}
return new URL(`${MAP_URL}/${GEOSERVER_WORKSPACE}/ows?${params.toString()}`);
}
export function normalizeMapFeatureCollection(value: unknown, maxFeatures: number): FeatureCollection | null {
if (!value || typeof value !== "object") return null;
const collection = value as FeatureCollection;
if (collection.type !== "FeatureCollection" || !Array.isArray(collection.features)) return null;
return { type: "FeatureCollection", features: collection.features.slice(0, maxFeatures) };
}
@@ -0,0 +1,90 @@
import type { FeatureCollection } from "geojson";
import { describe, expect, it } from "vitest";
import {
SCADA_ANALYSIS_LAYER_IDS,
SCADA_ANALYSIS_LEVEL_COLORS,
createScadaAnalysisCollection,
parseScadaAnalysisItems,
scadaAnalysisLayers
} from "./scada-analysis";
describe("SCADA analysis overlay", () => {
it("uses distinct semantic rings, status indicators, and legible priority labels", () => {
const casing = scadaAnalysisLayers.find((layer) => layer.id === SCADA_ANALYSIS_LAYER_IDS.casing);
const level = scadaAnalysisLayers.find((layer) => layer.id === SCADA_ANALYSIS_LAYER_IDS.level);
const indicator = scadaAnalysisLayers.find((layer) => layer.id === SCADA_ANALYSIS_LAYER_IDS.indicator);
const label = scadaAnalysisLayers.find((layer) => layer.id === SCADA_ANALYSIS_LAYER_IDS.label);
expect(SCADA_ANALYSIS_LEVEL_COLORS).toEqual({
high: "#DC2626",
medium: "#F59E0B",
low: "#16A34A",
unrated: "#7C3AED"
});
expect(casing).toMatchObject({
type: "circle",
paint: { "circle-radius": 16, "circle-stroke-color": "#FFFFFF", "circle-stroke-width": 7 }
});
expect(JSON.stringify(level)).toContain(SCADA_ANALYSIS_LEVEL_COLORS.high);
expect(JSON.stringify(level)).toContain(SCADA_ANALYSIS_LEVEL_COLORS.medium);
expect(JSON.stringify(level)).toContain(SCADA_ANALYSIS_LEVEL_COLORS.low);
expect(JSON.stringify(level)).toContain(SCADA_ANALYSIS_LEVEL_COLORS.unrated);
expect(level).toMatchObject({ paint: { "circle-stroke-width": 4 } });
expect(indicator).toMatchObject({
type: "circle",
paint: {
"circle-radius": 5,
"circle-translate": [11, -11],
"circle-stroke-color": "#FFFFFF",
"circle-stroke-width": 2
}
});
expect(label).toMatchObject({
type: "symbol",
layout: {
"symbol-sort-key": ["get", "sort_priority"],
"text-field": ["get", "analysis_label"],
"text-size": 15,
"text-font": ["Noto Sans Regular"],
"text-allow-overlap": false
},
paint: { "text-halo-color": "#FFFFFF", "text-halo-width": 3, "text-halo-blur": 0.5 }
});
expect(JSON.stringify(label)).toContain("#991B1B");
expect(JSON.stringify(label)).toContain("#92400E");
expect(JSON.stringify(label)).toContain("#166534");
});
it("validates one to one hundred unique IDs and fixed levels", () => {
expect(parseScadaAnalysisItems([{ sensor_id: " MP01 ", level: "high" }])).toEqual([
{ sensor_id: "MP01", level: "high" }
]);
expect(parseScadaAnalysisItems([])).toBeNull();
expect(parseScadaAnalysisItems([{ sensor_id: "MP01", level: "high" }, { sensor_id: "MP01", level: "low" }])).toBeNull();
expect(parseScadaAnalysisItems([{ sensor_id: "MP01", level: "critical" }])).toBeNull();
});
it("uses trusted WFS sensor IDs for localized labels and reports partial misses", () => {
const result = createScadaAnalysisCollection(scadaCollection, [
{ sensor_id: "MP01", level: "high" },
{ sensor_id: "MP02", level: "medium" },
{ sensor_id: "MP404", level: "low" }
]);
expect(result.result).toEqual({
rendered_ids: ["MP01", "MP02"],
missing_ids: ["MP404"],
level_counts: { high: 1, medium: 1, low: 0, unrated: 0 }
});
expect(result.collection.features.map((feature) => feature.properties)).toEqual([
expect.objectContaining({ sensor_id: "MP01", analysis_label: "MP01 · 高", sort_priority: 0 }),
expect.objectContaining({ sensor_id: "MP02", analysis_label: "MP02 · 中", sort_priority: 1 })
]);
});
});
const scadaCollection: FeatureCollection = {
type: "FeatureCollection",
features: [
{ type: "Feature", geometry: { type: "Point", coordinates: [120.7, 28] }, properties: { sensor_id: "MP01" } },
{ type: "Feature", geometry: { type: "Point", coordinates: [120.72, 28.02] }, properties: { sensor_id: "MP02" } }
]
};
+214
View File
@@ -0,0 +1,214 @@
import type { Feature, FeatureCollection, Point } from "geojson";
import type { ExpressionSpecification, Map as MapLibreMap, StyleSpecification } from "maplibre-gl";
export const SCADA_ANALYSIS_SOURCE_ID = "agent-scada-analysis";
export const SCADA_ANALYSIS_LAYER_IDS = {
casing: "agent-scada-analysis-casing",
level: "agent-scada-analysis-level",
indicator: "agent-scada-analysis-indicator",
label: "agent-scada-analysis-label"
} as const;
export const SCADA_ANALYSIS_LEVELS = ["high", "medium", "low", "unrated"] as const;
export type ScadaAnalysisLevel = (typeof SCADA_ANALYSIS_LEVELS)[number];
export type ScadaAnalysisItem = { sensor_id: string; level: ScadaAnalysisLevel };
export type ScadaAnalysisLevelCounts = Record<ScadaAnalysisLevel, number>;
export type ScadaAnalysisRenderResult = {
rendered_ids: string[];
missing_ids: string[];
level_counts: ScadaAnalysisLevelCounts;
fitted: true;
};
export const SCADA_ANALYSIS_LEVEL_COLORS: Record<ScadaAnalysisLevel, string> = {
high: "#DC2626",
medium: "#F59E0B",
low: "#16A34A",
unrated: "#7C3AED"
};
const SCADA_ANALYSIS_LABEL_COLORS: Record<ScadaAnalysisLevel, string> = {
high: "#991B1B",
medium: "#92400E",
low: "#166534",
unrated: "#5B21B6"
};
const levelColorExpression = (
colors: Record<ScadaAnalysisLevel, string>
): ExpressionSpecification => [
"match",
["get", "analysis_level"],
"high", colors.high,
"medium", colors.medium,
"low", colors.low,
colors.unrated
];
export const SCADA_ANALYSIS_LEVEL_LABELS: Record<ScadaAnalysisLevel, string> = {
high: "高",
medium: "中",
low: "低",
unrated: "未分级"
};
const SCADA_ANALYSIS_SORT_PRIORITY: Record<ScadaAnalysisLevel, number> = {
high: 0,
medium: 1,
low: 2,
unrated: 3
};
type Layer = NonNullable<StyleSpecification["layers"]>[number];
export const scadaAnalysisLayers: Layer[] = [
{
id: SCADA_ANALYSIS_LAYER_IDS.casing,
type: "circle",
source: SCADA_ANALYSIS_SOURCE_ID,
paint: {
"circle-radius": 16,
"circle-color": "rgba(255,255,255,0)",
"circle-stroke-color": "#FFFFFF",
"circle-stroke-width": 7
}
},
{
id: SCADA_ANALYSIS_LAYER_IDS.level,
type: "circle",
source: SCADA_ANALYSIS_SOURCE_ID,
paint: {
"circle-radius": 16,
"circle-color": "rgba(255,255,255,0)",
"circle-stroke-color": levelColorExpression(SCADA_ANALYSIS_LEVEL_COLORS),
"circle-stroke-width": 4
}
},
{
id: SCADA_ANALYSIS_LAYER_IDS.indicator,
type: "circle",
source: SCADA_ANALYSIS_SOURCE_ID,
paint: {
"circle-radius": 5,
"circle-translate": [11, -11],
"circle-color": levelColorExpression(SCADA_ANALYSIS_LEVEL_COLORS),
"circle-stroke-color": "#FFFFFF",
"circle-stroke-width": 2
}
},
{
id: SCADA_ANALYSIS_LAYER_IDS.label,
type: "symbol",
source: SCADA_ANALYSIS_SOURCE_ID,
layout: {
"symbol-sort-key": ["get", "sort_priority"],
"text-field": ["get", "analysis_label"],
"text-size": 15,
"text-font": ["Noto Sans Regular"],
"text-letter-spacing": 0.015,
"text-offset": [0, -2.05],
"text-anchor": "bottom",
"text-allow-overlap": false,
"text-ignore-placement": false
},
paint: {
"text-color": levelColorExpression(SCADA_ANALYSIS_LABEL_COLORS),
"text-halo-color": "#FFFFFF",
"text-halo-width": 3,
"text-halo-blur": 0.5
}
}
];
export function ensureScadaAnalysisLayers(
map: Pick<MapLibreMap, "addLayer" | "getLayer">
): void {
scadaAnalysisLayers.forEach((layer, index) => {
if (map.getLayer(layer.id)) return;
const beforeId = scadaAnalysisLayers
.slice(index + 1)
.map((candidate) => candidate.id)
.find((candidateId) => Boolean(map.getLayer(candidateId)));
map.addLayer(layer, beforeId);
});
}
export function createEmptyScadaAnalysisCollection(): FeatureCollection<Point> {
return { type: "FeatureCollection", features: [] };
}
export function parseScadaAnalysisItems(value: unknown): ScadaAnalysisItem[] | null {
if (!Array.isArray(value) || value.length < 1 || value.length > 100) return null;
const items: ScadaAnalysisItem[] = [];
const seen = new Set<string>();
for (const entry of value) {
if (!entry || typeof entry !== "object" || Array.isArray(entry)) return null;
const keys = Object.keys(entry);
if (keys.length !== 2 || !keys.includes("sensor_id") || !keys.includes("level")) return null;
const { sensor_id: rawSensorId, level } = entry as Record<string, unknown>;
const sensorId = typeof rawSensorId === "string" ? rawSensorId.trim() : "";
if (
!sensorId ||
sensorId.length > 128 ||
!SCADA_ANALYSIS_LEVELS.includes(level as ScadaAnalysisLevel) ||
seen.has(sensorId)
) return null;
seen.add(sensorId);
items.push({ sensor_id: sensorId, level: level as ScadaAnalysisLevel });
}
return items;
}
export function createScadaAnalysisCollection(
collection: FeatureCollection,
items: ScadaAnalysisItem[]
): { collection: FeatureCollection<Point>; result: Omit<ScadaAnalysisRenderResult, "fitted"> } {
const featuresBySensorId = new Map<string, Feature<Point>>();
collection.features.forEach((feature) => {
const sensorId = typeof feature.properties?.sensor_id === "string"
? feature.properties.sensor_id.trim()
: "";
if (sensorId && feature.geometry?.type === "Point" && !featuresBySensorId.has(sensorId)) {
featuresBySensorId.set(sensorId, feature as Feature<Point>);
}
});
const levelCounts = createEmptyScadaAnalysisLevelCounts();
const renderedIds: string[] = [];
const missingIds: string[] = [];
const features: Array<Feature<Point>> = [];
items.forEach((item) => {
const feature = featuresBySensorId.get(item.sensor_id);
if (!feature) {
missingIds.push(item.sensor_id);
return;
}
const trustedSensorId = String(feature.properties?.sensor_id ?? "").trim();
if (!trustedSensorId) {
missingIds.push(item.sensor_id);
return;
}
renderedIds.push(trustedSensorId);
levelCounts[item.level] += 1;
features.push({
...feature,
properties: {
...feature.properties,
sensor_id: trustedSensorId,
analysis_level: item.level,
analysis_level_label: SCADA_ANALYSIS_LEVEL_LABELS[item.level],
analysis_label: `${trustedSensorId} · ${SCADA_ANALYSIS_LEVEL_LABELS[item.level]}`,
sort_priority: SCADA_ANALYSIS_SORT_PRIORITY[item.level]
}
});
});
return {
collection: { type: "FeatureCollection", features },
result: { rendered_ids: renderedIds, missing_ids: missingIds, level_counts: levelCounts }
};
}
export function createEmptyScadaAnalysisLevelCounts(): ScadaAnalysisLevelCounts {
return { high: 0, medium: 0, low: 0, unrated: 0 };
}
+17 -9
View File
@@ -12,13 +12,20 @@ import {
import { waterNetworkLayers } from "./layers";
describe("SCADA map presentation", () => {
it("maps the three authoritative device types and falls back to unknown", () => {
it("maps the authoritative device types and falls back to unknown", () => {
expect(SCADA_ICON_EXPRESSION).toEqual([
"match", ["get", "kind"],
"water_quality", SCADA_IMAGE_IDS.waterQuality,
"radar_level", SCADA_IMAGE_IDS.radarLevel,
"ultrasonic_flow", SCADA_IMAGE_IDS.ultrasonicFlow,
SCADA_IMAGE_IDS.unknown
"case",
["has", "kind"],
[
"match", ["get", "kind"],
"water_quality", SCADA_IMAGE_IDS.waterQuality,
"radar_level", SCADA_IMAGE_IDS.radarLevel,
"ultrasonic_flow", SCADA_IMAGE_IDS.ultrasonicFlow,
"integrated_monitoring", SCADA_IMAGE_IDS.integratedMonitoring,
"综合监测点", SCADA_IMAGE_IDS.integratedMonitoring,
SCADA_IMAGE_IDS.integratedMonitoring
],
SCADA_IMAGE_IDS.integratedMonitoring
]);
});
@@ -50,10 +57,11 @@ describe("SCADA map presentation", () => {
"circle-stroke-width": junctionSelectedPaint?.["circle-stroke-width"]
}
});
expect(JSON.stringify((selectedLayer as { paint?: unknown })?.paint)).toContain("highlighted");
});
it("uses MapLibre-compatible PNG assets", () => {
expect(Object.values(SCADA_MAP_ICON_PATHS)).toHaveLength(4);
expect(Object.values(SCADA_MAP_ICON_PATHS)).toHaveLength(5);
Object.values(SCADA_MAP_ICON_PATHS).forEach((path) => expect(path).toMatch(/\.png$/));
});
@@ -62,8 +70,8 @@ describe("SCADA map presentation", () => {
const map = createImageMap(() => Promise.resolve({ data: image }));
await expect(registerScadaImages(map)).resolves.toEqual({ status: "ready", failedImageIds: [] });
expect(map.loadImage).toHaveBeenCalledTimes(4);
expect(map.addImage).toHaveBeenCalledTimes(4);
expect(map.loadImage).toHaveBeenCalledTimes(5);
expect(map.addImage).toHaveBeenCalledTimes(5);
expect(map.addImage).toHaveBeenCalledWith(expect.any(String), image, { pixelRatio: 2 });
});
+21 -4
View File
@@ -2,13 +2,14 @@ import type { ExpressionSpecification, Map as MapLibreMap, StyleSpecification }
import { MAP_STYLE_TOKENS } from "./map-colors";
export const SCADA_SOURCE_ID = "scada";
export const SCADA_SOURCE_LAYER = "scada_points";
export const SCADA_SOURCE_LAYER = "geo_scadas_mat";
export const SCADA_HIT_LAYER_ID = "scada-hit";
export const SCADA_ICON_PATHS = {
waterQuality: "/icons/scada-water-quality.svg",
radarLevel: "/icons/scada-radar-level.svg",
ultrasonicFlow: "/icons/scada-ultrasonic-flow.svg",
integratedMonitoring: "/icons/scada-integrated-monitoring.svg",
unknown: "/icons/scada-unknown.svg"
} as const;
@@ -16,6 +17,7 @@ export const SCADA_MAP_ICON_PATHS = {
waterQuality: "/icons/scada-water-quality.png",
radarLevel: "/icons/scada-radar-level.png",
ultrasonicFlow: "/icons/scada-ultrasonic-flow.png",
integratedMonitoring: "/icons/scada-integrated-monitoring.png",
unknown: "/icons/scada-unknown.png"
} as const;
@@ -23,19 +25,34 @@ export const SCADA_IMAGE_IDS = {
waterQuality: "scada-water-quality",
radarLevel: "scada-radar-level",
ultrasonicFlow: "scada-ultrasonic-flow",
integratedMonitoring: "scada-integrated-monitoring",
unknown: "scada-unknown"
} as const;
export const SCADA_ICON_EXPRESSION: ExpressionSpecification = [
const SCADA_KIND_ICON_EXPRESSION: ExpressionSpecification = [
"match", ["get", "kind"],
"water_quality", SCADA_IMAGE_IDS.waterQuality,
"radar_level", SCADA_IMAGE_IDS.radarLevel,
"ultrasonic_flow", SCADA_IMAGE_IDS.ultrasonicFlow,
SCADA_IMAGE_IDS.unknown
"integrated_monitoring", SCADA_IMAGE_IDS.integratedMonitoring,
"综合监测点", SCADA_IMAGE_IDS.integratedMonitoring,
SCADA_IMAGE_IDS.integratedMonitoring
];
export const SCADA_ICON_EXPRESSION: ExpressionSpecification = [
"case",
["has", "kind"],
SCADA_KIND_ICON_EXPRESSION,
SCADA_IMAGE_IDS.integratedMonitoring
];
const stateOpacity = (state: "hovered" | "selected"): ExpressionSpecification => [
"case", ["boolean", ["feature-state", state], false], 1, 0
"case",
state === "selected"
? ["any", ["boolean", ["feature-state", "selected"], false], ["boolean", ["feature-state", "highlighted"], false]]
: ["boolean", ["feature-state", state], false],
1,
0
];
const SCADA_ICON_SIZE = 0.75;
+10 -4
View File
@@ -3,14 +3,20 @@ import { describe, expect, it } from "vitest";
import { createBaseStyle, createWaterNetworkSources, SOURCE_LAYERS } from "./sources";
describe("createWaterNetworkSources", () => {
it("loads SCADA tiles from the lingang:scada_points GeoServer layer", () => {
it("loads SCADA tiles from the configured GeoServer workspace", () => {
const scada = createWaterNetworkSources().scada;
expect(SOURCE_LAYERS.scada).toBe("scada_points");
expect(SOURCE_LAYERS.scada).toBe("geo_scadas_mat");
expect(scada.tiles).toEqual([
expect.stringContaining("/lingang:scada_points/point/WebMercatorQuad/")
expect.stringContaining("/wenzhou:geo_scadas_mat/point/WebMercatorQuad/")
]);
expect(scada.promoteId).toBe("scada_id");
expect(scada.bounds).toEqual([
120.63483136963328,
27.957404243937606,
120.76346113516635,
28.02399422971424
]);
expect(scada.promoteId).toBe("sensor_id");
});
});
+28 -21
View File
@@ -1,23 +1,30 @@
import type { VectorSourceSpecification, StyleSpecification } from "maplibre-gl";
import { MAP_URL } from "../../../lib/config";
import { GEOSERVER_WORKSPACE, MAP_URL } from "../../../lib/config";
import { MAP_STYLE_TOKENS } from "./map-colors";
export const WATER_NETWORK_GLOBAL_VIEW = {
bbox3857: [
13551482,
3612812.75,
13577696,
3632065.75
13429008,
3243604.5,
13443327,
3251999.25
]
} as const;
const WATER_NETWORK_BOUNDS: [number, number, number, number] = [
120.63483136963328,
27.957404243937606,
120.76346113516635,
28.02399422971424
];
export const SOURCE_LAYERS = {
conduits: "geo_conduits_mat",
junctions: "geo_junctions_mat",
orifices: "geo_orifices_mat",
outfalls: "geo_outfalls_mat",
pumps: "geo_pumps_mat",
scada: "scada_points"
scada: "geo_scadas_mat"
} as const;
export const WATER_NETWORK_SOURCE_IDS = [
@@ -35,41 +42,41 @@ const GEOSERVER_WMTS_ROOT = `${MAP_URL}/gwc/service/wmts/rest`;
export function createWaterNetworkSources() {
return {
conduits: createLingangVectorSource(
conduits: createGeoServerVectorSource(
SOURCE_LAYERS.conduits,
"line",
[121.7350340307058, 30.84636502815, 121.97051839928491, 30.994737681416165]
WATER_NETWORK_BOUNDS
),
junctions: createLingangVectorSource(
junctions: createGeoServerVectorSource(
SOURCE_LAYERS.junctions,
"point",
[121.7350340307058, 30.84636502815, 121.97051839928491, 30.994737681416165]
WATER_NETWORK_BOUNDS
),
orifices: createLingangVectorSource(
orifices: createGeoServerVectorSource(
SOURCE_LAYERS.orifices,
"line",
[121.88611269518903, 30.919491577239235, 121.9347115520599, 30.952564332104696]
WATER_NETWORK_BOUNDS
),
outfalls: createLingangVectorSource(
outfalls: createGeoServerVectorSource(
SOURCE_LAYERS.outfalls,
"point",
[121.77154156385242, 30.85723317314842, 121.77231411499677, 30.859452151585806]
WATER_NETWORK_BOUNDS
),
pumps: createLingangVectorSource(
pumps: createGeoServerVectorSource(
SOURCE_LAYERS.pumps,
"line",
[121.73585149761436, 30.861759757577705, 121.9498481645973, 30.983213202338085]
WATER_NETWORK_BOUNDS
),
scada: createLingangVectorSource(
scada: createGeoServerVectorSource(
SOURCE_LAYERS.scada,
"point",
[121.7350340307058, 30.84636502815, 121.97051839928491, 30.994737681416165],
"scada_id"
WATER_NETWORK_BOUNDS,
"sensor_id"
)
};
}
function createLingangVectorSource(
function createGeoServerVectorSource(
sourceLayer: (typeof SOURCE_LAYERS)[keyof typeof SOURCE_LAYERS],
style: "line" | "point",
bounds: [number, number, number, number],
@@ -79,7 +86,7 @@ function createLingangVectorSource(
type: "vector",
promoteId,
tiles: [
`${GEOSERVER_WMTS_ROOT}/lingang:${sourceLayer}/${style}/WebMercatorQuad/{z}/{y}/{x}?format=application/vnd.mapbox-vector-tile`
`${GEOSERVER_WMTS_ROOT}/${GEOSERVER_WORKSPACE}:${sourceLayer}/${style}/WebMercatorQuad/{z}/{y}/{x}?format=application/vnd.mapbox-vector-tile`
],
bounds,
minzoom: 0,
@@ -0,0 +1,24 @@
import { describe, expect, it } from "vitest";
import { createValueLabelCollection, formatFeatureValue, getNumericFeatureProperties } from "./value-label";
describe("workbench value labels", () => {
const feature = {
type: "Feature" as const,
geometry: { type: "Point" as const, coordinates: [120, 28] },
properties: { diameter: 600.125, name: "P-1", invalid: Number.NaN, depth: 2 }
};
it("lists finite numeric properties only", () => {
expect(getNumericFeatureProperties(feature.properties)).toEqual([["depth", 2], ["diameter", 600.125]]);
});
it("formats precision and unit with hard limits", () => {
expect(formatFeatureValue(12.3456, 2, "mm")).toBe("12.35 mm");
expect(formatFeatureValue(12.3456, 8, "")).toBe("12.346");
});
it("creates a single controlled label feature", () => {
expect(createValueLabelCollection(feature, "diameter", 1, "mm").features[0]?.properties?.label).toBe("600.1 mm");
expect(() => createValueLabelCollection(feature, "name", 1)).toThrow("INVALID_VALUE_PROPERTY");
});
});
+41
View File
@@ -0,0 +1,41 @@
import type { Feature, FeatureCollection, Geometry } from "geojson";
export const VALUE_LABEL_SOURCE_ID = "workbench-value-label";
export const VALUE_LABEL_LAYER_IDS = ["workbench-value-label-point", "workbench-value-label-line"] as const;
export function getNumericFeatureProperties(properties: Record<string, unknown> | null | undefined) {
return Object.entries(properties ?? {})
.filter((entry): entry is [string, number] => typeof entry[1] === "number" && Number.isFinite(entry[1]))
.sort(([a], [b]) => a.localeCompare(b));
}
export function formatFeatureValue(value: number, precision: number, unit = "") {
const safePrecision = Math.min(3, Math.max(0, Math.trunc(precision)));
const safeUnit = unit.trim().slice(0, 16);
return `${value.toFixed(safePrecision)}${safeUnit ? ` ${safeUnit}` : ""}`;
}
export function createValueLabelCollection(
feature: Feature,
property: string,
precision: number,
unit = ""
): FeatureCollection {
const value = feature.properties?.[property];
if (typeof value !== "number" || !Number.isFinite(value)) {
throw new Error("INVALID_VALUE_PROPERTY");
}
return {
type: "FeatureCollection",
features: [{
type: "Feature",
geometry: feature.geometry as Geometry,
properties: { label: formatFeatureValue(value, precision, unit) }
}]
};
}
export function createEmptyValueLabelCollection(): FeatureCollection {
return { type: "FeatureCollection", features: [] };
}
@@ -0,0 +1,174 @@
import type { FeatureCollection } from "geojson";
import type { Map as MapLibreMap } from "maplibre-gl";
import { describe, expect, it, vi } from "vitest";
import { WorkbenchMapController } from "./workbench-map-controller";
import { SCADA_ANALYSIS_LAYER_IDS, SCADA_ANALYSIS_SOURCE_ID } from "./scada-analysis";
describe("WorkbenchMapController", () => {
it("locates points with zoom 18 and keeps selected state untouched", async () => {
const map = createMap();
const controller = createController(map, pointCollection);
await controller.locateAndHighlight({ sourceId: "junctions", featureId: "J-1" });
expect(map.easeTo).toHaveBeenCalledWith(expect.objectContaining({ center: [120.7, 28], zoom: 18 }));
expect(map.setFeatureState).toHaveBeenCalledWith(expect.objectContaining({ source: "junctions", id: "J-1" }), { highlighted: true });
controller.clearHighlight();
expect(map.removeFeatureState).toHaveBeenCalledWith(expect.anything(), "highlighted");
expect(map.removeFeatureState).not.toHaveBeenCalledWith(expect.anything(), "selected");
});
it("fits line bounds and uses responsive workbench padding", async () => {
const map = createMap();
const padding = { top: 50, right: 420, bottom: 50, left: 320 };
const controller = createController(map, lineCollection, padding);
await controller.locateAndHighlight({ sourceId: "conduits", featureId: "C-1" });
expect(map.fitBounds).toHaveBeenCalledWith([[120, 28], [121, 29]], expect.objectContaining({ padding, maxZoom: 18 }));
});
it("does not clear the last valid highlight when a target is missing", async () => {
const map = createMap();
let collection = pointCollection;
const controller = new WorkbenchMapController({
getMap: () => map as unknown as MapLibreMap,
isReady: () => true,
getPadding: () => ({ top: 0, right: 0, bottom: 0, left: 0 }),
fetchFeatures: async () => collection
});
await controller.highlight({ sourceId: "junctions", featureId: "J-1" });
collection = { type: "FeatureCollection", features: [] };
await controller.highlight({ sourceId: "junctions", featureId: "missing" });
expect(controller.getSnapshot().target).toEqual({ sourceId: "junctions", featureId: "J-1" });
expect(controller.getSnapshot().errorCode).toBe("FEATURE_NOT_FOUND");
expect(map.removeFeatureState).not.toHaveBeenCalled();
});
it("renders one SCADA point atomically and zooms to level 18", async () => {
const source = { setData: vi.fn() };
const map = createMap(source);
const controller = createController(map, scadaPointCollection);
const result = await controller.renderScadaAnalysis([{ sensor_id: "MP01", level: "high" }]);
expect(source.setData).toHaveBeenCalledWith(expect.objectContaining({
features: [expect.objectContaining({ properties: expect.objectContaining({ analysis_label: "MP01 · 高" }) })]
}));
expect(map.easeTo).toHaveBeenCalledWith(expect.objectContaining({ center: [120.7, 28], zoom: 18 }));
expect(result).toEqual({
rendered_ids: ["MP01"],
missing_ids: [],
level_counts: { high: 1, medium: 0, low: 0, unrated: 0 },
fitted: true
});
});
it("restores missing SCADA analysis presentation layers before reporting success", async () => {
const source = { setData: vi.fn() };
const map = createMap(source, false);
const controller = createController(map, scadaPointCollection);
await controller.renderScadaAnalysis([{ sensor_id: "MP01", level: "high" }]);
expect(map.addLayer).toHaveBeenCalledTimes(Object.keys(SCADA_ANALYSIS_LAYER_IDS).length);
expect(map.addLayer.mock.calls.map(([layer]) => layer.id)).toEqual(
Object.values(SCADA_ANALYSIS_LAYER_IDS)
);
expect(map.addLayer.mock.invocationCallOrder.at(-1)).toBeLessThan(
source.setData.mock.invocationCallOrder[0]
);
});
it("fits multiple SCADA points with panel-safe padding and reports missing IDs", async () => {
const source = { setData: vi.fn() };
const map = createMap(source);
const padding = { top: 50, right: 420, bottom: 50, left: 320 };
const controller = createController(map, scadaPointCollection, padding);
const result = await controller.renderScadaAnalysis([
{ sensor_id: "MP01", level: "high" },
{ sensor_id: "MP02", level: "medium" },
{ sensor_id: "MP404", level: "low" }
]);
expect(map.fitBounds).toHaveBeenCalledWith([[120.7, 28], [120.72, 28.02]], expect.objectContaining({ padding, maxZoom: 18 }));
expect(result.missing_ids).toEqual(["MP404"]);
expect(result.level_counts).toEqual({ high: 1, medium: 1, low: 0, unrated: 0 });
});
it("preserves the last valid overlay when all IDs are missing or WFS fails", async () => {
const source = { setData: vi.fn() };
const map = createMap(source);
let mode: "found" | "missing" | "failed" = "found";
const controller = new WorkbenchMapController({
getMap: () => map as unknown as MapLibreMap,
isReady: () => true,
getPadding: () => ({ top: 0, right: 0, bottom: 0, left: 0 }),
fetchFeatures: async () => {
if (mode === "failed") throw new Error("network");
return mode === "found" ? scadaPointCollection : { type: "FeatureCollection", features: [] };
}
});
await controller.renderScadaAnalysis([{ sensor_id: "MP01", level: "high" }]);
const lastValid = source.setData.mock.calls[0][0];
mode = "missing";
await expect(controller.renderScadaAnalysis([{ sensor_id: "MP404", level: "low" }])).rejects.toThrow("SCADA_FEATURES_NOT_FOUND");
expect(source.setData).toHaveBeenCalledTimes(1);
expect(controller.getSnapshot().scadaAnalysis?.rendered_ids).toEqual(["MP01"]);
mode = "failed";
await expect(controller.renderScadaAnalysis([{ sensor_id: "MP02", level: "low" }])).rejects.toThrow("WFS_UNAVAILABLE");
expect(source.setData).toHaveBeenCalledTimes(1);
expect(source.setData.mock.calls[0][0]).toBe(lastValid);
});
it("replaces the previous overlay and clears it explicitly", async () => {
const source = { setData: vi.fn() };
const map = createMap(source);
const controller = createController(map, scadaPointCollection);
await controller.renderScadaAnalysis([{ sensor_id: "MP01", level: "high" }]);
await controller.renderScadaAnalysis([{ sensor_id: "MP02", level: "low" }]);
expect(source.setData.mock.calls[1][0].features).toHaveLength(1);
expect(source.setData.mock.calls[1][0].features[0].properties.sensor_id).toBe("MP02");
expect(controller.clearScadaAnalysis()).toEqual({ cleared: true });
expect(source.setData.mock.calls[2][0]).toEqual({ type: "FeatureCollection", features: [] });
expect(controller.getSnapshot().scadaAnalysis).toBeNull();
});
it("keeps the existing overlay when the map is not ready", async () => {
const source = { setData: vi.fn() };
const map = createMap(source);
let ready = true;
const controller = new WorkbenchMapController({
getMap: () => map as unknown as MapLibreMap,
isReady: () => ready,
getPadding: () => ({ top: 0, right: 0, bottom: 0, left: 0 }),
fetchFeatures: async () => scadaPointCollection
});
await controller.renderScadaAnalysis([{ sensor_id: "MP01", level: "high" }]);
ready = false;
await expect(controller.renderScadaAnalysis([{ sensor_id: "MP02", level: "low" }])).rejects.toThrow("MAP_NOT_READY");
expect(source.setData).toHaveBeenCalledTimes(1);
expect(controller.getSnapshot().scadaAnalysis?.rendered_ids).toEqual(["MP01"]);
});
});
const pointCollection: FeatureCollection = { type: "FeatureCollection", features: [{ type: "Feature", geometry: { type: "Point", coordinates: [120.7, 28] }, properties: { id: "J-1" } }] };
const lineCollection: FeatureCollection = { type: "FeatureCollection", features: [{ type: "Feature", geometry: { type: "LineString", coordinates: [[120, 28], [121, 29]] }, properties: { id: "C-1" } }] };
const scadaPointCollection: FeatureCollection = { type: "FeatureCollection", features: [
{ type: "Feature", geometry: { type: "Point", coordinates: [120.7, 28] }, properties: { sensor_id: "MP01" } },
{ type: "Feature", geometry: { type: "Point", coordinates: [120.72, 28.02] }, properties: { sensor_id: "MP02" } }
] };
function createMap(
scadaSource?: { setData: ReturnType<typeof vi.fn> },
analysisLayersReady = true
) {
return {
easeTo: vi.fn(), fitBounds: vi.fn(), setFeatureState: vi.fn(), removeFeatureState: vi.fn(),
getSource: vi.fn((id: string) => id === SCADA_ANALYSIS_SOURCE_ID ? scadaSource : undefined),
getLayer: vi.fn((id: string) => analysisLayersReady && Object.values(SCADA_ANALYSIS_LAYER_IDS).includes(id as never) ? { id } : undefined),
addLayer: vi.fn(), setPaintProperty: vi.fn(), setLayoutProperty: vi.fn(), removeControl: vi.fn()
};
}
function createController(map: ReturnType<typeof createMap>, collection: FeatureCollection, padding = { top: 48, right: 48, bottom: 48, left: 48 }) {
return new WorkbenchMapController({
getMap: () => map as unknown as MapLibreMap,
isReady: () => true,
getPadding: () => padding,
fetchFeatures: vi.fn(async () => collection)
});
}
@@ -0,0 +1,349 @@
import type { Feature, FeatureCollection, GeoJsonProperties, Geometry, Position } from "geojson";
import type { GeoJSONSource, Map as MapLibreMap, PaddingOptions } from "maplibre-gl";
import { SOURCE_LAYERS, type WaterNetworkSourceId } from "./sources";
import {
LAYER_GROUP_GEOMETRIES,
applyLayerGroupStyle as applyStyle,
resetLayerGroupStyle as resetStyle,
validateLayerGroupStylePatch,
type LayerGroupId,
type LayerGroupStylePatch
} from "./layer-group-style";
import { createFlowOverlayState, hideFlowOverlay, showFlowOverlay, type FlowOverlayState } from "./flow-overlay";
import {
VALUE_LABEL_SOURCE_ID,
createEmptyValueLabelCollection,
createValueLabelCollection
} from "./value-label";
import {
SCADA_ANALYSIS_SOURCE_ID,
createEmptyScadaAnalysisCollection,
createScadaAnalysisCollection,
ensureScadaAnalysisLayers,
type ScadaAnalysisItem,
type ScadaAnalysisRenderResult
} from "./scada-analysis";
export type FeatureTarget = { sourceId: WaterNetworkSourceId; featureId: string };
export type WorkbenchMapErrorCode =
| "MAP_NOT_READY"
| "FEATURE_NOT_FOUND"
| "WFS_UNAVAILABLE"
| "SCADA_FEATURES_NOT_FOUND"
| "ACTION_SUPERSEDED"
| "FLOW_UNAVAILABLE"
| "INVALID_STYLE_PATCH";
export type WorkbenchMapControllerState = {
target: FeatureTarget | null;
pending: boolean;
errorCode: WorkbenchMapErrorCode | null;
flowVisible: boolean;
styledGroups: LayerGroupId[];
scadaAnalysis: ScadaAnalysisRenderResult | null;
};
export type ValueLabelRequest = FeatureTarget & { property: string; precision: number; unit?: string };
export type WorkbenchMapCommands = {
locateAndHighlight: (target: FeatureTarget) => Promise<void>;
highlight: (target: FeatureTarget) => Promise<void>;
clearHighlight: () => void;
setFlowVisible: (visible: boolean) => Promise<void>;
showValueLabel: (request: { target: FeatureTarget; property: string; precision: number; unit?: string }) => Promise<void>;
clearValueLabel: () => void;
applyLayerGroupStyle: (groupId: LayerGroupId, patch: LayerGroupStylePatch) => void;
resetLayerGroupStyle: (groupId: LayerGroupId) => void;
renderScadaAnalysis: (items: ScadaAnalysisItem[], signal?: AbortSignal) => Promise<ScadaAnalysisRenderResult>;
clearScadaAnalysis: () => { cleared: true };
resetAll: () => void;
};
export type WorkbenchMapControllerOptions = {
getMap: () => MapLibreMap | null;
isReady: () => boolean;
getPadding: () => PaddingOptions;
fetchFeatures?: (sourceId: WaterNetworkSourceId, featureIds?: string[], signal?: AbortSignal) => Promise<FeatureCollection>;
reducedMotion?: () => boolean;
};
export class WorkbenchMapController implements WorkbenchMapCommands {
private state: WorkbenchMapControllerState = { target: null, pending: false, errorCode: null, flowVisible: false, styledGroups: [], scadaAnalysis: null };
private listeners = new Set<() => void>();
private highlightedTarget: FeatureTarget | null = null;
private cachedFeatures = new Map<string, Feature>();
private flowState: FlowOverlayState = createFlowOverlayState();
private scadaAnalysisRevision = 0;
constructor(private readonly options: WorkbenchMapControllerOptions) {}
subscribe = (listener: () => void) => {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
};
getSnapshot = () => this.state;
async locateAndHighlight(target: FeatureTarget) {
const feature = await this.resolveTarget(target);
const map = this.requireMap();
if (!feature || !map) return;
const coordinates = collectCoordinates(feature.geometry);
if (!coordinates.length) return this.setError("FEATURE_NOT_FOUND");
if (feature.geometry.type === "Point") {
map.easeTo({ center: coordinates[0] as [number, number], zoom: 18, padding: this.options.getPadding(), duration: 600 });
} else {
const bounds = coordinates.reduce(
(acc, coordinate) => [Math.min(acc[0], coordinate[0]), Math.min(acc[1], coordinate[1]), Math.max(acc[2], coordinate[0]), Math.max(acc[3], coordinate[1])],
[Infinity, Infinity, -Infinity, -Infinity]
);
map.fitBounds([[bounds[0], bounds[1]], [bounds[2], bounds[3]]], { padding: this.options.getPadding(), maxZoom: 18, duration: 600 });
}
this.setHighlight(target);
}
async highlight(target: FeatureTarget) {
const feature = await this.resolveTarget(target);
if (feature) this.setHighlight(target);
}
clearHighlight() {
const map = this.options.getMap();
if (map && this.highlightedTarget) {
map.removeFeatureState(toFeatureStateTarget(this.highlightedTarget), "highlighted");
}
this.highlightedTarget = null;
this.patchState({ target: null, errorCode: null });
}
async setFlowVisible(visible: boolean) {
const map = this.requireMap();
if (!map) return;
if (!visible) {
hideFlowOverlay(map, this.flowState);
this.patchState({ flowVisible: false, errorCode: null });
return;
}
this.patchState({ pending: true, errorCode: null });
try {
await showFlowOverlay(
map,
this.flowState,
() => this.fetchFeatures("conduits"),
this.options.reducedMotion?.()
);
this.patchState({ pending: false, flowVisible: true });
} catch {
hideFlowOverlay(map, this.flowState);
this.patchState({ pending: false, flowVisible: false, errorCode: "FLOW_UNAVAILABLE" });
}
}
async showValueLabel({ target, property, precision, unit = "" }: { target: FeatureTarget; property: string; precision: number; unit?: string }) {
if (!Number.isInteger(precision) || precision < 0 || precision > 3 || unit.length > 16) return this.setError("INVALID_STYLE_PATCH");
const feature = await this.resolveTarget(target);
const map = this.requireMap();
if (!feature || !map) return;
try {
(map.getSource(VALUE_LABEL_SOURCE_ID) as GeoJSONSource | undefined)?.setData(
createValueLabelCollection(feature, property, precision, unit)
);
this.patchState({ target, errorCode: null });
} catch {
this.setError("INVALID_STYLE_PATCH");
}
}
clearValueLabel() {
const map = this.options.getMap();
(map?.getSource(VALUE_LABEL_SOURCE_ID) as GeoJSONSource | undefined)?.setData(createEmptyValueLabelCollection());
}
applyLayerGroupStyle(groupId: LayerGroupId, patch: LayerGroupStylePatch) {
const map = this.requireMap();
if (!map) return;
if (!validateLayerGroupStylePatch(groupId, patch)) return this.setError("INVALID_STYLE_PATCH");
applyStyle(map, groupId, patch);
this.patchState({ styledGroups: [...new Set([...this.state.styledGroups, groupId])], errorCode: null });
}
resetLayerGroupStyle(groupId: LayerGroupId) {
const map = this.requireMap();
if (!map || !(groupId in LAYER_GROUP_GEOMETRIES)) return;
resetStyle(map, groupId);
this.patchState({ styledGroups: this.state.styledGroups.filter((item) => item !== groupId), errorCode: null });
}
async renderScadaAnalysis(items: ScadaAnalysisItem[], signal?: AbortSignal): Promise<ScadaAnalysisRenderResult> {
const revision = ++this.scadaAnalysisRevision;
const map = this.options.getMap();
if (!map || !this.options.isReady()) {
this.setError("MAP_NOT_READY");
throw new Error("MAP_NOT_READY");
}
this.patchState({ pending: true, errorCode: null });
let sourceCollection: FeatureCollection;
try {
sourceCollection = await this.fetchFeatures("scada", items.map((item) => item.sensor_id), signal);
} catch {
if (revision === this.scadaAnalysisRevision) {
this.patchState({ pending: false, errorCode: "WFS_UNAVAILABLE" });
}
throw new Error(revision === this.scadaAnalysisRevision ? "WFS_UNAVAILABLE" : "ACTION_SUPERSEDED");
}
if (revision !== this.scadaAnalysisRevision) throw new Error("ACTION_SUPERSEDED");
const next = createScadaAnalysisCollection(sourceCollection, items);
if (next.collection.features.length === 0) {
this.patchState({ pending: false, errorCode: "SCADA_FEATURES_NOT_FOUND" });
throw new Error("SCADA_FEATURES_NOT_FOUND");
}
const source = map.getSource(SCADA_ANALYSIS_SOURCE_ID) as GeoJSONSource | undefined;
if (!source) {
this.patchState({ pending: false, errorCode: "MAP_NOT_READY" });
throw new Error("MAP_NOT_READY");
}
try {
ensureScadaAnalysisLayers(map);
source.setData(next.collection);
} catch {
this.patchState({ pending: false, errorCode: "MAP_NOT_READY" });
throw new Error("MAP_NOT_READY");
}
const result: ScadaAnalysisRenderResult = { ...next.result, fitted: true };
this.patchState({ pending: false, errorCode: null, scadaAnalysis: result });
const coordinates = next.collection.features.map((feature) => feature.geometry.coordinates);
if (coordinates.length === 1) {
map.easeTo({
center: [coordinates[0][0], coordinates[0][1]],
zoom: 18,
padding: this.options.getPadding(),
duration: this.options.reducedMotion?.() ? 0 : 600
});
} else {
const bounds = coordinates.reduce<[number, number, number, number]>(
(value, coordinate) => [
Math.min(value[0], coordinate[0]),
Math.min(value[1], coordinate[1]),
Math.max(value[2], coordinate[0]),
Math.max(value[3], coordinate[1])
],
[Infinity, Infinity, -Infinity, -Infinity]
);
map.fitBounds([[bounds[0], bounds[1]], [bounds[2], bounds[3]]], {
padding: this.options.getPadding(),
maxZoom: 18,
duration: this.options.reducedMotion?.() ? 0 : 600
});
}
return result;
}
clearScadaAnalysis() {
this.scadaAnalysisRevision += 1;
const map = this.options.getMap();
(map?.getSource(SCADA_ANALYSIS_SOURCE_ID) as GeoJSONSource | undefined)?.setData(
createEmptyScadaAnalysisCollection()
);
this.patchState({ scadaAnalysis: null, pending: false, errorCode: null });
return { cleared: true as const };
}
resetAll() {
this.clearHighlight();
this.clearValueLabel();
this.clearScadaAnalysis();
const map = this.options.getMap();
if (map) {
hideFlowOverlay(map, this.flowState);
(Object.keys(LAYER_GROUP_GEOMETRIES) as LayerGroupId[]).forEach((groupId) => resetStyle(map, groupId));
}
this.patchState({ target: null, pending: false, errorCode: null, flowVisible: false, styledGroups: [], scadaAnalysis: null });
}
destroy() {
const map = this.options.getMap();
if (map) hideFlowOverlay(map, this.flowState);
this.listeners.clear();
}
private async resolveTarget(target: FeatureTarget) {
const map = this.requireMap();
if (!map) return null;
const key = `${target.sourceId}:${target.featureId}`;
const cached = this.cachedFeatures.get(key);
if (cached) return cached;
this.patchState({ pending: true, errorCode: null });
try {
const collection = await this.fetchFeatures(target.sourceId, [target.featureId]);
const feature = collection.features[0];
if (!feature) {
this.patchState({ pending: false, errorCode: "FEATURE_NOT_FOUND" });
return null;
}
this.cachedFeatures.set(key, feature);
this.patchState({ pending: false });
return feature;
} catch {
this.patchState({ pending: false, errorCode: "WFS_UNAVAILABLE" });
return null;
}
}
private async fetchFeatures(sourceId: WaterNetworkSourceId, featureIds?: string[], signal?: AbortSignal) {
if (this.options.fetchFeatures) return this.options.fetchFeatures(sourceId, featureIds, signal);
const response = await fetch("/api/map-features", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sourceId, featureIds }),
signal
});
const body = await response.json();
if (!response.ok) throw new Error(body.code ?? "WFS_UNAVAILABLE");
return body as FeatureCollection;
}
private setHighlight(target: FeatureTarget) {
const map = this.requireMap();
if (!map) return;
if (this.highlightedTarget) map.removeFeatureState(toFeatureStateTarget(this.highlightedTarget), "highlighted");
map.setFeatureState(toFeatureStateTarget(target), { highlighted: true });
this.highlightedTarget = target;
this.clearValueLabel();
this.patchState({ target, errorCode: null });
}
private requireMap() {
const map = this.options.getMap();
if (!map || !this.options.isReady()) {
this.setError("MAP_NOT_READY");
return null;
}
return map;
}
private setError(errorCode: WorkbenchMapErrorCode) {
this.patchState({ errorCode });
}
private patchState(patch: Partial<WorkbenchMapControllerState>) {
this.state = { ...this.state, ...patch };
this.listeners.forEach((listener) => listener());
}
}
function toFeatureStateTarget(target: FeatureTarget) {
return { source: target.sourceId, sourceLayer: SOURCE_LAYERS[target.sourceId], id: target.featureId };
}
function collectCoordinates(geometry: Geometry | null): Position[] {
if (!geometry || geometry.type === "GeometryCollection") return [];
const coordinates: Position[] = [];
const visit = (value: unknown) => {
if (Array.isArray(value) && value.length >= 2 && typeof value[0] === "number" && typeof value[1] === "number") coordinates.push(value as Position);
else if (Array.isArray(value)) value.forEach(visit);
};
visit(geometry.coordinates);
return coordinates;
}