feat(workbench): align v2 GIS and SCADA API
This commit is contained in:
@@ -62,7 +62,7 @@ function createRequest(name: string) {
|
||||
toolCallId: "call-1",
|
||||
sessionId: "session-1",
|
||||
name,
|
||||
params: { items: [{ sensor_id: "MP01", level: "high" }] },
|
||||
params: { items: [{ device_id: "MP01", level: "high" }] },
|
||||
issuedAt: Date.now(),
|
||||
expiresAt: Date.now() + 15_000
|
||||
};
|
||||
|
||||
@@ -74,7 +74,7 @@ describe("toTrustedMapAction", () => {
|
||||
expect(toTrustedMapAction("locate_pipes", { pipe_ids: "P1,P2" })).toEqual({
|
||||
type: "locate_features",
|
||||
featureIds: ["P1", "P2"],
|
||||
layer: "geo_pipes_mat",
|
||||
layer: "pipes",
|
||||
fallbackText: undefined
|
||||
});
|
||||
});
|
||||
@@ -83,7 +83,7 @@ describe("toTrustedMapAction", () => {
|
||||
expect(toTrustedMapAction("locate_junctions", { junction_id: 42 })).toMatchObject({
|
||||
type: "locate_features",
|
||||
featureIds: ["42"],
|
||||
layer: "geo_junctions_mat"
|
||||
layer: "junctions"
|
||||
});
|
||||
});
|
||||
|
||||
@@ -97,7 +97,7 @@ describe("toTrustedMapAction", () => {
|
||||
expect(toTrustedMapAction("locate_scada", { scada_id: "S-1" })).toMatchObject({
|
||||
type: "locate_features",
|
||||
featureIds: ["S-1"],
|
||||
layer: "geo_scada"
|
||||
layer: "scada"
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -164,30 +164,21 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
}
|
||||
|
||||
const LEGACY_LOCATE_ACTION_LAYERS: Record<string, string> = {
|
||||
locate_junctions: "geo_junctions_mat",
|
||||
locate_pipes: "geo_pipes_mat",
|
||||
locate_valves: "geo_valves",
|
||||
locate_reservoirs: "geo_reservoirs",
|
||||
locate_scada: "geo_scada",
|
||||
locate_pumps: "geo_pumps",
|
||||
locate_tanks: "geo_tanks"
|
||||
locate_junctions: "junctions",
|
||||
locate_pipes: "pipes",
|
||||
locate_valves: "valves",
|
||||
locate_reservoirs: "reservoirs",
|
||||
locate_scada: "scada",
|
||||
locate_pumps: "pumps",
|
||||
locate_tanks: "tanks"
|
||||
};
|
||||
|
||||
const RESULT_REF_PATTERN = /^res-[A-Za-z0-9_-]{8,128}$/;
|
||||
const SUPPLY_SOURCE_IDS = ["junctions", "pipes", "valves", "reservoirs", "scada"];
|
||||
const SUPPLY_SOURCE_IDS = ["junctions", "pipes", "valves", "reservoirs", "scada", "pumps", "tanks"];
|
||||
const ALLOWED_LAYER_IDS = new Set(SUPPLY_SOURCE_IDS);
|
||||
const ALLOWED_LAYER_GROUP_IDS = new Set([...SUPPLY_SOURCE_IDS, "simulation"]);
|
||||
const ALLOWED_LOCATE_LAYERS = new Set([
|
||||
...SUPPLY_SOURCE_IDS,
|
||||
"pumps",
|
||||
"tanks",
|
||||
"geo_junctions_mat",
|
||||
"geo_pipes_mat",
|
||||
"geo_valves",
|
||||
"geo_reservoirs",
|
||||
"geo_scada",
|
||||
"geo_pumps",
|
||||
"geo_tanks"
|
||||
...SUPPLY_SOURCE_IDS
|
||||
]);
|
||||
const WEB_MERCATOR_RADIUS = 6378137;
|
||||
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { fetchScadaDeviceCollection } from "./scada-client";
|
||||
|
||||
const device = {
|
||||
device_id: "SCADA-1",
|
||||
device_type: "pressure",
|
||||
node_id: "J-1",
|
||||
link_id: null,
|
||||
api_query_id: "pressure-query",
|
||||
transmission_mode: "realtime",
|
||||
transmission_frequency: "2s",
|
||||
reliability: 100,
|
||||
x: 10,
|
||||
y: 20,
|
||||
longitude: 121.5,
|
||||
latitude: 30.9
|
||||
};
|
||||
|
||||
describe("SCADA API client", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("resolves the active project and builds GeoJSON keyed by device_id", async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({
|
||||
items: [{ project_id: "project-1", gs_workspace: "tjwater_next", status: "active" }],
|
||||
total: 1,
|
||||
limit: 1000,
|
||||
offset: 0
|
||||
}), { status: 200 }))
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({
|
||||
items: [device, { ...device, device_id: "SCADA-NO-LOCATION", longitude: null }],
|
||||
total: 2,
|
||||
limit: 1000,
|
||||
offset: 0
|
||||
}), { status: 200 }));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const getAccessToken = vi.fn().mockResolvedValue("access-token");
|
||||
|
||||
const collection = await fetchScadaDeviceCollection(getAccessToken);
|
||||
|
||||
expect(collection.features).toEqual([
|
||||
expect.objectContaining({
|
||||
id: "SCADA-1",
|
||||
geometry: { type: "Point", coordinates: [121.5, 30.9] },
|
||||
properties: expect.objectContaining({ device_id: "SCADA-1", node_id: "J-1" })
|
||||
})
|
||||
]);
|
||||
expect(getAccessToken).toHaveBeenCalledOnce();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
const projectHeaders = new Headers(fetchMock.mock.calls[0]?.[1]?.headers);
|
||||
const deviceHeaders = new Headers(fetchMock.mock.calls[1]?.[1]?.headers);
|
||||
expect(projectHeaders.get("Authorization")).toBe("Bearer access-token");
|
||||
expect(deviceHeaders.get("Authorization")).toBe("Bearer access-token");
|
||||
expect(deviceHeaders.get("X-Project-Id")).toBe("project-1");
|
||||
expect(String(fetchMock.mock.calls[1]?.[0])).toContain("/api/v1/scada-devices?limit=1000&offset=0");
|
||||
});
|
||||
|
||||
it("does not query devices when no active project matches the workspace", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({
|
||||
items: [{ project_id: "project-1", gs_workspace: "other", status: "active" }],
|
||||
total: 1,
|
||||
limit: 1000,
|
||||
offset: 0
|
||||
}), { status: 200 }));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await expect(fetchScadaDeviceCollection()).rejects.toThrow("未找到GeoServer工作空间");
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
import type { Feature, FeatureCollection, Point } from "geojson";
|
||||
import { z } from "zod";
|
||||
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
|
||||
import { env } from "@/shared/config/env";
|
||||
import { GEOSERVER_WORKSPACE } from "../map/geoserver-config";
|
||||
|
||||
const projectSchema = z.object({
|
||||
project_id: z.string().min(1),
|
||||
gs_workspace: z.string().min(1),
|
||||
status: z.string()
|
||||
});
|
||||
const projectPageSchema = z.object({
|
||||
items: z.array(projectSchema),
|
||||
total: z.number().int().nonnegative(),
|
||||
limit: z.number().int().positive(),
|
||||
offset: z.number().int().nonnegative()
|
||||
});
|
||||
|
||||
const scadaDeviceSchema = z.object({
|
||||
device_id: z.string().trim().min(1),
|
||||
device_type: z.string(),
|
||||
node_id: z.string().nullable(),
|
||||
link_id: z.string().nullable(),
|
||||
api_query_id: z.string().nullable(),
|
||||
transmission_mode: z.string(),
|
||||
transmission_frequency: z.string(),
|
||||
reliability: z.number().int().nullable(),
|
||||
x: z.number().nullable(),
|
||||
y: z.number().nullable(),
|
||||
longitude: z.number().nullable(),
|
||||
latitude: z.number().nullable()
|
||||
});
|
||||
|
||||
const scadaDevicePageSchema = z.object({
|
||||
items: z.array(scadaDeviceSchema),
|
||||
total: z.number().int().nonnegative(),
|
||||
limit: z.number().int().positive(),
|
||||
offset: z.number().int().nonnegative()
|
||||
});
|
||||
|
||||
export type ScadaDevice = z.infer<typeof scadaDeviceSchema>;
|
||||
|
||||
const SERVER_API_BASE_URL = env.TJWATER_SERVER_API_BASE_URL.replace(/\/$/, "");
|
||||
|
||||
export async function fetchScadaDeviceCollection(
|
||||
getAccessToken?: AccessTokenProvider,
|
||||
signal?: AbortSignal
|
||||
): Promise<FeatureCollection<Point, ScadaDevice>> {
|
||||
const accessToken = await getAccessToken?.();
|
||||
const projects = projectPageSchema.parse(
|
||||
await requestJson("/api/v1/projects?limit=1000&offset=0", accessToken, undefined, signal)
|
||||
);
|
||||
const project = projects.items.find(
|
||||
(item) => item.status === "active" && item.gs_workspace === GEOSERVER_WORKSPACE
|
||||
);
|
||||
if (!project) {
|
||||
throw new Error(`未找到GeoServer工作空间 ${GEOSERVER_WORKSPACE} 对应的项目`);
|
||||
}
|
||||
|
||||
const page = scadaDevicePageSchema.parse(
|
||||
await requestJson(
|
||||
"/api/v1/scada-devices?limit=1000&offset=0",
|
||||
accessToken,
|
||||
project.project_id,
|
||||
signal
|
||||
)
|
||||
);
|
||||
if (page.total > page.items.length) {
|
||||
throw new Error(`SCADA设备数量 ${page.total} 超过单次加载上限 ${page.items.length}`);
|
||||
}
|
||||
|
||||
return {
|
||||
type: "FeatureCollection",
|
||||
features: page.items.flatMap(toScadaFeature)
|
||||
};
|
||||
}
|
||||
|
||||
function toScadaFeature(device: ScadaDevice): Array<Feature<Point, ScadaDevice>> {
|
||||
if (device.longitude === null || device.latitude === null) return [];
|
||||
return [{
|
||||
type: "Feature",
|
||||
id: device.device_id,
|
||||
geometry: {
|
||||
type: "Point",
|
||||
coordinates: [device.longitude, device.latitude]
|
||||
},
|
||||
properties: device
|
||||
}];
|
||||
}
|
||||
|
||||
async function requestJson(
|
||||
path: string,
|
||||
accessToken: string | null | undefined,
|
||||
projectId: string | undefined,
|
||||
signal: AbortSignal | undefined
|
||||
) {
|
||||
const headers = new Headers({ Accept: "application/json" });
|
||||
if (accessToken) headers.set("Authorization", `Bearer ${accessToken}`);
|
||||
if (projectId) headers.set("X-Project-Id", projectId);
|
||||
const response = await fetch(`${SERVER_API_BASE_URL}${path}`, { headers, signal });
|
||||
const payload: unknown = await response.json().catch(() => null);
|
||||
if (!response.ok) {
|
||||
const detail = payload && typeof payload === "object" && "detail" in payload
|
||||
? String(payload.detail)
|
||||
: `HTTP ${response.status}`;
|
||||
throw new Error(`SCADA API请求失败:${detail}`);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
@@ -32,7 +32,9 @@ const GROUP_LABELS: Record<AvailableWaterNetworkSourceId, string> = {
|
||||
junctions: "节点",
|
||||
valves: "阀门",
|
||||
reservoirs: "水库",
|
||||
scada: "SCADA"
|
||||
scada: "SCADA",
|
||||
pumps: "水泵",
|
||||
tanks: "水箱"
|
||||
};
|
||||
|
||||
const STYLE_GROUP_IDS = Object.keys(LAYER_GROUP_GEOMETRIES) as LayerGroupId[];
|
||||
@@ -63,7 +65,7 @@ const LABEL_PRESETS: Record<
|
||||
unit: "m",
|
||||
options: [
|
||||
{ value: "elevation", label: "高程 elevation" },
|
||||
{ value: "demand", label: "需水量 demand" }
|
||||
{ value: "base_demand", label: "基础需水量 base_demand" }
|
||||
]
|
||||
},
|
||||
valves: {
|
||||
@@ -88,6 +90,9 @@ const ERROR_LABELS: Record<string, string> = {
|
||||
MAP_NOT_READY: "地图尚未就绪",
|
||||
FEATURE_NOT_FOUND: "目标要素不存在",
|
||||
WFS_UNAVAILABLE: "WFS 查询暂不可用",
|
||||
SCADA_API_UNAVAILABLE: "SCADA API 查询暂不可用",
|
||||
SCADA_FEATURES_NOT_FOUND: "未找到对应的 SCADA 设备",
|
||||
ACTION_SUPERSEDED: "SCADA 地图动作已被新请求替代",
|
||||
FLOW_UNAVAILABLE: "水流图层初始化失败",
|
||||
INVALID_FLOW_CALCULATION: "管道 ID 或有符号流速无效",
|
||||
INVALID_STYLE_PATCH: "参数不符合受控范围"
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
import { INTERACTIVE_HIT_LAYER_IDS } from "../map/layers";
|
||||
|
||||
describe("map feature interaction state", () => {
|
||||
const feature = { source: "junctions", sourceLayer: "geo_junctions_mat", id: "junction-7" } as const;
|
||||
const feature = { source: "junctions", sourceLayer: "junctions", id: "junction-7" } as const;
|
||||
|
||||
it("maps selected business features to promoted vector feature references", () => {
|
||||
expect(toMapFeatureReference({ id: "junction-7", layer: "junctions" })).toEqual(feature);
|
||||
@@ -18,7 +18,7 @@ describe("map feature interaction state", () => {
|
||||
const map = { setFeatureState: vi.fn(), removeFeatureState: vi.fn() };
|
||||
setMapFeatureInteractionState(map, feature, { selected: true, hovered: false });
|
||||
expect(map.setFeatureState).toHaveBeenCalledWith(
|
||||
{ source: "junctions", sourceLayer: "geo_junctions_mat", id: "junction-7" },
|
||||
{ source: "junctions", sourceLayer: "junctions", id: "junction-7" },
|
||||
{ selected: true, hovered: false }
|
||||
);
|
||||
});
|
||||
@@ -27,7 +27,7 @@ describe("map feature interaction state", () => {
|
||||
const map = { setFeatureState: vi.fn(), removeFeatureState: vi.fn() };
|
||||
clearMapFeatureInteractionState(map, feature, "selected");
|
||||
expect(map.removeFeatureState).toHaveBeenCalledWith(
|
||||
{ source: "junctions", sourceLayer: "geo_junctions_mat", id: "junction-7" },
|
||||
{ source: "junctions", sourceLayer: "junctions", id: "junction-7" },
|
||||
"selected"
|
||||
);
|
||||
});
|
||||
@@ -38,6 +38,8 @@ describe("map feature interaction state", () => {
|
||||
"junctions-hit",
|
||||
"valves-hit",
|
||||
"reservoirs-hit",
|
||||
"pumps-hit",
|
||||
"tanks-hit",
|
||||
"scada-hit"
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -12,7 +12,7 @@ import { SOURCE_LAYERS, type WaterNetworkSourceId } from "../map/sources";
|
||||
|
||||
export type MapFeatureInteractionState = {
|
||||
source: WaterNetworkSourceId;
|
||||
sourceLayer: string;
|
||||
sourceLayer?: string;
|
||||
id: string;
|
||||
hovered: boolean;
|
||||
selected: boolean;
|
||||
@@ -25,7 +25,9 @@ export function toMapFeatureReference(
|
||||
feature: Pick<DetailFeature, "id" | "layer"> | null
|
||||
): FeatureReference | null {
|
||||
if (!feature?.id) return null;
|
||||
return { source: feature.layer, sourceLayer: SOURCE_LAYERS[feature.layer], id: feature.id };
|
||||
return feature.layer === "scada"
|
||||
? { source: feature.layer, id: feature.id }
|
||||
: { source: feature.layer, sourceLayer: SOURCE_LAYERS[feature.layer], id: feature.id };
|
||||
}
|
||||
|
||||
export function setMapFeatureInteractionState(
|
||||
@@ -33,10 +35,7 @@ export function setMapFeatureInteractionState(
|
||||
feature: FeatureReference,
|
||||
state: Partial<Pick<MapFeatureInteractionState, "hovered" | "selected">>
|
||||
) {
|
||||
map.setFeatureState(
|
||||
{ source: feature.source, sourceLayer: feature.sourceLayer, id: feature.id },
|
||||
state
|
||||
);
|
||||
map.setFeatureState(toFeatureStateTarget(feature), state);
|
||||
}
|
||||
|
||||
export function clearMapFeatureInteractionState(
|
||||
@@ -44,10 +43,7 @@ export function clearMapFeatureInteractionState(
|
||||
feature: FeatureReference,
|
||||
key?: "hovered" | "selected"
|
||||
) {
|
||||
map.removeFeatureState(
|
||||
{ source: feature.source, sourceLayer: feature.sourceLayer, id: feature.id },
|
||||
key
|
||||
);
|
||||
map.removeFeatureState(toFeatureStateTarget(feature), key);
|
||||
}
|
||||
|
||||
type UseMapInteractionsOptions = {
|
||||
@@ -81,7 +77,9 @@ export function useMapInteractions({
|
||||
const id = feature && getFeatureId(feature);
|
||||
if (!source || !id) return;
|
||||
|
||||
const next = { source, sourceLayer: SOURCE_LAYERS[source], id };
|
||||
const next = source === "scada"
|
||||
? { source, id }
|
||||
: { source, sourceLayer: SOURCE_LAYERS[source], id };
|
||||
if (selectedFeature?.id === id && selectedFeature.layer === source) {
|
||||
clearHoveredFeature();
|
||||
return;
|
||||
@@ -127,3 +125,9 @@ export function useMapInteractions({
|
||||
};
|
||||
}, [mapReady, mapRef, selectedFeature]);
|
||||
}
|
||||
|
||||
function toFeatureStateTarget(feature: FeatureReference) {
|
||||
return feature.sourceLayer
|
||||
? { source: feature.source, sourceLayer: feature.sourceLayer, id: feature.id }
|
||||
: { source: feature.source, id: feature.id };
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import type { FeatureCollection, Point } from "geojson";
|
||||
import type { Map as MapLibreMap } from "maplibre-gl";
|
||||
import type { ScadaDevice } from "../api/scada-client";
|
||||
import { useEffect, useRef, useSyncExternalStore, type RefObject } from "react";
|
||||
import { getResponsiveWorkbenchPadding } from "../map/camera";
|
||||
import { WorkbenchMapController } from "../map/workbench-map-controller";
|
||||
@@ -9,7 +11,8 @@ export function useWorkbenchMapController({
|
||||
leftPanelOpen,
|
||||
rightPanelOpen,
|
||||
conditionPanelExpanded = false,
|
||||
agentPanelWidth
|
||||
agentPanelWidth,
|
||||
getScadaFeatures
|
||||
}: {
|
||||
mapRef: RefObject<MapLibreMap | null>;
|
||||
mapReady: boolean;
|
||||
@@ -17,6 +20,7 @@ export function useWorkbenchMapController({
|
||||
rightPanelOpen: boolean;
|
||||
conditionPanelExpanded?: boolean;
|
||||
agentPanelWidth?: number;
|
||||
getScadaFeatures?: (deviceIds?: string[], signal?: AbortSignal) => Promise<FeatureCollection<Point, ScadaDevice>>;
|
||||
}) {
|
||||
const valuesRef = useRef({
|
||||
mapReady,
|
||||
@@ -48,7 +52,8 @@ export function useWorkbenchMapController({
|
||||
valuesRef.current.agentPanelWidth
|
||||
)
|
||||
: { top: 48, right: 48, bottom: 48, left: 48 };
|
||||
}
|
||||
},
|
||||
getScadaFeatures
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import "maplibre-gl/dist/maplibre-gl.css";
|
||||
|
||||
import maplibregl, { type Map as MapLibreMap, type MapSourceDataEvent } from "maplibre-gl";
|
||||
import { useEffect, useRef, useState, type RefObject } from "react";
|
||||
import type { FeatureCollection, Point } from "geojson";
|
||||
import maplibregl, { type GeoJSONSource, type Map as MapLibreMap, type MapSourceDataEvent } from "maplibre-gl";
|
||||
import { useCallback, useEffect, useRef, useState, type RefObject } from "react";
|
||||
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
|
||||
import { env } from "@/shared/config/env";
|
||||
import { fetchScadaDeviceCollection, type ScadaDevice } from "../api/scada-client";
|
||||
import type { DetailFeature } from "../types";
|
||||
import {
|
||||
SIMULATION_SOURCE_IDS,
|
||||
@@ -20,11 +23,13 @@ import { createValueLabelLayers } from "../map/value-label";
|
||||
import { setSimulationLayersVisibility } from "../map/simulation-layers";
|
||||
import {
|
||||
AVAILABLE_WATER_NETWORK_SOURCE_IDS,
|
||||
GEOSERVER_WATER_NETWORK_SOURCE_IDS,
|
||||
createBaseStyle,
|
||||
createWaterNetworkSources,
|
||||
WATER_NETWORK_GLOBAL_VIEW
|
||||
} from "../map/sources";
|
||||
import {
|
||||
SCADA_SOURCE_ID,
|
||||
registerScadaImages,
|
||||
scadaBusinessLayers,
|
||||
scadaFallbackBusinessLayers,
|
||||
@@ -46,6 +51,7 @@ type UseWorkbenchMapOptions = {
|
||||
impactVisible: boolean;
|
||||
onSelectFeature: (feature: DetailFeature) => void;
|
||||
selectedFeature: DetailFeature | null;
|
||||
getAccessToken?: AccessTokenProvider;
|
||||
};
|
||||
|
||||
type UseWorkbenchMapResult = {
|
||||
@@ -53,6 +59,7 @@ type UseWorkbenchMapResult = {
|
||||
mapReady: boolean;
|
||||
mapError: string | null;
|
||||
sourceStatuses: WorkbenchSourceStatus[];
|
||||
getScadaFeatures: (deviceIds?: string[], signal?: AbortSignal) => Promise<FeatureCollection<Point, ScadaDevice>>;
|
||||
fitNetworkBounds: () => void;
|
||||
};
|
||||
|
||||
@@ -68,6 +75,7 @@ export type WorkbenchSourceStatus = {
|
||||
const SOURCE_STATUS_LABELS: Record<string, string> = {
|
||||
"mapbox-base": "Mapbox 底图",
|
||||
"geoserver-mvt": "GeoServer MVT",
|
||||
"scada-api": "SCADA API",
|
||||
"scada-icons": "SCADA 图标",
|
||||
"annotation-source": "业务标注源"
|
||||
};
|
||||
@@ -76,7 +84,7 @@ const SOURCE_GROUP_BY_ID: Record<string, string> = {
|
||||
"mapbox-light": "mapbox-base",
|
||||
"mapbox-satellite": "mapbox-base",
|
||||
...Object.fromEntries(
|
||||
AVAILABLE_WATER_NETWORK_SOURCE_IDS.map((sourceId) => [sourceId, "geoserver-mvt"])
|
||||
GEOSERVER_WATER_NETWORK_SOURCE_IDS.map((sourceId) => [sourceId, "geoserver-mvt"])
|
||||
),
|
||||
[SIMULATION_SOURCE_IDS.impactArea]: "annotation-source",
|
||||
[SIMULATION_SOURCE_IDS.annotations]: "annotation-source"
|
||||
@@ -86,13 +94,32 @@ export function useWorkbenchMap({
|
||||
containerRef,
|
||||
impactVisible,
|
||||
onSelectFeature,
|
||||
selectedFeature
|
||||
selectedFeature,
|
||||
getAccessToken
|
||||
}: UseWorkbenchMapOptions): UseWorkbenchMapResult {
|
||||
const mapRef = useRef<MapLibreMap | null>(null);
|
||||
const impactVisibleRef = useRef(impactVisible);
|
||||
const [mapReady, setMapReady] = useState(false);
|
||||
const [mapError, setMapError] = useState<string | null>(null);
|
||||
const [sourceStatuses, setSourceStatuses] = useState<Record<string, WorkbenchSourceStatus>>({});
|
||||
const scadaCollectionRef = useRef<FeatureCollection<Point, ScadaDevice>>({
|
||||
type: "FeatureCollection",
|
||||
features: []
|
||||
});
|
||||
const scadaLoadPromiseRef = useRef<Promise<FeatureCollection<Point, ScadaDevice>>>(
|
||||
Promise.resolve(scadaCollectionRef.current)
|
||||
);
|
||||
|
||||
const getScadaFeatures = useCallback(async (deviceIds?: string[], signal?: AbortSignal) => {
|
||||
const collection = await scadaLoadPromiseRef.current;
|
||||
if (signal?.aborted) throw new DOMException("The operation was aborted", "AbortError");
|
||||
if (!deviceIds) return collection;
|
||||
const selectedIds = new Set(deviceIds);
|
||||
return {
|
||||
type: "FeatureCollection" as const,
|
||||
features: collection.features.filter((feature) => selectedIds.has(String(feature.id)))
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
impactVisibleRef.current = impactVisible;
|
||||
@@ -104,6 +131,13 @@ export function useWorkbenchMap({
|
||||
}
|
||||
|
||||
const mapboxToken = env.TJWATER_MAPBOX_ACCESS_TOKEN || undefined;
|
||||
const scadaAbortController = new AbortController();
|
||||
updateSourceStatus(setSourceStatuses, "scada-api", "loading", "正在通过API加载SCADA设备。");
|
||||
const scadaLoadPromise = fetchScadaDeviceCollection(
|
||||
getAccessToken,
|
||||
scadaAbortController.signal
|
||||
);
|
||||
scadaLoadPromiseRef.current = scadaLoadPromise;
|
||||
const map = new maplibregl.Map({
|
||||
container: containerRef.current,
|
||||
style: createBaseStyle(mapboxToken),
|
||||
@@ -118,8 +152,11 @@ export function useWorkbenchMap({
|
||||
|
||||
map.on("load", async () => {
|
||||
map.resize();
|
||||
(map.getSource(SCADA_SOURCE_ID) as GeoJSONSource | undefined)?.setData(
|
||||
scadaCollectionRef.current
|
||||
);
|
||||
const sources = createWaterNetworkSources();
|
||||
AVAILABLE_WATER_NETWORK_SOURCE_IDS.forEach((sourceId) =>
|
||||
GEOSERVER_WATER_NETWORK_SOURCE_IDS.forEach((sourceId) =>
|
||||
map.addSource(sourceId, sources[sourceId])
|
||||
);
|
||||
map.addSource(SIMULATION_SOURCE_IDS.impactArea, simulationSources.impactArea);
|
||||
@@ -166,6 +203,25 @@ export function useWorkbenchMap({
|
||||
fitNetworkBounds(map, WATER_NETWORK_GLOBAL_VIEW);
|
||||
});
|
||||
|
||||
void scadaLoadPromise.then((collection) => {
|
||||
scadaCollectionRef.current = collection;
|
||||
(map.getSource(SCADA_SOURCE_ID) as GeoJSONSource | undefined)?.setData(collection);
|
||||
updateSourceStatus(
|
||||
setSourceStatuses,
|
||||
"scada-api",
|
||||
"online",
|
||||
`已通过API加载 ${collection.features.length} 个SCADA设备。`
|
||||
);
|
||||
}).catch((error) => {
|
||||
if (scadaAbortController.signal.aborted) return;
|
||||
updateSourceStatus(
|
||||
setSourceStatuses,
|
||||
"scada-api",
|
||||
"offline",
|
||||
error instanceof Error ? error.message : "SCADA API加载失败。"
|
||||
);
|
||||
});
|
||||
|
||||
let resizeFrame: number | null = null;
|
||||
const resizeMap = () => map.resize();
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
@@ -213,11 +269,12 @@ export function useWorkbenchMap({
|
||||
window.cancelAnimationFrame(resizeFrame);
|
||||
}
|
||||
resizeObserver.disconnect();
|
||||
scadaAbortController.abort();
|
||||
map.remove();
|
||||
mapRef.current = null;
|
||||
delete window.__waterNetworkMap;
|
||||
};
|
||||
}, [containerRef]);
|
||||
}, [containerRef, getAccessToken]);
|
||||
|
||||
useMapInteractions({
|
||||
mapRef,
|
||||
@@ -244,6 +301,7 @@ export function useWorkbenchMap({
|
||||
mapReady,
|
||||
mapError,
|
||||
sourceStatuses: Object.values(sourceStatuses),
|
||||
getScadaFeatures,
|
||||
fitNetworkBounds: fitToNetworkBounds
|
||||
};
|
||||
}
|
||||
@@ -338,6 +396,12 @@ function getSourceStatusMessage(sourceGroupId: string, status: WorkbenchSourceSt
|
||||
: "业务标注源加载中断,模拟标注可能暂不可见。";
|
||||
}
|
||||
|
||||
if (sourceGroupId === "scada-api") {
|
||||
return status === "online"
|
||||
? "SCADA设备已通过API加载。"
|
||||
: "SCADA API请求中断,管网图层仍可继续使用。";
|
||||
}
|
||||
|
||||
return status === "online" ? "地图数据源已恢复正常。" : "地图数据源请求中断。";
|
||||
}
|
||||
|
||||
@@ -354,5 +418,9 @@ function getSourceErrorMessage(sourceGroupId: string, errorMessage: string) {
|
||||
return `业务标注源请求失败:${errorMessage}`;
|
||||
}
|
||||
|
||||
if (sourceGroupId === "scada-api") {
|
||||
return `SCADA API请求失败:${errorMessage}`;
|
||||
}
|
||||
|
||||
return errorMessage;
|
||||
}
|
||||
|
||||
@@ -259,11 +259,12 @@ export function MapWorkbenchPage({
|
||||
});
|
||||
}
|
||||
|
||||
const { mapRef, mapReady, mapError, sourceStatuses, fitNetworkBounds } = useWorkbenchMap({
|
||||
const { mapRef, mapReady, mapError, sourceStatuses, fitNetworkBounds, getScadaFeatures } = useWorkbenchMap({
|
||||
containerRef: mapContainerRef,
|
||||
impactVisible,
|
||||
onSelectFeature: handleSelectFeature,
|
||||
selectedFeature: detailFeature
|
||||
selectedFeature: detailFeature,
|
||||
getAccessToken
|
||||
});
|
||||
const { controller: mapController, state: mapControllerState } = useWorkbenchMapController({
|
||||
mapRef,
|
||||
@@ -271,7 +272,8 @@ export function MapWorkbenchPage({
|
||||
leftPanelOpen: false,
|
||||
rightPanelOpen,
|
||||
conditionPanelExpanded: rightPanelExpanded,
|
||||
agentPanelWidth
|
||||
agentPanelWidth,
|
||||
getScadaFeatures
|
||||
});
|
||||
|
||||
const activeAgentUiResults = useMemo(
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -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" } }
|
||||
]
|
||||
};
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 }
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
@@ -8,8 +8,8 @@ import {
|
||||
|
||||
describe("feature panel properties", () => {
|
||||
it.each([
|
||||
["pipes", ["id", "diameter", "length", "material"]],
|
||||
["junctions", ["id", "elevation", "demand", "pressure"]]
|
||||
["pipes", ["id", "start_node_id", "end_node_id", "diameter", "length", "roughness", "status"]],
|
||||
["junctions", ["id", "elevation", "base_demand"]]
|
||||
] as const)("shows the configured %s fields", (layer, expectedKeys) => {
|
||||
const entries = getFeaturePanelProperties(layer, {}, "feature-id");
|
||||
|
||||
@@ -20,16 +20,22 @@ describe("feature panel properties", () => {
|
||||
it("formats pipe diameter from GeoServer DN millimeter values", () => {
|
||||
const entries = getFeaturePanelProperties("pipes", {
|
||||
id: "P-1",
|
||||
start_node_id: "J-1",
|
||||
end_node_id: "J-2",
|
||||
diameter: 600,
|
||||
length: 42.5,
|
||||
material: "DI"
|
||||
roughness: 120,
|
||||
status: "OPEN"
|
||||
});
|
||||
|
||||
expect(entries.map(({ label, value }) => [label, value])).toEqual([
|
||||
["编号", "P-1"],
|
||||
["起点节点", "J-1"],
|
||||
["终点节点", "J-2"],
|
||||
["管径", "600 mm"],
|
||||
["长度", "42.50 m"],
|
||||
["材质", "球墨铸铁"]
|
||||
["粗糙系数", "120"],
|
||||
["状态", "开启"]
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -37,14 +43,14 @@ describe("feature panel properties", () => {
|
||||
const metrics = getFeatureInsightMetrics("pipes", {
|
||||
diameter: 600,
|
||||
length: 42.5,
|
||||
material: "DI",
|
||||
roughness: 120,
|
||||
status: "OPEN"
|
||||
});
|
||||
|
||||
expect(metrics).toEqual([
|
||||
{ label: "管径", value: "600 mm" },
|
||||
{ label: "长度", value: "42.50 m" },
|
||||
{ label: "材质", value: "球墨铸铁" },
|
||||
{ label: "粗糙系数", value: "120" },
|
||||
{ label: "状态", value: "开启" }
|
||||
]);
|
||||
});
|
||||
@@ -52,56 +58,62 @@ describe("feature panel properties", () => {
|
||||
it("localizes common enumerated property values in the panel", () => {
|
||||
expect(getFeaturePanelProperties("valves", {
|
||||
id: "V-1",
|
||||
node1: "J-1",
|
||||
node2: "J-2",
|
||||
start_node_id: "J-1",
|
||||
end_node_id: "J-2",
|
||||
diameter: 300,
|
||||
v_type: "PRV",
|
||||
setting: "OPEN"
|
||||
valve_type: "PRV",
|
||||
setting: "OPEN",
|
||||
minor_loss: 0.2
|
||||
}).map(({ label, value }) => [label, value])).toEqual([
|
||||
["编号", "V-1"],
|
||||
["起点节点", "J-1"],
|
||||
["终点节点", "J-2"],
|
||||
["管径", "300 mm"],
|
||||
["阀门类型", "减压阀"],
|
||||
["阀门设定", "开启"]
|
||||
["阀门设定", "开启"],
|
||||
["局部损失系数", "0.20"]
|
||||
]);
|
||||
|
||||
expect(getFeaturePanelProperties("scada", {
|
||||
id: "S-1",
|
||||
type: "pressure",
|
||||
associated_element_id: "J-1",
|
||||
device_id: "S-1",
|
||||
device_type: "pressure",
|
||||
node_id: "J-1",
|
||||
link_id: null,
|
||||
api_query_id: "query-1",
|
||||
transmission_mode: "non_realtime",
|
||||
transmission_frequency: "15min",
|
||||
reliability: "high"
|
||||
}).map(({ label, value }) => [label, value])).toEqual([
|
||||
["编号", "S-1"],
|
||||
["类型", "压力监测"],
|
||||
["关联资产", "J-1"],
|
||||
["设备 ID", "S-1"],
|
||||
["设备类型", "压力监测"],
|
||||
["关联节点", "J-1"],
|
||||
["关联连线", "暂无"],
|
||||
["采集接口", "query-1"],
|
||||
["传输方式", "非实时"],
|
||||
["传输频率", "15min"],
|
||||
["可靠性", "高"]
|
||||
]);
|
||||
|
||||
expect(getFeaturePanelProperties("scada", {
|
||||
id: "S-2",
|
||||
type: "pipe_flow"
|
||||
}).find((entry) => entry.key === "type")?.value).toBe("流量监测");
|
||||
device_id: "S-2",
|
||||
device_type: "pipe_flow"
|
||||
}).find((entry) => entry.key === "device_type")?.value).toBe("流量监测");
|
||||
});
|
||||
|
||||
it("uses the map feature id when the properties omit it", () => {
|
||||
const model = getFeaturePanelModel("junctions", { demand: 3.4 }, "map-feature-id");
|
||||
const model = getFeaturePanelModel("junctions", { base_demand: 3.4 }, "map-feature-id");
|
||||
|
||||
expect(model.id).toBe("map-feature-id");
|
||||
expect(model.badge).toBeUndefined();
|
||||
expect(model.attributes.map((entry) => entry.key)).toEqual(["elevation", "demand", "pressure"]);
|
||||
expect(model.attributes.map((entry) => entry.key)).toEqual(["elevation", "base_demand"]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["valves", ["id", "node1", "node2", "diameter", "v_type", "setting"]],
|
||||
["reservoirs", ["id", "head", "pattern"]],
|
||||
["scada", ["id", "type", "associated_element_id", "transmission_mode", "transmission_frequency", "reliability"]],
|
||||
["pumps", ["id", "node1", "node2", "status"]],
|
||||
["tanks", ["id", "elevation", "init_level", "min_level", "max_level"]]
|
||||
["valves", ["id", "start_node_id", "end_node_id", "diameter", "valve_type", "setting", "minor_loss"]],
|
||||
["reservoirs", ["id", "head", "pattern_id"]],
|
||||
["scada", ["device_id", "device_type", "node_id", "link_id", "api_query_id", "transmission_mode", "transmission_frequency", "reliability"]],
|
||||
["pumps", ["id", "start_node_id", "end_node_id", "power", "head_curve_id", "speed", "pattern_id"]],
|
||||
["tanks", ["id", "elevation", "initial_level", "minimum_level", "maximum_level", "diameter", "minimum_volume", "volume_curve_id", "overflow"]]
|
||||
] as const)("localizes configured %s panel field labels", (layer, keys) => {
|
||||
const entries = getFeaturePanelProperties(layer, {}, "feature-id");
|
||||
|
||||
@@ -112,7 +124,7 @@ describe("feature panel properties", () => {
|
||||
label: getFeaturePropertyLabel(entry.key)
|
||||
}))
|
||||
);
|
||||
expect(entries.filter((entry) => entry.key !== "id").map((entry) => entry.label))
|
||||
expect(entries.filter((entry) => entry.key !== "id" && entry.key !== "device_id").map((entry) => entry.label))
|
||||
.not.toContainEqual(expect.stringMatching(/^[a-z][a-z0-9_]*$/));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -29,8 +29,6 @@ const PROPERTY_LABELS: Record<string, string> = {
|
||||
fid: "GeoServer 要素 ID",
|
||||
id: "编号",
|
||||
scada_id: "编号 UUID",
|
||||
sensor_id: "传感器 ID",
|
||||
sensor_name: "传感器名称",
|
||||
swmm_node: "SWMM 节点",
|
||||
topology_order: "拓扑序",
|
||||
distance_to_wwtp_m: "距处理厂距离",
|
||||
@@ -48,8 +46,11 @@ const PROPERTY_LABELS: Record<string, string> = {
|
||||
name: "名称",
|
||||
code: "编码",
|
||||
type: "类型",
|
||||
device_type: "设备类型",
|
||||
node1: "起点节点",
|
||||
node2: "终点节点",
|
||||
start_node_id: "起点节点",
|
||||
end_node_id: "终点节点",
|
||||
from_node: "起点节点",
|
||||
to_node: "终点节点",
|
||||
diameter: "管径",
|
||||
@@ -60,6 +61,7 @@ const PROPERTY_LABELS: Record<string, string> = {
|
||||
max_depth: "最大深度",
|
||||
invert_elevation: "井底高程",
|
||||
v_type: "阀门类型",
|
||||
valve_type: "阀门类型",
|
||||
setting: "阀门设定",
|
||||
minor_loss: "局部损失系数",
|
||||
orifice_type: "孔口类型",
|
||||
@@ -76,12 +78,24 @@ const PROPERTY_LABELS: Record<string, string> = {
|
||||
status: "状态",
|
||||
head: "水头",
|
||||
pattern: "模式",
|
||||
pattern_id: "模式",
|
||||
head_curve_id: "扬程曲线",
|
||||
volume_curve_id: "容积曲线",
|
||||
init_level: "初始水位",
|
||||
min_level: "最低水位",
|
||||
max_level: "最高水位",
|
||||
initial_level: "初始水位",
|
||||
minimum_level: "最低水位",
|
||||
maximum_level: "最高水位",
|
||||
minimum_volume: "最小容积",
|
||||
overflow: "允许溢流",
|
||||
power: "功率",
|
||||
speed: "转速倍率",
|
||||
material: "材质",
|
||||
elevation: "高程",
|
||||
demand: "需水量",
|
||||
base_demand: "基础需水量",
|
||||
demands: "需水配置",
|
||||
pressure: "压力",
|
||||
flow: "流量",
|
||||
velocity: "流速",
|
||||
@@ -93,6 +107,9 @@ const PROPERTY_LABELS: Record<string, string> = {
|
||||
point_external_id: "测点编号",
|
||||
point_name: "测点名称",
|
||||
associated_element_id: "关联资产",
|
||||
node_id: "关联节点",
|
||||
link_id: "关联连线",
|
||||
api_query_id: "采集接口",
|
||||
transmission_mode: "传输方式",
|
||||
transmission_frequency: "传输频率",
|
||||
reliability: "可靠性",
|
||||
@@ -114,8 +131,6 @@ const PROPERTY_ORDER: Record<string, number> = {
|
||||
fid: 9,
|
||||
id: 10,
|
||||
scada_id: 10,
|
||||
sensor_id: 10,
|
||||
sensor_name: 12,
|
||||
swmm_node: 13,
|
||||
topology_order: 14,
|
||||
distance_to_wwtp_m: 15,
|
||||
@@ -143,10 +158,13 @@ const PROPERTY_ORDER: Record<string, number> = {
|
||||
material: 23,
|
||||
node1: 24,
|
||||
from_node: 24,
|
||||
start_node_id: 24,
|
||||
node2: 25,
|
||||
to_node: 25,
|
||||
end_node_id: 25,
|
||||
elevation: 30,
|
||||
demand: 31,
|
||||
base_demand: 31,
|
||||
pressure: 32,
|
||||
flow: 33,
|
||||
velocity: 34,
|
||||
@@ -275,13 +293,13 @@ const PROPERTY_UNITS: Record<string, string> = {
|
||||
};
|
||||
|
||||
const FEATURE_PANEL_PROPERTY_KEYS: Record<WaterNetworkSourceId, readonly string[]> = {
|
||||
pipes: ["id", "diameter", "length", "material"],
|
||||
junctions: ["id", "elevation", "demand", "pressure"],
|
||||
valves: ["id", "node1", "node2", "diameter", "v_type", "setting"],
|
||||
reservoirs: ["id", "head", "pattern"],
|
||||
scada: ["id", "type", "associated_element_id", "transmission_mode", "transmission_frequency", "reliability"],
|
||||
pumps: ["id", "node1", "node2", "status"],
|
||||
tanks: ["id", "elevation", "init_level", "min_level", "max_level"]
|
||||
pipes: ["id", "start_node_id", "end_node_id", "diameter", "length", "roughness", "status"],
|
||||
junctions: ["id", "elevation", "base_demand"],
|
||||
valves: ["id", "start_node_id", "end_node_id", "diameter", "valve_type", "setting", "minor_loss"],
|
||||
reservoirs: ["id", "head", "pattern_id"],
|
||||
scada: ["device_id", "device_type", "node_id", "link_id", "api_query_id", "transmission_mode", "transmission_frequency", "reliability"],
|
||||
pumps: ["id", "start_node_id", "end_node_id", "power", "head_curve_id", "speed", "pattern_id"],
|
||||
tanks: ["id", "elevation", "initial_level", "minimum_level", "maximum_level", "diameter", "minimum_volume", "volume_curve_id", "overflow"]
|
||||
};
|
||||
|
||||
const FEATURE_PANEL_BADGE_KEYS: Partial<Record<WaterNetworkSourceId, string>> = {
|
||||
@@ -299,42 +317,44 @@ const FEATURE_INSIGHT_METRICS: Record<WaterNetworkSourceId, readonly FeatureInsi
|
||||
pipes: [
|
||||
{ label: "管径", key: "diameter" },
|
||||
{ label: "长度", key: "length" },
|
||||
{ label: "材质", key: "material" },
|
||||
{ label: "粗糙系数", key: "roughness" },
|
||||
{ label: "状态", key: "status" }
|
||||
],
|
||||
junctions: [
|
||||
{ label: "需水量", key: "demand" },
|
||||
{ label: "基础需水量", key: "base_demand" },
|
||||
{ label: "高程", key: "elevation" },
|
||||
{ label: "压力", key: "pressure" },
|
||||
{ label: "状态", value: "在线" }
|
||||
{ label: "X 坐标", key: "x" },
|
||||
{ label: "Y 坐标", key: "y" }
|
||||
],
|
||||
valves: [
|
||||
{ label: "管径", key: "diameter" },
|
||||
{ label: "类型", key: "v_type" },
|
||||
{ label: "类型", key: "valve_type" },
|
||||
{ label: "设定", key: "setting" },
|
||||
{ label: "局部损失", key: "minor_loss" }
|
||||
],
|
||||
reservoirs: [
|
||||
{ label: "水头", key: "head" },
|
||||
{ label: "模式", key: "pattern" },
|
||||
{ label: "状态", value: "在线" }
|
||||
{ label: "模式", key: "pattern_id" },
|
||||
{ label: "X 坐标", key: "x" },
|
||||
{ label: "Y 坐标", key: "y" }
|
||||
],
|
||||
scada: [
|
||||
{ label: "类型", key: "type" },
|
||||
{ label: "关联资产", key: "associated_element_id" },
|
||||
{ label: "传输方式", key: "transmission_mode" },
|
||||
{ label: "可靠性", key: "reliability" }
|
||||
{ label: "类型", key: "device_type" },
|
||||
{ label: "关联节点", key: "node_id" },
|
||||
{ label: "关联连线", key: "link_id" },
|
||||
{ label: "传输方式", key: "transmission_mode" }
|
||||
],
|
||||
pumps: [
|
||||
{ label: "起点", key: "node1" },
|
||||
{ label: "终点", key: "node2" },
|
||||
{ label: "状态", key: "status" }
|
||||
{ label: "功率", key: "power" },
|
||||
{ label: "扬程曲线", key: "head_curve_id" },
|
||||
{ label: "转速倍率", key: "speed" },
|
||||
{ label: "模式", key: "pattern_id" }
|
||||
],
|
||||
tanks: [
|
||||
{ label: "高程", key: "elevation" },
|
||||
{ label: "初始水位", key: "init_level" },
|
||||
{ label: "最高水位", key: "max_level" },
|
||||
{ label: "状态", value: "在线" }
|
||||
{ label: "初始水位", key: "initial_level" },
|
||||
{ label: "最低水位", key: "minimum_level" },
|
||||
{ label: "最高水位", key: "maximum_level" }
|
||||
]
|
||||
};
|
||||
|
||||
@@ -353,7 +373,7 @@ export function getFeaturePanelProperties(
|
||||
): LocalizedFeatureProperty[] {
|
||||
return FEATURE_PANEL_PROPERTY_KEYS[layer].map((key) => {
|
||||
const propertyValue = getPropertyValue(properties, key);
|
||||
const value = key === "id" || key === "scada_id" || key === "sensor_id" ? propertyValue ?? featureId : propertyValue;
|
||||
const value = key === "id" || key === "scada_id" || key === "device_id" ? propertyValue ?? featureId : propertyValue;
|
||||
|
||||
return {
|
||||
key,
|
||||
@@ -391,7 +411,7 @@ export function getFeaturePanelModel(
|
||||
featureId?: string
|
||||
): FeaturePanelModel {
|
||||
const entries = getFeaturePanelProperties(layer, properties, featureId);
|
||||
const idKey = "id";
|
||||
const idKey = layer === "scada" ? "device_id" : "id";
|
||||
const id = entries.find((entry) => entry.key === idKey)?.value ?? "暂无";
|
||||
const badgeKey = FEATURE_PANEL_BADGE_KEYS[layer];
|
||||
const badgeEntry = badgeKey ? entries.find((entry) => entry.key === badgeKey) : undefined;
|
||||
@@ -420,7 +440,7 @@ export function formatFeaturePropertyValue(key: string, value: unknown) {
|
||||
return OUTFALL_TYPE_LABELS[value.toLowerCase()] ?? value;
|
||||
}
|
||||
|
||||
if (normalizedKey === "gated" && value !== null && value !== undefined && value !== "") {
|
||||
if (["gated", "overflow"].includes(normalizedKey) && value !== null && value !== undefined && value !== "") {
|
||||
return GATED_LABELS[String(value).toLowerCase()] ?? formatValue(value);
|
||||
}
|
||||
|
||||
@@ -452,7 +472,7 @@ export function formatFeaturePropertyValue(key: string, value: unknown) {
|
||||
: formatValue(value);
|
||||
}
|
||||
|
||||
if (["length", "elevation", "invert_elevation", "max_depth", "startup_depth", "shutoff_depth", "distance_to_wwtp_m"].includes(normalizedKey)
|
||||
if (["length", "elevation", "invert_elevation", "max_depth", "startup_depth", "shutoff_depth", "distance_to_wwtp_m", "initial_level", "minimum_level", "maximum_level"].includes(normalizedKey)
|
||||
&& value !== null && value !== undefined && value !== "") {
|
||||
return `${formatValue(value)} m`;
|
||||
}
|
||||
@@ -485,7 +505,7 @@ function getMappedPropertyValue(normalizedKey: string, value: string) {
|
||||
return STATUS_LABELS[normalizedValue];
|
||||
}
|
||||
|
||||
if (normalizedKey === "v_type") {
|
||||
if (normalizedKey === "v_type" || normalizedKey === "valve_type") {
|
||||
return VALVE_TYPE_LABELS[normalizedValue];
|
||||
}
|
||||
|
||||
@@ -493,7 +513,7 @@ function getMappedPropertyValue(normalizedKey: string, value: string) {
|
||||
return MATERIAL_LABELS[normalizedValue];
|
||||
}
|
||||
|
||||
if (normalizedKey === "type") {
|
||||
if (normalizedKey === "type" || normalizedKey === "device_type") {
|
||||
return ASSET_TYPE_LABELS[normalizedValue];
|
||||
}
|
||||
|
||||
@@ -505,7 +525,7 @@ function getMappedPropertyValue(normalizedKey: string, value: string) {
|
||||
return RELIABILITY_LABELS[normalizedValue];
|
||||
}
|
||||
|
||||
if (normalizedKey === "pattern") {
|
||||
if (normalizedKey === "pattern" || normalizedKey === "pattern_id") {
|
||||
return PATTERN_LABELS[normalizedValue];
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user