feat(workbench): align v2 GIS and SCADA API
Generic Container CI/CD / test-build-publish (push) Successful in 26s
Frontend CI/CD / build-test-publish-and-deploy (push) Successful in 26s

This commit is contained in:
2026-09-08 18:18:40 +08:00
parent 28287a1447
commit f9c71cc076
45 changed files with 1025 additions and 254 deletions
+3 -1
View File
@@ -51,7 +51,9 @@ export async function exportMapViewImage(map: MapLibreMap, { scale, targetLongEd
});
if (selectedFeature) {
exportMap.setFeatureState(
{ source: selectedFeature.source, sourceLayer: selectedFeature.sourceLayer, id: selectedFeature.id },
selectedFeature.sourceLayer
? { source: selectedFeature.source, sourceLayer: selectedFeature.sourceLayer, id: selectedFeature.id }
: { source: selectedFeature.source, id: selectedFeature.id },
{ selected: true, hovered: false }
);
}
+26 -7
View File
@@ -1,15 +1,19 @@
import type { MapGeoJSONFeature } from "maplibre-gl";
import type { DetailFeature } from "../types";
import { formatValue } from "../utils/format-value";
import { SOURCE_LAYERS, type WaterNetworkSourceId } from "./sources";
import {
SOURCE_LAYERS,
type GeoServerWaterNetworkSourceId,
type WaterNetworkSourceId
} from "./sources";
export function getFeatureId(feature: MapGeoJSONFeature) {
const raw = feature.properties?.id ?? feature.id;
const raw = feature.properties?.device_id ?? feature.properties?.id ?? feature.id;
return raw === undefined || raw === null ? "" : String(raw);
}
export function getWaterNetworkSourceId(feature: MapGeoJSONFeature): WaterNetworkSourceId | null {
for (const [sourceId, sourceLayer] of Object.entries(SOURCE_LAYERS) as [WaterNetworkSourceId, string][]) {
for (const [sourceId, sourceLayer] of Object.entries(SOURCE_LAYERS) as [GeoServerWaterNetworkSourceId, string][]) {
if (feature.source === sourceId || feature.sourceLayer === sourceLayer) {
return sourceId;
}
@@ -43,26 +47,41 @@ function getFeatureLabels(sourceId: WaterNetworkSourceId, id: string, properties
if (sourceId === "valves") {
return {
title: `阀门 ${id || "未命名"}`,
subtitle: `DN${formatValue(properties.diameter)} · ${formatValue(properties.v_type)}`
subtitle: `DN${formatValue(properties.diameter)} · ${formatValue(properties.valve_type)}`
};
}
if (sourceId === "reservoirs") {
return {
title: `水库 ${id || "未命名"}`,
subtitle: `水头 ${formatValue(properties.head)} · 模式 ${formatValue(properties.pattern)}`
subtitle: `水头 ${formatValue(properties.head)} · 模式 ${formatValue(properties.pattern_id)}`
};
}
if (sourceId === "scada") {
const associatedElementId = properties.node_id ?? properties.link_id;
return {
title: `SCADA ${id || "未命名"}`,
subtitle: `${formatValue(properties.type)} · 关联 ${formatValue(properties.associated_element_id)}`
subtitle: `${formatValue(properties.device_type)} · 关联 ${formatValue(associatedElementId)}`
};
}
if (sourceId === "pumps") {
return {
title: `水泵 ${id || "未命名"}`,
subtitle: `${formatValue(properties.start_node_id)} → ${formatValue(properties.end_node_id)}`
};
}
if (sourceId === "tanks") {
return {
title: `水箱 ${id || "未命名"}`,
subtitle: `初始水位 ${formatValue(properties.initial_level)} · 高程 ${formatValue(properties.elevation)} m`
};
}
return {
title: `节点 ${id || "未命名"}`,
subtitle: `需水量 ${formatValue(properties.demand)} · 高程 ${formatValue(properties.elevation)} m`
subtitle: `基础需水量 ${formatValue(properties.base_demand)} · 高程 ${formatValue(properties.elevation)} m`
};
}
@@ -27,12 +27,12 @@ describe("flow overlay", () => {
expect(layers.get(FLOW_LINE_LAYER_ID)).toMatchObject({
source: "pipes",
"source-layer": "geo_pipes_mat",
"source-layer": "pipes",
paint: { "line-pattern": "workbench-network-flow-pattern-forward" }
});
expect(layers.get(FLOW_REVERSE_LINE_LAYER_ID)).toMatchObject({
source: "pipes",
"source-layer": "geo_pipes_mat",
"source-layer": "pipes",
paint: { "line-pattern": "workbench-network-flow-pattern-reverse" }
});
expect([...layers.keys()].some((id) => id.includes("arrows"))).toBe(false);
@@ -56,7 +56,7 @@ describe("flow overlay", () => {
expect(map.setFeatureState).toHaveBeenNthCalledWith(1, {
source: "pipes",
sourceLayer: "geo_pipes_mat",
sourceLayer: "pipes",
id: "P-1"
}, {
flowDirection: -1
+9 -1
View File
@@ -34,6 +34,14 @@ describe("supply network layer styling", () => {
minzoom: 8.5,
layout: { "icon-image": SUPPLY_ASSET_IMAGE_IDS.reservoir }
});
expect(getLayer("pumps-symbol")).toMatchObject({
minzoom: 11,
layout: { "icon-image": SUPPLY_ASSET_IMAGE_IDS.pump }
});
expect(getLayer("tanks-symbol")).toMatchObject({
minzoom: 11,
layout: { "icon-image": SUPPLY_ASSET_IMAGE_IDS.tank }
});
expect(getLayer("junctions")).toMatchObject({ minzoom: 15 });
const valvePaint = getLayer("valves")?.paint as Record<string, unknown> | undefined;
@@ -47,7 +55,7 @@ describe("supply network layer styling", () => {
const ids = waterNetworkLayers.map((layer) => layer.id);
expect(ids.indexOf("pipes-flow")).toBeLessThan(ids.indexOf("pipes-hover"));
expect(ids.indexOf("pipes-hover")).toBeLessThan(ids.indexOf("pipes-hit"));
expect(ids.at(-1)).toBe("reservoirs-hit");
expect(ids.at(-1)).toBe("tanks-hit");
});
});
+61 -1
View File
@@ -22,6 +22,8 @@ export const INTERACTIVE_HIT_LAYER_IDS = [
"junctions-hit",
"valves-hit",
"reservoirs-hit",
"pumps-hit",
"tanks-hit",
SCADA_HIT_LAYER_ID
] as const;
@@ -208,6 +210,38 @@ layers.push(
paint: {
"icon-opacity": supplyIconOpacityExpression
}
},
{
id: "pumps-symbol",
type: "symbol",
source: "pumps",
"source-layer": SOURCE_LAYERS.pumps,
minzoom: SUPPLY_LAYER_VISUALS.pumps.minzoom,
layout: {
"icon-image": SUPPLY_ASSET_IMAGE_IDS.pump,
"icon-size": SUPPLY_LAYER_VISUALS.pumps.iconSize,
"icon-allow-overlap": true,
"icon-ignore-placement": true
},
paint: {
"icon-opacity": supplyIconOpacityExpression
}
},
{
id: "tanks-symbol",
type: "symbol",
source: "tanks",
"source-layer": SOURCE_LAYERS.tanks,
minzoom: SUPPLY_LAYER_VISUALS.tanks.minzoom,
layout: {
"icon-image": SUPPLY_ASSET_IMAGE_IDS.tank,
"icon-size": SUPPLY_LAYER_VISUALS.tanks.iconSize,
"icon-allow-overlap": true,
"icon-ignore-placement": true
},
paint: {
"icon-opacity": supplyIconOpacityExpression
}
}
);
@@ -295,7 +329,9 @@ lineInteractions.forEach(([prefix, source, sourceLayer, visual, color, dash]) =>
const pointInteractions = [
["junctions", "junctions", SOURCE_LAYERS.junctions, SUPPLY_LAYER_VISUALS.junctions, MAP_STYLE_TOKENS.supply.junction],
["valves", "valves", SOURCE_LAYERS.valves, SUPPLY_LAYER_VISUALS.valves, MAP_STYLE_TOKENS.supply.valve],
["reservoirs", "reservoirs", SOURCE_LAYERS.reservoirs, SUPPLY_LAYER_VISUALS.reservoirs, MAP_STYLE_TOKENS.supply.reservoir]
["reservoirs", "reservoirs", SOURCE_LAYERS.reservoirs, SUPPLY_LAYER_VISUALS.reservoirs, MAP_STYLE_TOKENS.supply.reservoir],
["pumps", "pumps", SOURCE_LAYERS.pumps, SUPPLY_LAYER_VISUALS.pumps, MAP_STYLE_TOKENS.supply.pump],
["tanks", "tanks", SOURCE_LAYERS.tanks, SUPPLY_LAYER_VISUALS.tanks, MAP_STYLE_TOKENS.supply.tank]
] as const;
pointInteractions.forEach(([prefix, source, sourceLayer, visual, color]) => {
@@ -403,6 +439,30 @@ layers.push(
"circle-radius": SUPPLY_LAYER_VISUALS.reservoirs.hitRadius,
"circle-opacity": 0
}
},
{
id: "pumps-hit",
type: "circle",
source: "pumps",
"source-layer": SOURCE_LAYERS.pumps,
minzoom: SUPPLY_LAYER_VISUALS.pumps.minzoom,
paint: {
"circle-color": MAP_STYLE_TOKENS.canvas.transparent,
"circle-radius": SUPPLY_LAYER_VISUALS.pumps.hitRadius,
"circle-opacity": 0
}
},
{
id: "tanks-hit",
type: "circle",
source: "tanks",
"source-layer": SOURCE_LAYERS.tanks,
minzoom: SUPPLY_LAYER_VISUALS.tanks.minzoom,
paint: {
"circle-color": MAP_STYLE_TOKENS.canvas.transparent,
"circle-radius": SUPPLY_LAYER_VISUALS.tanks.hitRadius,
"circle-opacity": 0
}
}
);
+2
View File
@@ -14,6 +14,8 @@ export const MAP_STYLE_TOKENS = {
junction: "#47787B",
valve: "#6B7280",
reservoir: "#195F84",
pump: "#326B87",
tank: "#4F6F7A",
scadaFlow: "#007A9E",
scadaPressure: "#9A6700"
},
@@ -19,16 +19,18 @@ describe("workbench supply layer controls", () => {
"valves",
"reservoirs",
"scada",
"pumps",
"tanks",
"simulation"
]);
expect(items.map((item) => item.id)).not.toContain("pumps");
expect(items.map((item) => item.id)).not.toContain("tanks");
expect(items.slice(0, 5).map((item) => item.description)).toEqual([
"tjwater:geo_pipes_mat",
"tjwater:geo_junctions_mat",
"tjwater:geo_valves",
"tjwater:geo_reservoirs",
"tjwater:geo_scada"
expect(items.slice(0, 7).map((item) => item.description)).toEqual([
"tjwater_next:pipes",
"tjwater_next:junctions",
"tjwater_next:valves",
"tjwater_next:reservoirs",
"后端业务 API",
"tjwater_next:pumps",
"tjwater_next:tanks"
]);
});
@@ -41,6 +43,8 @@ describe("workbench supply layer controls", () => {
"junction",
"valve",
"reservoir",
"pump",
"tank",
"scada-flow",
"scada-pressure",
"impact-area"
@@ -50,6 +54,8 @@ describe("workbench supply layer controls", () => {
label: "水库",
imageSrc: "/map/reservoir.png"
});
expect(MAP_LEGEND_ITEMS.find((item) => item.id === "pump")).toMatchObject({ imageSrc: "/map/pump.png" });
expect(MAP_LEGEND_ITEMS.find((item) => item.id === "tank")).toMatchObject({ imageSrc: "/map/tank.png" });
expect(MAP_LEGEND_ITEMS.find((item) => item.id === "scada-flow")).toMatchObject({ imageSrc: "/map/scada-flow.png" });
expect(MAP_LEGEND_ITEMS.find((item) => item.id === "scada-pressure")).toMatchObject({ imageSrc: "/map/scada-pressure.png" });
expect(MAP_LEGEND_ITEMS.find((item) => item.id === "inactive-pipe")).toMatchObject({
@@ -82,5 +88,12 @@ describe("workbench supply layer controls", () => {
"reservoirs-selected",
"reservoirs-hit"
]);
expect(getWorkbenchLayerIds(map, "pumps")).toEqual([
"pumps-symbol",
"pumps-hover",
"pumps-selected-outer",
"pumps-selected",
"pumps-hit"
]);
});
});
@@ -35,7 +35,9 @@ const SOURCE_CONTROL_LABELS = Object.fromEntries(
layer.id,
{
label: layer.label,
description: `${GEOSERVER_WORKSPACE}:${layer.sourceLayer}`
description: layer.id === "scada"
? "后端业务 API"
: `${GEOSERVER_WORKSPACE}:${layer.sourceLayer}`
}
])
) as Record<WaterNetworkSourceId, { label: string; description: string }>;
@@ -48,6 +50,8 @@ export const MAP_LEGEND_ITEMS: MapLegendItem[] = [
{ id: "junction", label: "节点", color: MAP_STYLE_TOKENS.supply.junction, shape: "dot" },
{ id: "valve", label: "阀门", color: MAP_STYLE_TOKENS.supply.valve, imageSrc: SUPPLY_ASSET_ICON_PATHS.valve },
{ id: "reservoir", label: "水库", color: MAP_STYLE_TOKENS.supply.reservoir, imageSrc: SUPPLY_ASSET_ICON_PATHS.reservoir },
{ id: "pump", label: "水泵", color: MAP_STYLE_TOKENS.supply.pump, imageSrc: SUPPLY_ASSET_ICON_PATHS.pump },
{ id: "tank", label: "水箱", color: MAP_STYLE_TOKENS.supply.tank, imageSrc: SUPPLY_ASSET_ICON_PATHS.tank },
{ id: "scada-flow", label: "SCADA 流量", color: MAP_STYLE_TOKENS.supply.scadaFlow, imageSrc: SCADA_ICON_PATHS.flow },
{ id: "scada-pressure", label: "SCADA 压力", color: MAP_STYLE_TOKENS.supply.scadaPressure, imageSrc: SCADA_ICON_PATHS.pressure },
{ id: "impact-area", label: "影响范围", color: MAP_STYLE_TOKENS.state.incident, shape: "square" }
@@ -15,7 +15,6 @@ describe("map feature WFS query", () => {
junctions: "id",
valves: "id",
reservoirs: "id",
scada: "id",
pumps: "id",
tanks: "id"
});
@@ -23,18 +22,21 @@ describe("map feature WFS query", () => {
.toBe("\"id\" IN ('J-1')");
});
it("escapes CQL literals and uses materialized WFS layers", () => {
it("escapes CQL literals and uses the new materialized WFS layers", () => {
expect(escapeCqlLiteral("a'b")).toBe("'a''b'");
const url = createMapFeatureWfsUrl({ sourceId: "pipes", featureIds: ["a'b"] });
expect(url.searchParams.get("typeNames")).toContain("geo_pipes_mat");
expect(url.searchParams.get("typeNames")).toContain("tjwater_next:pipes");
expect(url.searchParams.get("srsName")).toBe("EPSG:4326");
expect(url.searchParams.get("cql_filter")).toBe("\"id\" IN ('a''b')");
});
it("uses the authoritative supply asset WFS layers", () => {
expect(createMapFeatureWfsUrl({ sourceId: "valves", featureIds: ["V-1"] }).searchParams.get("typeNames")).toContain("geo_valves");
expect(createMapFeatureWfsUrl({ sourceId: "reservoirs", featureIds: ["R-1"] }).searchParams.get("typeNames")).toContain("geo_reservoirs");
expect(createMapFeatureWfsUrl({ sourceId: "scada", featureIds: ["S-1"] }).searchParams.get("typeNames")).toContain("geo_scada");
it("uses WFS only for authoritative supply asset layers", () => {
expect(createMapFeatureWfsUrl({ sourceId: "valves", featureIds: ["V-1"] }).searchParams.get("typeNames")).toContain("tjwater_next:valves");
expect(createMapFeatureWfsUrl({ sourceId: "reservoirs", featureIds: ["R-1"] }).searchParams.get("typeNames")).toContain("tjwater_next:reservoirs");
expect(parseMapFeatureQuery({ sourceId: "scada", featureIds: ["S-1"] })).toMatchObject({
ok: false,
code: "UNSUPPORTED_SOURCE"
});
});
it("limits ids and permits full-network requests only for pipes", () => {
@@ -42,8 +44,8 @@ describe("map feature WFS query", () => {
expect(createMapFeatureWfsUrl({ sourceId: "pipes" }).searchParams.get("count")).toBe(String(MAX_PIPE_FEATURES));
expect(parseMapFeatureQuery({ sourceId: "junctions" })).toMatchObject({ ok: false, status: 400 });
expect(parseMapFeatureQuery({ sourceId: "unknown", featureIds: ["1"] })).toMatchObject({ ok: false, status: 422 });
expect(parseMapFeatureQuery({ sourceId: "pumps", featureIds: ["P-1"] })).toMatchObject({ ok: false, status: 422 });
expect(parseMapFeatureQuery({ sourceId: "tanks", featureIds: ["T-1"] })).toMatchObject({ ok: false, status: 422 });
expect(parseMapFeatureQuery({ sourceId: "pumps", featureIds: ["P-1"] })).toEqual({ ok: true, value: { sourceId: "pumps", featureIds: ["P-1"] } });
expect(parseMapFeatureQuery({ sourceId: "tanks", featureIds: ["T-1"] })).toEqual({ ok: true, value: { sourceId: "tanks", featureIds: ["T-1"] } });
expect(parseMapFeatureQuery({ sourceId: "pipes", featureIds: Array.from({ length: 101 }, (_, index) => String(index)) }))
.toMatchObject({ ok: false, status: 400 });
expect(parseMapFeatureQuery({ sourceId: "pipes", featureIds: ["x".repeat(129)] }))
@@ -2,16 +2,15 @@ import type { FeatureCollection } from "geojson";
import { GEOSERVER_WORKSPACE, MAP_URL } from "./geoserver-config";
import {
SOURCE_LAYERS,
isAvailableWaterNetworkSourceId,
type WaterNetworkSourceId
GEOSERVER_WATER_NETWORK_SOURCE_IDS,
type GeoServerWaterNetworkSourceId
} from "./sources";
export const MAP_FEATURE_ID_FIELDS: Record<WaterNetworkSourceId, "id"> = {
export const MAP_FEATURE_ID_FIELDS: Record<GeoServerWaterNetworkSourceId, "id"> = {
pipes: "id",
junctions: "id",
valves: "id",
reservoirs: "id",
scada: "id",
pumps: "id",
tanks: "id"
};
@@ -21,7 +20,7 @@ export const MAX_MAP_FEATURE_ID_LENGTH = 128;
export const MAX_PIPE_FEATURES = 5000;
export type MapFeatureQuery = {
sourceId: WaterNetworkSourceId;
sourceId: GeoServerWaterNetworkSourceId;
featureIds?: string[];
};
@@ -35,7 +34,10 @@ export function parseMapFeatureQuery(body: unknown): MapFeatureQueryValidation {
}
const { sourceId, featureIds } = body as { sourceId?: unknown; featureIds?: unknown };
if (typeof sourceId !== "string" || !(sourceId in SOURCE_LAYERS) || !isAvailableWaterNetworkSourceId(sourceId)) {
if (
typeof sourceId !== "string" ||
!(GEOSERVER_WATER_NETWORK_SOURCE_IDS as readonly string[]).includes(sourceId)
) {
return { ok: false, code: "UNSUPPORTED_SOURCE", status: 422 };
}
@@ -58,7 +60,7 @@ export function parseMapFeatureQuery(body: unknown): MapFeatureQueryValidation {
return {
ok: true,
value: { sourceId: sourceId as WaterNetworkSourceId, featureIds: featureIds.map((id) => id.trim()) }
value: { sourceId: sourceId as GeoServerWaterNetworkSourceId, featureIds: featureIds.map((id) => id.trim()) }
};
}
@@ -56,19 +56,19 @@ describe("SCADA analysis overlay", () => {
});
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([{ device_id: " MP01 ", level: "high" }])).toEqual([
{ device_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();
expect(parseScadaAnalysisItems([{ device_id: "MP01", level: "high" }, { device_id: "MP01", level: "low" }])).toBeNull();
expect(parseScadaAnalysisItems([{ device_id: "MP01", level: "critical" }])).toBeNull();
});
it("uses trusted WFS sensor IDs for localized labels and reports partial misses", () => {
it("uses trusted API device 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" }
{ device_id: "MP01", level: "high" },
{ device_id: "MP02", level: "medium" },
{ device_id: "MP404", level: "low" }
]);
expect(result.result).toEqual({
rendered_ids: ["MP01", "MP02"],
@@ -76,8 +76,8 @@ describe("SCADA analysis overlay", () => {
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 })
expect.objectContaining({ device_id: "MP01", analysis_label: "MP01 · 高", sort_priority: 0 }),
expect.objectContaining({ device_id: "MP02", analysis_label: "MP02 · 中", sort_priority: 1 })
]);
});
});
@@ -85,7 +85,7 @@ describe("SCADA analysis overlay", () => {
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" } }
{ type: "Feature", geometry: { type: "Point", coordinates: [120.7, 28] }, properties: { device_id: "MP01" } },
{ type: "Feature", geometry: { type: "Point", coordinates: [120.72, 28.02] }, properties: { device_id: "MP02" } }
]
};
+22 -22
View File
@@ -12,7 +12,7 @@ export const SCADA_ANALYSIS_LAYER_IDS = {
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 ScadaAnalysisItem = { device_id: string; level: ScadaAnalysisLevel };
export type ScadaAnalysisLevelCounts = Record<ScadaAnalysisLevel, number>;
export type ScadaAnalysisRenderResult = {
rendered_ids: string[];
@@ -145,17 +145,17 @@ export function parseScadaAnalysisItems(value: unknown): ScadaAnalysisItem[] | n
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 (keys.length !== 2 || !keys.includes("device_id") || !keys.includes("level")) return null;
const { device_id: rawDeviceId, level } = entry as Record<string, unknown>;
const deviceId = typeof rawDeviceId === "string" ? rawDeviceId.trim() : "";
if (
!sensorId ||
sensorId.length > 128 ||
!deviceId ||
deviceId.length > 128 ||
!SCADA_ANALYSIS_LEVELS.includes(level as ScadaAnalysisLevel) ||
seen.has(sensorId)
seen.has(deviceId)
) return null;
seen.add(sensorId);
items.push({ sensor_id: sensorId, level: level as ScadaAnalysisLevel });
seen.add(deviceId);
items.push({ device_id: deviceId, level: level as ScadaAnalysisLevel });
}
return items;
}
@@ -164,13 +164,13 @@ export function createScadaAnalysisCollection(
collection: FeatureCollection,
items: ScadaAnalysisItem[]
): { collection: FeatureCollection<Point>; result: Omit<ScadaAnalysisRenderResult, "fitted"> } {
const featuresBySensorId = new Map<string, Feature<Point>>();
const featuresByDeviceId = new Map<string, Feature<Point>>();
collection.features.forEach((feature) => {
const sensorId = typeof feature.properties?.sensor_id === "string"
? feature.properties.sensor_id.trim()
const deviceId = typeof feature.properties?.device_id === "string"
? feature.properties.device_id.trim()
: "";
if (sensorId && feature.geometry?.type === "Point" && !featuresBySensorId.has(sensorId)) {
featuresBySensorId.set(sensorId, feature as Feature<Point>);
if (deviceId && feature.geometry?.type === "Point" && !featuresByDeviceId.has(deviceId)) {
featuresByDeviceId.set(deviceId, feature as Feature<Point>);
}
});
@@ -179,26 +179,26 @@ export function createScadaAnalysisCollection(
const missingIds: string[] = [];
const features: Array<Feature<Point>> = [];
items.forEach((item) => {
const feature = featuresBySensorId.get(item.sensor_id);
const feature = featuresByDeviceId.get(item.device_id);
if (!feature) {
missingIds.push(item.sensor_id);
missingIds.push(item.device_id);
return;
}
const trustedSensorId = String(feature.properties?.sensor_id ?? "").trim();
if (!trustedSensorId) {
missingIds.push(item.sensor_id);
const trustedDeviceId = String(feature.properties?.device_id ?? "").trim();
if (!trustedDeviceId) {
missingIds.push(item.device_id);
return;
}
renderedIds.push(trustedSensorId);
renderedIds.push(trustedDeviceId);
levelCounts[item.level] += 1;
features.push({
...feature,
properties: {
...feature.properties,
sensor_id: trustedSensorId,
device_id: trustedDeviceId,
analysis_level: item.level,
analysis_level_label: SCADA_ANALYSIS_LEVEL_LABELS[item.level],
analysis_label: `${trustedSensorId} · ${SCADA_ANALYSIS_LEVEL_LABELS[item.level]}`,
analysis_label: `${trustedDeviceId} · ${SCADA_ANALYSIS_LEVEL_LABELS[item.level]}`,
sort_priority: SCADA_ANALYSIS_SORT_PRIORITY[item.level]
}
});
+8 -1
View File
@@ -16,7 +16,7 @@ import { SUPPLY_LAYER_VISUALS } from "./map-layer-visuals";
describe("SCADA map presentation", () => {
it("maps the authoritative device types and falls back to unknown", () => {
expect(SCADA_ICON_EXPRESSION).toEqual([
"match", ["downcase", ["to-string", ["coalesce", ["get", "type"], ""]]],
"match", ["downcase", ["to-string", ["coalesce", ["get", "device_type"], ""]]],
"pipe_flow", SCADA_IMAGE_IDS.flow,
"pressure", SCADA_IMAGE_IDS.pressure,
SCADA_IMAGE_IDS.pressure
@@ -28,6 +28,13 @@ describe("SCADA map presentation", () => {
expect(scadaFallbackLayers.at(-1)?.id).toBe(SCADA_HIT_LAYER_ID);
});
it("renders every SCADA layer from GeoJSON without a GeoServer source-layer", () => {
[...scadaLayers, ...scadaFallbackLayers].forEach((layer) => {
expect("source" in layer ? layer.source : undefined).toBe("scada");
expect(layer).not.toHaveProperty("source-layer");
});
});
it("keeps the icon and selected ring consistent with junction selection", () => {
const symbolLayer = scadaLayers.find((layer) => layer.id === "scada-symbol");
const outerLayer = scadaLayers.find((layer) => layer.id === "scada-selected-outer");
+10 -11
View File
@@ -8,7 +8,6 @@ import {
} from "./supply-style-expressions";
export const SCADA_SOURCE_ID = "scada";
export const SCADA_SOURCE_LAYER = "geo_scada";
export const SCADA_HIT_LAYER_ID = "scada-hit";
export const SCADA_ICON_PATHS = {
@@ -24,7 +23,7 @@ export const SCADA_IMAGE_IDS = {
} as const;
export const SCADA_ICON_EXPRESSION: ExpressionSpecification = [
"match", ["downcase", ["to-string", ["coalesce", ["get", "type"], ""]]],
"match", ["downcase", ["to-string", ["coalesce", ["get", "device_type"], ""]]],
"pipe_flow", SCADA_IMAGE_IDS.flow,
"pressure", SCADA_IMAGE_IDS.pressure,
SCADA_IMAGE_IDS.pressure
@@ -40,7 +39,7 @@ const stateOpacity = (state: "hovered" | "selected"): ExpressionSpecification =>
];
const scadaColor: ExpressionSpecification = [
"match", ["downcase", ["to-string", ["coalesce", ["get", "type"], ""]]],
"match", ["downcase", ["to-string", ["coalesce", ["get", "device_type"], ""]]],
"pipe_flow", MAP_STYLE_TOKENS.supply.scadaFlow,
MAP_STYLE_TOKENS.supply.scadaPressure
];
@@ -53,7 +52,7 @@ export type ScadaImageRegistrationResult = {
};
const scadaCasingLayer: Layer = {
id: "scada-casing", type: "circle", source: SCADA_SOURCE_ID, "source-layer": SCADA_SOURCE_LAYER, minzoom: SUPPLY_LAYER_VISUALS.scada.minzoom,
id: "scada-casing", type: "circle", source: SCADA_SOURCE_ID, minzoom: SUPPLY_LAYER_VISUALS.scada.minzoom,
paint: {
"circle-radius": SUPPLY_LAYER_VISUALS.scada.haloRadius,
"circle-color": MAP_STYLE_TOKENS.canvas.casing,
@@ -62,7 +61,7 @@ const scadaCasingLayer: Layer = {
};
const scadaFillLayer: Layer = {
id: "scada-fill", type: "circle", source: SCADA_SOURCE_ID, "source-layer": SCADA_SOURCE_LAYER, minzoom: SUPPLY_LAYER_VISUALS.scada.minzoom,
id: "scada-fill", type: "circle", source: SCADA_SOURCE_ID, minzoom: SUPPLY_LAYER_VISUALS.scada.minzoom,
paint: {
"circle-radius": SUPPLY_LAYER_VISUALS.scada.radius,
"circle-color": createSupplyStateColor(scadaColor),
@@ -71,13 +70,13 @@ const scadaFillLayer: Layer = {
};
const scadaSymbolLayer: Layer = {
id: "scada-symbol", type: "symbol", source: SCADA_SOURCE_ID, "source-layer": SCADA_SOURCE_LAYER, minzoom: SUPPLY_LAYER_VISUALS.scada.minzoom,
id: "scada-symbol", type: "symbol", source: SCADA_SOURCE_ID, minzoom: SUPPLY_LAYER_VISUALS.scada.minzoom,
layout: { "icon-image": SCADA_ICON_EXPRESSION, "icon-size": SUPPLY_LAYER_VISUALS.scada.iconSize, "icon-allow-overlap": true, "icon-ignore-placement": true },
paint: { "icon-opacity": supplyIconOpacityExpression }
};
const scadaFallbackLayer: Layer = {
id: "scada-fallback", type: "circle", source: SCADA_SOURCE_ID, "source-layer": SCADA_SOURCE_LAYER, minzoom: SUPPLY_LAYER_VISUALS.scada.minzoom,
id: "scada-fallback", type: "circle", source: SCADA_SOURCE_ID, minzoom: SUPPLY_LAYER_VISUALS.scada.minzoom,
paint: {
"circle-radius": ["interpolate", ["linear"], ["zoom"], 9, 1.7, 12, 2.1, 16, 2.7, 20, 3.4, 24, 4.2],
"circle-color": MAP_STYLE_TOKENS.canvas.casing,
@@ -86,13 +85,13 @@ const scadaFallbackLayer: Layer = {
};
export const scadaInteractionLayers: Layer[] = [
{ id: "scada-hover", type: "circle", source: SCADA_SOURCE_ID, "source-layer": SCADA_SOURCE_LAYER, minzoom: SUPPLY_LAYER_VISUALS.scada.minzoom, paint: { "circle-radius": SUPPLY_LAYER_VISUALS.scada.hoverRadius, "circle-color": MAP_STYLE_TOKENS.canvas.transparent, "circle-stroke-color": scadaColor, "circle-stroke-width": 2, "circle-opacity": stateOpacity("hovered"), "circle-stroke-opacity": stateOpacity("hovered") } },
{ id: "scada-selected-outer", type: "circle", source: SCADA_SOURCE_ID, "source-layer": SCADA_SOURCE_LAYER, minzoom: SUPPLY_LAYER_VISUALS.scada.minzoom, paint: { "circle-radius": SUPPLY_LAYER_VISUALS.scada.selectedRadius, "circle-color": MAP_STYLE_TOKENS.canvas.transparent, "circle-stroke-color": MAP_STYLE_TOKENS.canvas.casing, "circle-stroke-width": 5, "circle-opacity": stateOpacity("selected"), "circle-stroke-opacity": stateOpacity("selected") } },
{ id: "scada-selected", type: "circle", source: SCADA_SOURCE_ID, "source-layer": SCADA_SOURCE_LAYER, minzoom: SUPPLY_LAYER_VISUALS.scada.minzoom, paint: { "circle-radius": SUPPLY_LAYER_VISUALS.scada.selectedRadius, "circle-color": MAP_STYLE_TOKENS.canvas.transparent, "circle-stroke-color": MAP_STYLE_TOKENS.state.selected, "circle-stroke-width": 2.5, "circle-opacity": stateOpacity("selected"), "circle-stroke-opacity": stateOpacity("selected") } }
{ id: "scada-hover", type: "circle", source: SCADA_SOURCE_ID, minzoom: SUPPLY_LAYER_VISUALS.scada.minzoom, paint: { "circle-radius": SUPPLY_LAYER_VISUALS.scada.hoverRadius, "circle-color": MAP_STYLE_TOKENS.canvas.transparent, "circle-stroke-color": scadaColor, "circle-stroke-width": 2, "circle-opacity": stateOpacity("hovered"), "circle-stroke-opacity": stateOpacity("hovered") } },
{ id: "scada-selected-outer", type: "circle", source: SCADA_SOURCE_ID, minzoom: SUPPLY_LAYER_VISUALS.scada.minzoom, paint: { "circle-radius": SUPPLY_LAYER_VISUALS.scada.selectedRadius, "circle-color": MAP_STYLE_TOKENS.canvas.transparent, "circle-stroke-color": MAP_STYLE_TOKENS.canvas.casing, "circle-stroke-width": 5, "circle-opacity": stateOpacity("selected"), "circle-stroke-opacity": stateOpacity("selected") } },
{ id: "scada-selected", type: "circle", source: SCADA_SOURCE_ID, minzoom: SUPPLY_LAYER_VISUALS.scada.minzoom, paint: { "circle-radius": SUPPLY_LAYER_VISUALS.scada.selectedRadius, "circle-color": MAP_STYLE_TOKENS.canvas.transparent, "circle-stroke-color": MAP_STYLE_TOKENS.state.selected, "circle-stroke-width": 2.5, "circle-opacity": stateOpacity("selected"), "circle-stroke-opacity": stateOpacity("selected") } }
];
const scadaHitLayer: Layer = {
id: SCADA_HIT_LAYER_ID, type: "circle", source: SCADA_SOURCE_ID, "source-layer": SCADA_SOURCE_LAYER, minzoom: SUPPLY_LAYER_VISUALS.scada.minzoom,
id: SCADA_HIT_LAYER_ID, type: "circle", source: SCADA_SOURCE_ID, minzoom: SUPPLY_LAYER_VISUALS.scada.minzoom,
paint: { "circle-radius": SUPPLY_LAYER_VISUALS.scada.hitRadius, "circle-color": MAP_STYLE_TOKENS.canvas.transparent, "circle-opacity": 0 }
};
+31 -16
View File
@@ -1,7 +1,9 @@
import { describe, expect, it } from "vitest";
import {
AVAILABLE_WATER_NETWORK_SOURCE_IDS,
GEOSERVER_WATER_NETWORK_SOURCE_IDS,
SUPPLY_LAYER_CATALOG,
createBaseStyle,
createWaterNetworkSources,
SOURCE_LAYERS,
WATER_NETWORK_SOURCE_IDS
@@ -10,13 +12,12 @@ import {
describe("createWaterNetworkSources", () => {
it("catalogs published and pre-registered supply system layers", () => {
expect(SOURCE_LAYERS).toEqual({
pipes: "geo_pipes_mat",
junctions: "geo_junctions_mat",
valves: "geo_valves",
reservoirs: "geo_reservoirs",
scada: "geo_scada",
pumps: "geo_pumps",
tanks: "geo_tanks"
pipes: "pipes",
junctions: "junctions",
valves: "valves",
reservoirs: "reservoirs",
pumps: "pumps",
tanks: "tanks"
});
expect(WATER_NETWORK_SOURCE_IDS).toEqual(["pipes", "junctions", "valves", "reservoirs", "scada", "pumps", "tanks"]);
expect(SUPPLY_LAYER_CATALOG.map((layer) => [layer.id, layer.available])).toEqual([
@@ -25,31 +26,45 @@ describe("createWaterNetworkSources", () => {
["valves", true],
["reservoirs", true],
["scada", true],
["pumps", false],
["tanks", false]
["pumps", true],
["tanks", true]
]);
expect(SUPPLY_LAYER_CATALOG.find((layer) => layer.id === "valves")).toMatchObject({
geometry: "point",
sourceLayer: "geo_valves"
sourceLayer: "valves"
});
});
it("loads only available supply system layers from GeoServer WMTS", () => {
expect(AVAILABLE_WATER_NETWORK_SOURCE_IDS).toEqual(["pipes", "junctions", "valves", "reservoirs", "scada"]);
expect(AVAILABLE_WATER_NETWORK_SOURCE_IDS).toEqual(["pipes", "junctions", "valves", "reservoirs", "scada", "pumps", "tanks"]);
expect(SUPPLY_LAYER_CATALOG.find((layer) => layer.id === "reservoirs")).toMatchObject({
label: "水库",
icon: "reservoir"
});
const sources = createWaterNetworkSources();
expect(Object.keys(sources)).toEqual(AVAILABLE_WATER_NETWORK_SOURCE_IDS);
AVAILABLE_WATER_NETWORK_SOURCE_IDS.forEach((sourceId) => {
expect(Object.keys(sources)).toEqual(GEOSERVER_WATER_NETWORK_SOURCE_IDS);
GEOSERVER_WATER_NETWORK_SOURCE_IDS.forEach((sourceId) => {
expect(sources[sourceId].tiles).toEqual([
expect.stringContaining(`/tjwater:${SOURCE_LAYERS[sourceId]}/WebMercatorQuad/`)
expect.stringContaining(`/tjwater_next:${SOURCE_LAYERS[sourceId]}/WebMercatorQuad/`)
]);
expect(sources[sourceId].promoteId).toBe("id");
expect(sources[sourceId].bounds).toEqual([
121.35162408429075,
30.81049932304578,
121.77248479490075,
31.007207809222017
]);
});
expect("pumps" in sources).toBe(true);
expect("tanks" in sources).toBe(true);
expect("scada" in sources).toBe(false);
});
it("uses device_id as the MapLibre feature ID for the API-backed SCADA source", () => {
expect(createBaseStyle().sources.scada).toMatchObject({
type: "geojson",
promoteId: "device_id"
});
expect("pumps" in sources).toBe(false);
expect("tanks" in sources).toBe(false);
});
});
+32 -22
View File
@@ -6,19 +6,20 @@ import {
SCADA_ANALYSIS_SOURCE_ID,
createEmptyScadaAnalysisCollection
} from "./scada-analysis";
import { SCADA_SOURCE_ID } from "./scada";
export const WATER_NETWORK_GLOBAL_VIEW = {
bbox3857: [13508801.930066336, 3608163.3499832638, 13555650.638648884, 3633685.137885742]
bbox3857: [13508801.930066336, 3608163.3499832638, 13555650.638648884, 3633685.137885742],
bbox4326: [121.35162408429075, 30.81049932304578, 121.77248479490075, 31.007207809222017]
} as const;
export const SOURCE_LAYERS = {
pipes: "geo_pipes_mat",
junctions: "geo_junctions_mat",
valves: "geo_valves",
reservoirs: "geo_reservoirs",
scada: "geo_scada",
pumps: "geo_pumps",
tanks: "geo_tanks"
pipes: "pipes",
junctions: "junctions",
valves: "valves",
reservoirs: "reservoirs",
pumps: "pumps",
tanks: "tanks"
} as const;
export const WATER_NETWORK_SOURCE_IDS = [
@@ -35,7 +36,7 @@ export type WaterNetworkSourceId = (typeof WATER_NETWORK_SOURCE_IDS)[number];
export type SupplyLayerCatalogItem = {
id: WaterNetworkSourceId;
sourceLayer: (typeof SOURCE_LAYERS)[WaterNetworkSourceId];
sourceLayer?: (typeof SOURCE_LAYERS)[keyof typeof SOURCE_LAYERS];
geometry: "line" | "point";
available: boolean;
label: string;
@@ -77,7 +78,7 @@ export const SUPPLY_LAYER_CATALOG = [
},
{
id: "scada",
sourceLayer: SOURCE_LAYERS.scada,
sourceLayer: undefined,
geometry: "point",
available: true,
label: "SCADA",
@@ -87,7 +88,7 @@ export const SUPPLY_LAYER_CATALOG = [
id: "pumps",
sourceLayer: SOURCE_LAYERS.pumps,
geometry: "point",
available: false,
available: true,
label: "水泵",
icon: "pump"
},
@@ -95,7 +96,7 @@ export const SUPPLY_LAYER_CATALOG = [
id: "tanks",
sourceLayer: SOURCE_LAYERS.tanks,
geometry: "point",
available: false,
available: true,
label: "水箱",
icon: "tank"
}
@@ -103,12 +104,14 @@ export const SUPPLY_LAYER_CATALOG = [
export const AVAILABLE_WATER_NETWORK_SOURCE_IDS = SUPPLY_LAYER_CATALOG.filter(
(layer) => layer.available
).map((layer) => layer.id) as Extract<
WaterNetworkSourceId,
"pipes" | "junctions" | "valves" | "reservoirs" | "scada"
>[];
).map((layer) => layer.id) as WaterNetworkSourceId[];
export type AvailableWaterNetworkSourceId = (typeof AVAILABLE_WATER_NETWORK_SOURCE_IDS)[number];
export type GeoServerWaterNetworkSourceId = Exclude<AvailableWaterNetworkSourceId, "scada">;
export const GEOSERVER_WATER_NETWORK_SOURCE_IDS = AVAILABLE_WATER_NETWORK_SOURCE_IDS.filter(
(sourceId): sourceId is GeoServerWaterNetworkSourceId => sourceId !== "scada"
);
export function isAvailableWaterNetworkSourceId(
value: string
@@ -118,11 +121,12 @@ export function isAvailableWaterNetworkSourceId(
export function createWaterNetworkSources() {
return Object.fromEntries(
SUPPLY_LAYER_CATALOG.filter((layer) => layer.available).map((layer) => [
layer.id,
createGeoServerVectorSource(layer.sourceLayer)
])
) as Record<AvailableWaterNetworkSourceId, VectorSourceSpecification>;
SUPPLY_LAYER_CATALOG.flatMap((layer) =>
layer.available && layer.sourceLayer !== undefined
? [[layer.id, createGeoServerVectorSource(layer.sourceLayer)] as const]
: []
)
) as Record<GeoServerWaterNetworkSourceId, VectorSourceSpecification>;
}
function createGeoServerVectorSource(
@@ -135,12 +139,18 @@ function createGeoServerVectorSource(
`${MAP_URL}/gwc/service/wmts/rest/${GEOSERVER_WORKSPACE}:${sourceLayer}/WebMercatorQuad/{z}/{y}/{x}?format=application/vnd.mapbox-vector-tile`
],
minzoom: 0,
maxzoom: 24
maxzoom: 24,
bounds: [...WATER_NETWORK_GLOBAL_VIEW.bbox4326]
};
}
export function createBaseStyle(mapboxToken?: string): StyleSpecification {
const sources: StyleSpecification["sources"] = {
[SCADA_SOURCE_ID]: {
type: "geojson",
promoteId: "device_id",
data: { type: "FeatureCollection", features: [] }
},
[SCADA_ANALYSIS_SOURCE_ID]: {
type: "geojson",
data: createEmptyScadaAnalysisCollection()
@@ -56,7 +56,7 @@ describe("WorkbenchMapController", () => {
await controller.highlight({ sourceId: "junctions", featureId: "J-1" });
const requestUrl = new URL(String(fetchMock.mock.calls[0]?.[0]));
expect(requestUrl.pathname).toBe("/geoserver/tjwater/ows");
expect(requestUrl.pathname).toBe("/geoserver/tjwater_next/ows");
expect(requestUrl.searchParams.get("request")).toBe("GetFeature");
expect(requestUrl.searchParams.get("cql_filter")).toBe("\"id\" IN ('J-1')");
expect(map.setFeatureState).toHaveBeenCalled();
@@ -81,7 +81,7 @@ describe("WorkbenchMapController", () => {
expect(layers.get(FLOW_LINE_LAYER_ID)).toMatchObject({
source: "pipes",
"source-layer": "geo_pipes_mat"
"source-layer": "pipes"
});
expect([...layers.keys()].some((id) => id.includes("arrows"))).toBe(false);
vi.unstubAllGlobals();
@@ -96,7 +96,7 @@ describe("WorkbenchMapController", () => {
expect(map.setFeatureState).toHaveBeenCalledWith(
expect.objectContaining({
source: "pipes",
sourceLayer: "geo_pipes_mat",
sourceLayer: "pipes",
id: "P-1"
}),
{ flowDirection: -1 }
@@ -251,13 +251,13 @@ describe("WorkbenchMapController", () => {
{
type: "Feature",
geometry: { type: "Point", coordinates: [120.7, 28] },
properties: { sensor_id: "MP01" }
properties: { device_id: "MP01" }
}
]
});
await expect(
controller.renderScadaAnalysis([{ sensor_id: "MP01", level: "high" }])
controller.renderScadaAnalysis([{ device_id: "MP01", level: "high" }])
).resolves.toMatchObject({
rendered_ids: ["MP01"],
fitted: true
@@ -47,6 +47,7 @@ export type WorkbenchMapErrorCode =
| "MAP_NOT_READY"
| "FEATURE_NOT_FOUND"
| "WFS_UNAVAILABLE"
| "SCADA_API_UNAVAILABLE"
| "SCADA_FEATURES_NOT_FOUND"
| "ACTION_SUPERSEDED"
| "FLOW_UNAVAILABLE"
@@ -88,6 +89,7 @@ export type WorkbenchMapControllerOptions = {
isReady: () => boolean;
getPadding: () => PaddingOptions;
fetchFeatures?: (sourceId: WaterNetworkSourceId, featureIds?: string[], signal?: AbortSignal) => Promise<FeatureCollection>;
getScadaFeatures?: (deviceIds?: string[], signal?: AbortSignal) => Promise<FeatureCollection>;
reducedMotion?: () => boolean;
};
@@ -320,16 +322,16 @@ export class WorkbenchMapController implements WorkbenchMapCommands {
try {
sourceCollection = await this.fetchFeatures(
"scada",
items.map((item) => item.sensor_id),
items.map((item) => item.device_id),
signal
);
} catch {
if (revision === this.scadaAnalysisRevision) {
this.patchState({ pending: false, errorCode: "WFS_UNAVAILABLE" });
this.patchState({ pending: false, errorCode: "SCADA_API_UNAVAILABLE" });
}
throw new Error(
revision === this.scadaAnalysisRevision
? "WFS_UNAVAILABLE"
? "SCADA_API_UNAVAILABLE"
: "ACTION_SUPERSEDED"
);
}
@@ -463,13 +465,20 @@ export class WorkbenchMapController implements WorkbenchMapCommands {
this.patchState({ pending: false });
return feature;
} catch {
this.patchState({ pending: false, errorCode: "WFS_UNAVAILABLE" });
this.patchState({
pending: false,
errorCode: target.sourceId === "scada" ? "SCADA_API_UNAVAILABLE" : "WFS_UNAVAILABLE"
});
return null;
}
}
private async fetchFeatures(sourceId: WaterNetworkSourceId, featureIds?: string[], signal?: AbortSignal) {
if (this.options.fetchFeatures) return this.options.fetchFeatures(sourceId, featureIds, signal);
if (sourceId === "scada") {
if (!this.options.getScadaFeatures) throw new Error("SCADA_API_UNAVAILABLE");
return this.options.getScadaFeatures(featureIds, signal);
}
const parsed = parseMapFeatureQuery({ sourceId, featureIds });
if (!parsed.ok) throw new Error(parsed.code);
const response = await fetch(createMapFeatureWfsUrl(parsed.value), {
@@ -516,7 +525,9 @@ function createEmptyPipeFlowSummary(): PipeFlowSummary {
}
function toFeatureStateTarget(target: FeatureTarget) {
return { source: target.sourceId, sourceLayer: SOURCE_LAYERS[target.sourceId], id: target.featureId };
return target.sourceId === "scada"
? { source: target.sourceId, id: target.featureId }
: { source: target.sourceId, sourceLayer: SOURCE_LAYERS[target.sourceId], id: target.featureId };
}
function collectCoordinates(geometry: Geometry | null): Position[] {