feat(workbench): integrate runtime data and map tooling

This commit is contained in:
2026-09-22 10:45:46 +08:00
parent bf44cc0f25
commit bebcf2a06c
163 changed files with 15642 additions and 1312 deletions
@@ -4,9 +4,15 @@ import { MAP_STYLE_TOKENS } from "./map-colors";
export const SIMULATION_SOURCE_IDS = {
impactArea: "simulation-impact-area",
annotations: "simulation-annotations"
annotations: "simulation-annotations",
backendAnalysis: "simulation-backend-analysis"
} as const;
const EMPTY_FEATURE_COLLECTION = {
type: "FeatureCollection" as const,
features: []
};
export const simulationSources = {
impactArea: {
type: "geojson",
@@ -15,6 +21,10 @@ export const simulationSources = {
annotations: {
type: "geojson",
data: annotationPoints
} satisfies GeoJSONSourceSpecification,
backendAnalysis: {
type: "geojson",
data: EMPTY_FEATURE_COLLECTION
} satisfies GeoJSONSourceSpecification
};
@@ -39,6 +49,39 @@ export const simulationAnnotationLayers: StyleSpecification["layers"] = [
"line-opacity": 0.72
}
},
{
id: "simulation-backend-analysis-fill",
type: "fill",
source: SIMULATION_SOURCE_IDS.backendAnalysis,
filter: ["==", ["geometry-type"], "Polygon"],
paint: {
"fill-color": MAP_STYLE_TOKENS.state.agent,
"fill-opacity": 0.2
}
},
{
id: "simulation-backend-analysis-line",
type: "line",
source: SIMULATION_SOURCE_IDS.backendAnalysis,
filter: ["==", ["geometry-type"], "LineString"],
paint: {
"line-color": MAP_STYLE_TOKENS.state.agent,
"line-width": 3,
"line-opacity": 0.9
}
},
{
id: "simulation-backend-analysis-point",
type: "circle",
source: SIMULATION_SOURCE_IDS.backendAnalysis,
filter: ["==", ["geometry-type"], "Point"],
paint: {
"circle-color": MAP_STYLE_TOKENS.state.agent,
"circle-radius": 7,
"circle-stroke-color": MAP_STYLE_TOKENS.canvas.casing,
"circle-stroke-width": 2
}
},
{
id: "simulation-burst-halo",
type: "circle",
@@ -116,13 +159,31 @@ export const simulationAnnotationLayers: StyleSpecification["layers"] = [
"text-halo-width": 3
}
},
{
id: "simulation-backend-analysis-label",
type: "symbol",
source: SIMULATION_SOURCE_IDS.backendAnalysis,
layout: {
"text-field": ["coalesce", ["get", "label"], ["get", "name"], "后端分析结果"],
"text-size": 12,
"text-font": ["Noto Sans Regular"],
"text-offset": [0, 1.2],
"text-anchor": "top",
"text-allow-overlap": false
},
paint: {
"text-color": MAP_STYLE_TOKENS.label.primary,
"text-halo-color": MAP_STYLE_TOKENS.canvas.casing,
"text-halo-width": 3
}
},
{
id: "simulation-burst-label",
type: "symbol",
source: SIMULATION_SOURCE_IDS.annotations,
filter: ["==", ["get", "kind"], "burst"],
layout: {
"text-field": "爆管位置\nDN600 给水管线\n压力:0.18 MPa\n时间:09:00",
"text-field": "演示爆管位置\nDN600 给水管线\n压力:0.18 MPa\n时间:09:00",
"text-size": 12,
"text-font": ["Noto Sans Regular"],
"text-offset": [5.2, -3.4],
@@ -0,0 +1,35 @@
import type { MapGeoJSONFeature } from "maplibre-gl";
import { describe, expect, it } from "vitest";
import { getWaterNetworkSourceId, toDetailFeature } from "./feature-adapter";
describe("feature adapter", () => {
it("classifies API-backed SCADA features by their canonical source id", () => {
const feature = {
id: "SCADA-1",
source: "scada",
properties: {
device_id: "SCADA-1",
device_type: "pressure",
node_id: "J-1"
}
} as unknown as MapGeoJSONFeature;
expect(getWaterNetworkSourceId(feature)).toBe("scada");
expect(toDetailFeature(feature)).toMatchObject({
id: "SCADA-1",
layer: "scada",
title: "SCADA SCADA-1"
});
});
it("continues to classify vector features by source layer", () => {
const feature = {
id: "J-1",
source: "water-network",
sourceLayer: "junctions",
properties: { id: "J-1" }
} as unknown as MapGeoJSONFeature;
expect(getWaterNetworkSourceId(feature)).toBe("junctions");
});
});
+25 -1
View File
@@ -3,6 +3,8 @@ import type { DetailFeature } from "../types";
import { formatValue } from "../utils/format-value";
import {
SOURCE_LAYERS,
WATER_NETWORK_SOURCE_IDS,
isSourceGisSourceId,
type GeoServerWaterNetworkSourceId,
type WaterNetworkSourceId
} from "./sources";
@@ -13,8 +15,12 @@ export function getFeatureId(feature: MapGeoJSONFeature) {
}
export function getWaterNetworkSourceId(feature: MapGeoJSONFeature): WaterNetworkSourceId | null {
if ((WATER_NETWORK_SOURCE_IDS as readonly string[]).includes(feature.source)) {
return feature.source as WaterNetworkSourceId;
}
for (const [sourceId, sourceLayer] of Object.entries(SOURCE_LAYERS) as [GeoServerWaterNetworkSourceId, string][]) {
if (feature.source === sourceId || feature.sourceLayer === sourceLayer) {
if (feature.sourceLayer === sourceLayer) {
return sourceId;
}
}
@@ -37,6 +43,24 @@ export function toDetailFeature(feature: MapGeoJSONFeature): DetailFeature {
}
function getFeatureLabels(sourceId: WaterNetworkSourceId, id: string, properties: Record<string, unknown>) {
if (sourceId === "water_quality_monitoring_points") {
return {
title: `水质监测点 ${formatValue(properties.monitoring_point_no)}`,
subtitle: `节点 ${formatValue(properties.node_id ?? id)} · 灵敏度分析选点`
};
}
if (isSourceGisSourceId(sourceId)) {
const sourceName = formatValue(properties.source_layer_name);
const sourceIdLabel = formatValue(properties.source_object_id ?? properties.source_fid ?? id);
return {
title: `${sourceName} ${sourceIdLabel}`,
subtitle: sourceId === "fx_20200423_pipes" || sourceId === "fx_20200811_dn100_pipes"
? `管径 ${formatValue(properties.diameter)} · 长度 ${formatValue(properties.length)} m`
: `${formatValue(properties.dataset_title)} · 源要素 ${formatValue(properties.source_fid)}`
};
}
if (sourceId === "pipes") {
return {
title: `管线 ${id || "未命名"}`,
@@ -1,5 +1,6 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
DATA_DEPENDENT_GEOSERVER_SOURCE_IDS,
createGeoServerLayerHitsUrl,
fetchGeoServerLayerFeatureCount,
resolveGeoServerLayerAvailability
@@ -38,18 +39,22 @@ describe("GeoServer data-dependent layer availability", () => {
});
it("keeps only optional layers that contain features", async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(new Response('<wfs:FeatureCollection numberMatched="0" />'))
.mockResolvedValueOnce(new Response('<wfs:FeatureCollection numberMatched="0" />'))
.mockResolvedValueOnce(new Response('<wfs:FeatureCollection numberMatched="3" />'))
.mockResolvedValueOnce(new Response('<wfs:FeatureCollection numberMatched="0" />'));
const fetchMock = vi.fn((request: RequestInfo | URL) => {
const url = new URL(String(request));
const typeName = url.searchParams.get("typeNames");
return Promise.resolve(new Response(
`<wfs:FeatureCollection numberMatched="${typeName === "tjwater_next:pumps" ? 3 : 0}" />`
));
});
vi.stubGlobal("fetch", fetchMock);
await expect(resolveGeoServerLayerAvailability()).resolves.toEqual({
availableSourceIds: ["pipes", "junctions", "pumps"],
emptySourceIds: ["valves", "reservoirs", "tanks"],
failedSourceIds: []
emptySourceIds: DATA_DEPENDENT_GEOSERVER_SOURCE_IDS.filter((sourceId) => sourceId !== "pumps"),
failedSourceIds: [],
featureCounts: Object.fromEntries(
DATA_DEPENDENT_GEOSERVER_SOURCE_IDS.map((sourceId) => [sourceId, sourceId === "pumps" ? 3 : 0])
)
});
});
@@ -63,18 +68,14 @@ describe("GeoServer data-dependent layer availability", () => {
});
it("omits invalid optional layers without blocking required layers", async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(new Response("missing", { status: 503 }))
.mockResolvedValueOnce(new Response("<ServiceException />"))
.mockResolvedValueOnce(new Response("missing", { status: 503 }))
.mockResolvedValueOnce(new Response("<ServiceException />"));
const fetchMock = vi.fn().mockResolvedValue(new Response("missing", { status: 503 }));
vi.stubGlobal("fetch", fetchMock);
await expect(resolveGeoServerLayerAvailability()).resolves.toEqual({
availableSourceIds: ["pipes", "junctions"],
emptySourceIds: [],
failedSourceIds: ["valves", "reservoirs", "pumps", "tanks"]
failedSourceIds: DATA_DEPENDENT_GEOSERVER_SOURCE_IDS,
featureCounts: {}
});
});
});
@@ -25,6 +25,7 @@ export type GeoServerLayerAvailability = {
availableSourceIds: GeoServerWaterNetworkSourceId[];
emptySourceIds: DataDependentGeoServerSourceId[];
failedSourceIds: DataDependentGeoServerSourceId[];
featureCounts: Partial<Record<DataDependentGeoServerSourceId, number>>;
};
export const REQUIRED_GEOSERVER_SOURCE_IDS: GeoServerWaterNetworkSourceId[] = SUPPLY_LAYER_CATALOG.filter(
@@ -84,15 +85,16 @@ export async function resolveGeoServerLayerAvailability(
const availableOptionalSourceIds: DataDependentGeoServerSourceId[] = [];
const emptySourceIds: DataDependentGeoServerSourceId[] = [];
const failedSourceIds: DataDependentGeoServerSourceId[] = [];
const featureCounts: Partial<Record<DataDependentGeoServerSourceId, number>> = {};
results.forEach((result, index) => {
const sourceId = DATA_DEPENDENT_GEOSERVER_SOURCE_IDS[index];
if (result.status === "rejected") {
failedSourceIds.push(sourceId);
} else if (result.value.count > 0) {
availableOptionalSourceIds.push(sourceId);
} else {
emptySourceIds.push(sourceId);
featureCounts[sourceId] = result.value.count;
if (result.value.count > 0) availableOptionalSourceIds.push(sourceId);
else emptySourceIds.push(sourceId);
}
});
@@ -106,6 +108,7 @@ export async function resolveGeoServerLayerAvailability(
availableSourceIdSet.has(sourceId)
),
emptySourceIds,
failedSourceIds
failedSourceIds,
featureCounts
};
}
+51 -1
View File
@@ -30,6 +30,49 @@ describe("supply network layer styling", () => {
});
});
it("styles source GIS pipes with the network diameter hierarchy and a distinct inventory trace", () => {
const sourcePipe = getLayer("fx_20200423_pipes-line");
const sourcePipePaint = sourcePipe?.paint as Record<string, unknown> | undefined;
const inventoryPipe = getLayer("fx_20200811_dn100_pipes-line");
const inventoryPaint = inventoryPipe?.paint as Record<string, unknown> | undefined;
expect(sourcePipePaint?.["line-width"]).toEqual(SUPPLY_LAYER_VISUALS.pipes.width);
expect(JSON.stringify(sourcePipePaint?.["line-color"])).toContain("diameter");
expect((sourcePipePaint?.["line-color"] as unknown[])[1]).toEqual([
"<=",
["to-number", ["coalesce", ["get", "diameter"], 0]],
0
]);
expect(inventoryPaint?.["line-width"]).toEqual(SUPPLY_LAYER_VISUALS.pipes.width);
expect(inventoryPaint?.["line-dasharray"]).toEqual([4, 2]);
});
it("uses distinct engineering symbols for every source GIS facility type", () => {
const sourceFacilitySymbols = [
["fx_20200423_valves-symbol", SUPPLY_ASSET_IMAGE_IDS.sourceValve, 14],
["fx_20200423_drain_valves-symbol", SUPPLY_ASSET_IMAGE_IDS.sourceDrainValve, 10],
["fx_20200423_flow_meters-symbol", SUPPLY_ASSET_IMAGE_IDS.sourceFlowMeter, 10],
["fx_20200423_hydrants-symbol", SUPPLY_ASSET_IMAGE_IDS.sourceHydrant, 13],
["fx_20200423_service_connections-symbol", SUPPLY_ASSET_IMAGE_IDS.sourceConnection, 13],
["fx_20200423_customer_meters-symbol", SUPPLY_ASSET_IMAGE_IDS.sourceCustomerMeter, 13],
[
"fx_20200423_non_residential_meters-symbol",
SUPPLY_ASSET_IMAGE_IDS.sourceNonResidentialMeter,
15
]
] as const;
sourceFacilitySymbols.forEach(([layerId, imageId, minZoom]) => {
expect(getLayer(layerId)).toMatchObject({
type: "symbol",
layout: {
"icon-image": imageId,
"icon-size": ["interpolate", ["linear"], ["zoom"], minZoom, 0.36, 18, 0.54]
}
});
});
});
it("keeps facilities visible from overview while deferring ordinary junctions", () => {
expect(getLayer("valves")).toMatchObject({ minzoom: 8.5 });
expect(getLayer("valves-symbol")).toMatchObject({ minzoom: 8.5 });
@@ -46,6 +89,11 @@ describe("supply network layer styling", () => {
minzoom: 11,
layout: { "icon-image": SUPPLY_ASSET_IMAGE_IDS.tank }
});
expect(getLayer("water_quality_monitoring_points-symbol")).toMatchObject({
type: "symbol",
minzoom: 8,
layout: { "icon-image": SUPPLY_ASSET_IMAGE_IDS.waterQuality }
});
expect(getLayer("junctions")).toMatchObject({ minzoom: 15 });
const valvePaint = getLayer("valves")?.paint as Record<string, unknown> | undefined;
@@ -59,7 +107,9 @@ 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("tanks-hit");
expect(ids.at(-1)).toBe("fx_20200423_non_residential_meters-hit");
expect(ids.indexOf("water_quality_monitoring_points-point"))
.toBeLessThan(ids.indexOf("water_quality_monitoring_points-hit"));
});
it("removes every layer backed by an unavailable optional source", () => {
+28 -9
View File
@@ -1,7 +1,13 @@
import type { ExpressionSpecification, StyleSpecification } from "maplibre-gl";
import { MAP_STYLE_TOKENS } from "./map-colors";
import { SUPPLY_LAYER_VISUALS } from "./map-layer-visuals";
import { SCADA_HIT_LAYER_ID } from "./scada";
import { SCADA_HIT_LAYER_IDS } from "./scada";
import {
PUBLISHED_GIS_HIT_LAYER_IDS,
publishedGisBusinessLayers,
publishedGisHitLayers,
publishedGisInteractionLayers
} from "./published-gis-layers";
import {
SOURCE_LAYERS,
type GeoServerWaterNetworkSourceId
@@ -27,8 +33,9 @@ export const INTERACTIVE_HIT_LAYER_IDS = [
"reservoirs-hit",
"pumps-hit",
"tanks-hit",
SCADA_HIT_LAYER_ID
] as const;
...SCADA_HIT_LAYER_IDS,
...PUBLISHED_GIS_HIT_LAYER_IDS
];
const hoveredOpacity: ExpressionSpecification = [
"case",
@@ -469,14 +476,26 @@ layers.push(
}
);
export const waterNetworkLayers: StyleSpecification["layers"] = layers as Layer[];
const interactionIndex = layers.findIndex((layer) => layer.id === "pipes-hover-outline");
const hitIndex = layers.findIndex((layer) => layer.id === INTERACTIVE_HIT_LAYER_IDS[0]);
const hitIndex = layers.findIndex((layer) => layer.id === "pipes-hit");
export const waterNetworkBusinessLayers = layers.slice(0, interactionIndex) as Layer[];
export const waterNetworkInteractionLayers = layers.slice(interactionIndex, hitIndex) as Layer[];
export const waterNetworkHitLayers = layers.slice(hitIndex) as Layer[];
export const waterNetworkBusinessLayers = [
...layers.slice(0, interactionIndex),
...publishedGisBusinessLayers
] as Layer[];
export const waterNetworkInteractionLayers = [
...layers.slice(interactionIndex, hitIndex),
...publishedGisInteractionLayers
] as Layer[];
export const waterNetworkHitLayers = [
...layers.slice(hitIndex),
...publishedGisHitLayers
] as Layer[];
export const waterNetworkLayers: StyleSpecification["layers"] = [
...waterNetworkBusinessLayers,
...waterNetworkInteractionLayers,
...waterNetworkHitLayers
];
export const WORKBENCH_INTERACTION_BEFORE_ID = "pipes-hover-outline";
export function filterWaterNetworkLayersBySourceIds(
+13 -1
View File
@@ -17,7 +17,19 @@ export const MAP_STYLE_TOKENS = {
pump: "#326B87",
tank: "#4F6F7A",
scadaFlow: "#007A9E",
scadaPressure: "#9A6700"
scadaPressure: "#9A6700",
waterQuality: "#0F766E",
sourcePipe: "#56808A",
sourcePipeMedium: "#397781",
sourcePipeMajor: "#315F73",
sourcePipeInventory: "#39738C",
sourceValve: "#737988",
sourceDrainValve: "#6D7F8C",
sourceFlowMeter: "#287A89",
sourceHydrant: "#A15F55",
sourceConnection: "#70818F",
sourceCustomerMeter: "#6F8292",
sourceNonResidentialMeter: "#526F83"
},
// Map overlays stay more chromatic than UI status surfaces so they remain legible over imagery.
state: {
@@ -6,32 +6,49 @@ import {
INITIAL_LAYER_VISIBILITY,
MAP_LEGEND_ITEMS,
createLayerControlItems,
createVisibleMapLegendItems,
getWorkbenchLayerIds
} from "./map-control-config";
describe("workbench supply layer controls", () => {
it("creates controls only for available supply system sources", () => {
const items = createLayerControlItems(INITIAL_LAYER_VISIBILITY);
const items = createLayerControlItems(INITIAL_LAYER_VISIBILITY, undefined, {
fx_20200423_pipes: 215_797,
fx_20200811_dn100_pipes: 122_686
});
expect(items.map((item) => item.id)).toEqual([
expect(items.map((item) => item.id)).toEqual(expect.arrayContaining([
"pipes",
"junctions",
"valves",
"reservoirs",
"scada",
"pumps",
"tanks",
"scada-pressure",
"scada-flow",
"water_quality_monitoring_points",
"fx_20200423_pipes",
"fx_20200811_dn100_pipes",
"simulation"
]);
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"
]);
]));
expect(items.find((item) => item.id === "fx_20200423_pipes")).toMatchObject({
group: "source-gis",
groupLabel: "GIS 原始数据",
groupDescription: "源数据快照与专项台账",
groupDefaultOpen: false,
collection: "fx-water-assets-20200423",
collectionLabel: "2020-04-23 · 全网除头桥快照",
featureCount: 215_797,
minZoom: 10,
visible: false
});
expect(items.find((item) => item.id === "fx_20200811_dn100_pipes")).toMatchObject({
collection: "fx-pipe-inventory-20200811",
featureCount: 122_686,
visual: { kind: "line", dashed: true }
});
expect(items.find((item) => item.id === "water_quality_monitoring_points")).toMatchObject({
group: "monitoring",
groupLabel: "监测点",
visual: { kind: "image", imageSrc: "/map/water-quality-monitoring.svg" },
visible: true
});
});
it("omits data-dependent controls when their GeoServer layers are empty", () => {
@@ -48,12 +65,13 @@ describe("workbench supply layer controls", () => {
"junctions",
"valves",
"reservoirs",
"scada",
"scada-pressure",
"scada-flow",
"simulation"
]);
});
it("shows only current visible business icons in the legend", () => {
it("defines map-matched symbols for model, source GIS, and analysis layers", () => {
expect(MAP_LEGEND_ITEMS.map((item) => item.id)).toEqual([
"major-pipe",
"medium-pipe",
@@ -66,6 +84,19 @@ describe("workbench supply layer controls", () => {
"tank",
"scada-flow",
"scada-pressure",
"water-quality-monitoring",
"source-major-pipe",
"source-medium-pipe",
"source-minor-pipe",
"source-unknown-pipe",
"source-inventory-pipe",
"source-valve",
"source-drain-valve",
"source-flow-meter",
"source-hydrant",
"source-connection",
"source-customer-meter",
"source-non-residential-meter",
"impact-area"
]);
expect(MAP_LEGEND_ITEMS.find((item) => item.id === "valve")).toMatchObject({ imageSrc: "/map/valve.png" });
@@ -79,8 +110,80 @@ describe("workbench supply layer controls", () => {
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({
color: MAP_STYLE_TOKENS.state.inactive,
lineWidth: 2.5,
shape: "dashed-line"
});
expect(MAP_LEGEND_ITEMS.find((item) => item.id === "source-inventory-pipe")).toMatchObject({
color: MAP_STYLE_TOKENS.supply.sourcePipeInventory,
groupLabel: "原始 GIS 管线 · 2020-08-11",
layerControlId: "fx_20200811_dn100_pipes",
shape: "dashed-line"
});
expect(MAP_LEGEND_ITEMS.find((item) => item.id === "source-unknown-pipe")).toMatchObject({
label: "未标注口径",
color: MAP_STYLE_TOKENS.state.inactive,
groupLabel: "原始 GIS 管线 · 2020-04-23",
layerControlId: "fx_20200423_pipes",
shape: "line"
});
expect(MAP_LEGEND_ITEMS.find((item) => item.id === "source-valve")).toMatchObject({
imageSrc: "/map/gis-valve.svg",
groupLabel: "原始 GIS 设施"
});
expect(MAP_LEGEND_ITEMS.find((item) => item.id === "source-hydrant")).toMatchObject({
imageSrc: "/map/gis-hydrant.svg"
});
expect(MAP_LEGEND_ITEMS.find((item) => item.id === "source-non-residential-meter")).toMatchObject({
imageSrc: "/map/gis-non-residential-meter.svg"
});
expect(MAP_LEGEND_ITEMS.find((item) => item.id === "impact-area")).toMatchObject({
groupLabel: "分析覆盖",
layerControlId: "simulation",
shape: "area"
});
});
it("keeps the legend synchronized with visible layer controls", () => {
const initialItems = createLayerControlItems(INITIAL_LAYER_VISIBILITY);
const initialLegendIds = createVisibleMapLegendItems(initialItems).map((item) => item.id);
expect(initialLegendIds).toEqual(expect.arrayContaining([
"major-pipe",
"valve",
"scada-flow",
"water-quality-monitoring"
]));
expect(initialLegendIds).not.toEqual(expect.arrayContaining([
"source-major-pipe",
"source-inventory-pipe",
"impact-area"
]));
const sourcePipeItems = createLayerControlItems({
...INITIAL_LAYER_VISIBILITY,
fx_20200423_pipes: true
});
const sourcePipeLegendIds = createVisibleMapLegendItems(sourcePipeItems).map(
(item) => item.id
);
expect(sourcePipeLegendIds).toEqual(expect.arrayContaining([
"source-major-pipe",
"source-medium-pipe",
"source-minor-pipe",
"source-unknown-pipe"
]));
expect(sourcePipeLegendIds).not.toContain("source-inventory-pipe");
const reducedItems = createLayerControlItems({
...INITIAL_LAYER_VISIBILITY,
valves: false,
simulation: false
});
const reducedLegendIds = createVisibleMapLegendItems(reducedItems).map((item) => item.id);
expect(reducedLegendIds).not.toContain("valve");
expect(reducedLegendIds).not.toContain("impact-area");
});
it("finds every rendered layer that uses a source", () => {
+363 -32
View File
@@ -1,13 +1,21 @@
import type { Map as MapLibreMap } from "maplibre-gl";
import type { BaseLayerOption, MapLayerControlItem, MapLegendItem } from "@/features/map/core";
import { GEOSERVER_WORKSPACE } from "./geoserver-config";
import type {
BaseLayerOption,
MapLayerControlItem,
MapLayerControlVisual,
MapLegendItem
} from "@/features/map/core";
import { MAP_STYLE_TOKENS } from "./map-colors";
import {
AVAILABLE_WATER_NETWORK_SOURCE_IDS,
SUPPLY_LAYER_CATALOG,
type WaterNetworkSourceId
} from "./sources";
import { SCADA_ICON_PATHS } from "./scada";
import {
SCADA_ICON_PATHS,
SCADA_LAYER_CONTROL_ITEMS,
SCADA_LAYER_GROUPS
} from "./scada";
import { SUPPLY_ASSET_ICON_PATHS } from "./supply-icons";
export const WORKBENCH_LAYER_GROUPS: Record<string, string[]> = {
@@ -24,39 +32,306 @@ export const WORKBENCH_LAYER_GROUPS: Record<string, string[]> = {
};
export const INITIAL_LAYER_VISIBILITY: Record<string, boolean> = {
...Object.fromEntries(AVAILABLE_WATER_NETWORK_SOURCE_IDS.map((sourceId) => [sourceId, true])),
...Object.fromEntries(
SUPPLY_LAYER_CATALOG
.filter((layer) => layer.id !== "scada")
.map((layer) => [layer.id, layer.initialVisible])
),
...Object.fromEntries(SCADA_LAYER_CONTROL_ITEMS.map((item) => [item.id, true])),
simulation: false
};
const SOURCE_CONTROL_LABELS = Object.fromEntries(
SUPPLY_LAYER_CATALOG
.filter((layer) => layer.available)
.map((layer) => [
layer.id,
{
label: layer.label,
description: layer.id === "scada"
? "后端业务 API"
: `${GEOSERVER_WORKSPACE}:${layer.sourceLayer}`
}
])
) as Record<WaterNetworkSourceId, { label: string; description: string }>;
.map((layer) => [layer.id, layer.label])
) as Record<WaterNetworkSourceId, string>;
type LayerControlPresentation = {
description: string;
minZoom?: number;
collection?: string;
collectionLabel?: string;
visual: MapLayerControlVisual;
};
const LAYER_CONTROL_PRESENTATION: Record<Exclude<WaterNetworkSourceId, "scada">, LayerControlPresentation> = {
pipes: {
description: "按管径分级",
minZoom: 8.5,
visual: {
kind: "line-stack",
colors: [
MAP_STYLE_TOKENS.supply.pipeMinor,
MAP_STYLE_TOKENS.supply.pipeMedium,
MAP_STYLE_TOKENS.supply.pipeMajor
]
}
},
junctions: {
description: "拓扑连接点",
minZoom: 15,
visual: { kind: "point", color: MAP_STYLE_TOKENS.supply.junction }
},
valves: {
description: "控制设施",
minZoom: 8.5,
visual: { kind: "image", imageSrc: SUPPLY_ASSET_ICON_PATHS.valve }
},
reservoirs: {
description: "供水设施",
minZoom: 8.5,
visual: { kind: "image", imageSrc: SUPPLY_ASSET_ICON_PATHS.reservoir }
},
pumps: {
description: "加压设施",
minZoom: 11,
visual: { kind: "image", imageSrc: SUPPLY_ASSET_ICON_PATHS.pump }
},
tanks: {
description: "调蓄设施",
minZoom: 11,
visual: { kind: "image", imageSrc: SUPPLY_ASSET_ICON_PATHS.tank }
},
water_quality_monitoring_points: {
description: "灵敏度分析选点",
minZoom: 8,
visual: { kind: "image", imageSrc: SUPPLY_ASSET_ICON_PATHS.waterQuality }
},
fx_20200423_pipes: {
description: "全口径管线",
minZoom: 10,
collection: "fx-water-assets-20200423",
collectionLabel: "2020-04-23 · 全网除头桥快照",
visual: {
kind: "line-stack",
colors: [
MAP_STYLE_TOKENS.supply.sourcePipe,
MAP_STYLE_TOKENS.supply.sourcePipeMedium,
MAP_STYLE_TOKENS.supply.sourcePipeMajor
]
}
},
fx_20200811_dn100_pipes: {
description: "DN100 及以上",
minZoom: 9,
collection: "fx-pipe-inventory-20200811",
collectionLabel: "2020-08-11 · 专项管线台账",
visual: {
kind: "line",
color: MAP_STYLE_TOKENS.supply.sourcePipeInventory,
dashed: true
}
},
fx_20200423_valves: {
description: "源设施台账",
minZoom: 14,
collection: "fx-water-assets-20200423",
collectionLabel: "2020-04-23 · 全网除头桥快照",
visual: { kind: "image", imageSrc: SUPPLY_ASSET_ICON_PATHS.sourceValve }
},
fx_20200423_drain_valves: {
description: "源设施台账",
minZoom: 10,
collection: "fx-water-assets-20200423",
collectionLabel: "2020-04-23 · 全网除头桥快照",
visual: { kind: "image", imageSrc: SUPPLY_ASSET_ICON_PATHS.sourceDrainValve }
},
fx_20200423_flow_meters: {
description: "源仪表台账",
minZoom: 10,
collection: "fx-water-assets-20200423",
collectionLabel: "2020-04-23 · 全网除头桥快照",
visual: { kind: "image", imageSrc: SUPPLY_ASSET_ICON_PATHS.sourceFlowMeter }
},
fx_20200423_hydrants: {
description: "源设施台账",
minZoom: 13,
collection: "fx-water-assets-20200423",
collectionLabel: "2020-04-23 · 全网除头桥快照",
visual: { kind: "image", imageSrc: SUPPLY_ASSET_ICON_PATHS.sourceHydrant }
},
fx_20200423_service_connections: {
description: "源设施台账",
minZoom: 13,
collection: "fx-water-assets-20200423",
collectionLabel: "2020-04-23 · 全网除头桥快照",
visual: { kind: "image", imageSrc: SUPPLY_ASSET_ICON_PATHS.sourceConnection }
},
fx_20200423_customer_meters: {
description: "源水表台账",
minZoom: 13,
collection: "fx-water-assets-20200423",
collectionLabel: "2020-04-23 · 全网除头桥快照",
visual: { kind: "image", imageSrc: SUPPLY_ASSET_ICON_PATHS.sourceCustomerMeter }
},
fx_20200423_non_residential_meters: {
description: "源水表台账",
minZoom: 15,
collection: "fx-water-assets-20200423",
collectionLabel: "2020-04-23 · 全网除头桥快照",
visual: { kind: "image", imageSrc: SUPPLY_ASSET_ICON_PATHS.sourceNonResidentialMeter }
}
};
const MODEL_PIPE_LEGEND_GROUP = {
group: "model-pipe",
groupLabel: "模型管线",
groupDescription: "颜色与线宽共同表示口径",
groupLayout: "list"
} as const;
const MODEL_FACILITY_LEGEND_GROUP = {
group: "model-facility",
groupLabel: "供水设施",
groupDescription: "拓扑节点与关键设施",
groupLayout: "grid"
} as const;
const MONITORING_LEGEND_GROUP = {
group: "monitoring",
groupLabel: "监测点",
groupDescription: "在线监测与水质选点",
groupLayout: "grid"
} as const;
const SOURCE_APRIL_PIPE_LEGEND_GROUP = {
group: "source-pipe-april",
groupLabel: "原始 GIS 管线 · 2020-04-23",
groupDescription: "全网快照,实线按已知口径分级",
groupLayout: "list"
} as const;
const SOURCE_AUGUST_PIPE_LEGEND_GROUP = {
group: "source-pipe-august",
groupLabel: "原始 GIS 管线 · 2020-08-11",
groupDescription: "DN100+ 专项台账,蓝色虚线",
groupLayout: "list"
} as const;
const SOURCE_FACILITY_LEGEND_GROUP = {
group: "source-facility",
groupLabel: "原始 GIS 设施",
groupDescription: "源设施与水表台账",
groupLayout: "grid"
} as const;
const ANALYSIS_LEGEND_GROUP = {
group: "analysis",
groupLabel: "分析覆盖",
groupDescription: "仅随空间分析成果显示",
groupLayout: "list"
} as const;
export const MAP_LEGEND_ITEMS: MapLegendItem[] = [
{ id: "major-pipe", label: "DN600 及以上管线", color: MAP_STYLE_TOKENS.supply.pipeMajor, shape: "line" },
{ id: "medium-pipe", label: "DN300-DN599 管线", color: MAP_STYLE_TOKENS.supply.pipeMedium, shape: "line" },
{ id: "minor-pipe", label: "DN300 以下管线", color: MAP_STYLE_TOKENS.supply.pipeMinor, shape: "line" },
{ id: "inactive-pipe", label: "停运管线", color: MAP_STYLE_TOKENS.state.inactive, shape: "dashed-line" },
{ 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" }
{
id: "major-pipe",
label: "DN600 及以上",
description: "主干管",
color: MAP_STYLE_TOKENS.supply.pipeMajor,
lineWidth: 5,
shape: "line",
layerControlId: "pipes",
...MODEL_PIPE_LEGEND_GROUP
},
{
id: "medium-pipe",
label: "DN300-DN599",
description: "次干管",
color: MAP_STYLE_TOKENS.supply.pipeMedium,
lineWidth: 3.5,
shape: "line",
layerControlId: "pipes",
...MODEL_PIPE_LEGEND_GROUP
},
{
id: "minor-pipe",
label: "DN300 以下",
description: "配水支管",
color: MAP_STYLE_TOKENS.supply.pipeMinor,
lineWidth: 2,
shape: "line",
layerControlId: "pipes",
...MODEL_PIPE_LEGEND_GROUP
},
{
id: "inactive-pipe",
label: "停运管线",
description: "虚线",
color: MAP_STYLE_TOKENS.state.inactive,
lineWidth: 2.5,
shape: "dashed-line",
layerControlId: "pipes",
...MODEL_PIPE_LEGEND_GROUP
},
{ id: "junction", label: "节点", color: MAP_STYLE_TOKENS.supply.junction, shape: "dot", layerControlId: "junctions", ...MODEL_FACILITY_LEGEND_GROUP },
{ id: "valve", label: "阀门", color: MAP_STYLE_TOKENS.supply.valve, imageSrc: SUPPLY_ASSET_ICON_PATHS.valve, layerControlId: "valves", ...MODEL_FACILITY_LEGEND_GROUP },
{ id: "reservoir", label: "水库", color: MAP_STYLE_TOKENS.supply.reservoir, imageSrc: SUPPLY_ASSET_ICON_PATHS.reservoir, layerControlId: "reservoirs", ...MODEL_FACILITY_LEGEND_GROUP },
{ id: "pump", label: "水泵", color: MAP_STYLE_TOKENS.supply.pump, imageSrc: SUPPLY_ASSET_ICON_PATHS.pump, layerControlId: "pumps", ...MODEL_FACILITY_LEGEND_GROUP },
{ id: "tank", label: "水箱", color: MAP_STYLE_TOKENS.supply.tank, imageSrc: SUPPLY_ASSET_ICON_PATHS.tank, layerControlId: "tanks", ...MODEL_FACILITY_LEGEND_GROUP },
{ id: "scada-flow", label: "流量监测", color: MAP_STYLE_TOKENS.supply.scadaFlow, imageSrc: SCADA_ICON_PATHS.flow, layerControlId: "scada-flow", ...MONITORING_LEGEND_GROUP },
{ id: "scada-pressure", label: "压力监测", color: MAP_STYLE_TOKENS.supply.scadaPressure, imageSrc: SCADA_ICON_PATHS.pressure, layerControlId: "scada-pressure", ...MONITORING_LEGEND_GROUP },
{ id: "water-quality-monitoring", label: "水质监测", color: MAP_STYLE_TOKENS.supply.waterQuality, imageSrc: SUPPLY_ASSET_ICON_PATHS.waterQuality, layerControlId: "water_quality_monitoring_points", ...MONITORING_LEGEND_GROUP },
{
id: "source-major-pipe",
label: "DN600 及以上",
color: MAP_STYLE_TOKENS.supply.sourcePipeMajor,
lineWidth: 5,
shape: "line",
layerControlId: "fx_20200423_pipes",
...SOURCE_APRIL_PIPE_LEGEND_GROUP
},
{
id: "source-medium-pipe",
label: "DN300-DN599",
color: MAP_STYLE_TOKENS.supply.sourcePipeMedium,
lineWidth: 3.5,
shape: "line",
layerControlId: "fx_20200423_pipes",
...SOURCE_APRIL_PIPE_LEGEND_GROUP
},
{
id: "source-minor-pipe",
label: "DN300 以下",
color: MAP_STYLE_TOKENS.supply.sourcePipe,
lineWidth: 2,
shape: "line",
layerControlId: "fx_20200423_pipes",
...SOURCE_APRIL_PIPE_LEGEND_GROUP
},
{
id: "source-unknown-pipe",
label: "未标注口径",
color: MAP_STYLE_TOKENS.state.inactive,
lineWidth: 2,
shape: "line",
layerControlId: "fx_20200423_pipes",
...SOURCE_APRIL_PIPE_LEGEND_GROUP
},
{
id: "source-inventory-pipe",
label: "DN100 及以上",
color: MAP_STYLE_TOKENS.supply.sourcePipeInventory,
lineWidth: 3,
shape: "dashed-line",
layerControlId: "fx_20200811_dn100_pipes",
...SOURCE_AUGUST_PIPE_LEGEND_GROUP
},
{ id: "source-valve", label: "GIS 阀门", color: MAP_STYLE_TOKENS.supply.sourceValve, imageSrc: SUPPLY_ASSET_ICON_PATHS.sourceValve, layerControlId: "fx_20200423_valves", ...SOURCE_FACILITY_LEGEND_GROUP },
{ id: "source-drain-valve", label: "排水阀", color: MAP_STYLE_TOKENS.supply.sourceDrainValve, imageSrc: SUPPLY_ASSET_ICON_PATHS.sourceDrainValve, layerControlId: "fx_20200423_drain_valves", ...SOURCE_FACILITY_LEGEND_GROUP },
{ id: "source-flow-meter", label: "流量仪", color: MAP_STYLE_TOKENS.supply.sourceFlowMeter, imageSrc: SUPPLY_ASSET_ICON_PATHS.sourceFlowMeter, layerControlId: "fx_20200423_flow_meters", ...SOURCE_FACILITY_LEGEND_GROUP },
{ id: "source-hydrant", label: "消火栓", color: MAP_STYLE_TOKENS.supply.sourceHydrant, imageSrc: SUPPLY_ASSET_ICON_PATHS.sourceHydrant, layerControlId: "fx_20200423_hydrants", ...SOURCE_FACILITY_LEGEND_GROUP },
{ id: "source-connection", label: "接水点", color: MAP_STYLE_TOKENS.supply.sourceConnection, imageSrc: SUPPLY_ASSET_ICON_PATHS.sourceConnection, layerControlId: "fx_20200423_service_connections", ...SOURCE_FACILITY_LEGEND_GROUP },
{ id: "source-customer-meter", label: "用户水表", color: MAP_STYLE_TOKENS.supply.sourceCustomerMeter, imageSrc: SUPPLY_ASSET_ICON_PATHS.sourceCustomerMeter, layerControlId: "fx_20200423_customer_meters", ...SOURCE_FACILITY_LEGEND_GROUP },
{ id: "source-non-residential-meter", label: "非居民水表", color: MAP_STYLE_TOKENS.supply.sourceNonResidentialMeter, imageSrc: SUPPLY_ASSET_ICON_PATHS.sourceNonResidentialMeter, layerControlId: "fx_20200423_non_residential_meters", ...SOURCE_FACILITY_LEGEND_GROUP },
{ id: "impact-area", label: "影响范围", description: "事故或分析结果", color: MAP_STYLE_TOKENS.state.incident, shape: "area", layerControlId: "simulation", ...ANALYSIS_LEGEND_GROUP }
];
export function createVisibleMapLegendItems(
layerItems: readonly Pick<MapLayerControlItem, "id" | "visible">[]
) {
const visibleLayerIds = new Set(
layerItems.filter((item) => item.visible).map((item) => item.id)
);
return MAP_LEGEND_ITEMS.filter((item) =>
item.layerControlId ? visibleLayerIds.has(item.layerControlId) : true
);
}
export const BASE_LAYER_IDS = ["mapbox-light", "mapbox-satellite"] as const;
export function applyBaseLayerVisibility(map: MapLibreMap, activeBaseLayerId: string) {
@@ -94,19 +369,71 @@ export const BASE_LAYER_OPTIONS: BaseLayerOption[] = [
export function createLayerControlItems(
layerVisibility: Record<string, boolean>,
availableSourceIds: readonly WaterNetworkSourceId[] = AVAILABLE_WATER_NETWORK_SOURCE_IDS
availableSourceIds: readonly WaterNetworkSourceId[] = AVAILABLE_WATER_NETWORK_SOURCE_IDS,
featureCounts: Partial<Record<WaterNetworkSourceId, number>> = {}
): MapLayerControlItem[] {
const available = new Set<WaterNetworkSourceId>(availableSourceIds);
const catalogItems = SUPPLY_LAYER_CATALOG.filter(
(layer) => layer.available && layer.id !== "scada" && available.has(layer.id)
);
const modelItems = catalogItems.filter((layer) => layer.group === "model");
const monitoringItems = catalogItems.filter((layer) => layer.group === "monitoring");
const sourceGisItems = catalogItems.filter((layer) => layer.group === "source-gis");
return [
...availableSourceIds.map((sourceId) => ({
id: sourceId,
...SOURCE_CONTROL_LABELS[sourceId],
visible: layerVisibility[sourceId]
...modelItems.map((layer) => ({
id: layer.id,
label: SOURCE_CONTROL_LABELS[layer.id],
...LAYER_CONTROL_PRESENTATION[layer.id as Exclude<WaterNetworkSourceId, "scada">],
featureCount: featureCounts[layer.id],
visible: layerVisibility[layer.id],
group: "model",
groupLabel: "管网模型",
groupDescription: "计算模型与基础设施"
})),
...(available.has("scada") ? SCADA_LAYER_CONTROL_ITEMS.map((item) => ({
id: item.id,
label: item.label,
description: item.description,
minZoom: 8.5,
visual: {
kind: "image" as const,
imageSrc: item.id === "scada-pressure" ? SCADA_ICON_PATHS.pressure : SCADA_ICON_PATHS.flow
},
visible: layerVisibility[item.id],
group: "monitoring",
groupLabel: "监测点",
groupDescription: "在线监测与分析选点"
})) : []),
...monitoringItems.map((layer) => ({
id: layer.id,
label: SOURCE_CONTROL_LABELS[layer.id],
...LAYER_CONTROL_PRESENTATION[layer.id as Exclude<WaterNetworkSourceId, "scada">],
featureCount: featureCounts[layer.id],
visible: layerVisibility[layer.id],
group: "monitoring",
groupLabel: "监测点",
groupDescription: "在线监测与分析选点"
})),
...sourceGisItems.map((layer) => ({
id: layer.id,
label: SOURCE_CONTROL_LABELS[layer.id],
...LAYER_CONTROL_PRESENTATION[layer.id as Exclude<WaterNetworkSourceId, "scada">],
featureCount: featureCounts[layer.id],
visible: layerVisibility[layer.id],
group: "source-gis",
groupLabel: "GIS 原始数据",
groupDescription: "源数据快照与专项台账",
groupDefaultOpen: false
})),
{
id: "simulation",
label: "模拟结果",
description: "影响范围与事故标注",
visible: layerVisibility.simulation
visual: { kind: "area", color: MAP_STYLE_TOKENS.state.incident },
visible: layerVisibility.simulation,
group: "analysis",
groupLabel: "分析覆盖",
groupDescription: "随已打开的空间成果显示"
}
];
}
@@ -115,6 +442,10 @@ export function getWorkbenchLayerIds(
map: Pick<MapLibreMap, "getLayer" | "getStyle">,
groupId: string
) {
if (groupId in SCADA_LAYER_GROUPS) {
return SCADA_LAYER_GROUPS[groupId as keyof typeof SCADA_LAYER_GROUPS]
.filter((layerId) => map.getLayer(layerId));
}
if (isWaterNetworkSourceId(groupId)) {
return map
.getStyle()
@@ -6,14 +6,9 @@ import {
type GeoServerWaterNetworkSourceId
} from "./sources";
export const MAP_FEATURE_ID_FIELDS: Record<GeoServerWaterNetworkSourceId, "id"> = {
pipes: "id",
junctions: "id",
valves: "id",
reservoirs: "id",
pumps: "id",
tanks: "id"
};
export const MAP_FEATURE_ID_FIELDS = Object.fromEntries(
GEOSERVER_WATER_NETWORK_SOURCE_IDS.map((sourceId) => [sourceId, "id"])
) as Record<GeoServerWaterNetworkSourceId, "id">;
export const MAX_MAP_FEATURE_IDS = 100;
export const MAX_MAP_FEATURE_ID_LENGTH = 128;
@@ -0,0 +1,268 @@
import type { ExpressionSpecification, StyleSpecification } from "maplibre-gl";
import { MAP_STYLE_TOKENS } from "./map-colors";
import { SUPPLY_LAYER_VISUALS } from "./map-layer-visuals";
import {
SOURCE_LAYERS,
type GeoServerWaterNetworkSourceId
} from "./sources";
import { SUPPLY_ASSET_IMAGE_IDS } from "./supply-icons";
type Layer = NonNullable<StyleSpecification["layers"]>[number];
type PublishedLayerDefinition = {
sourceId: GeoServerWaterNetworkSourceId;
geometry: "line" | "point";
color: string;
minzoom: number;
radius?: number;
imageId?: string;
lineVariant?: "network" | "inventory";
};
const definitions: PublishedLayerDefinition[] = [
{
sourceId: "water_quality_monitoring_points",
geometry: "point",
color: MAP_STYLE_TOKENS.supply.waterQuality,
minzoom: 8,
radius: 5.5,
imageId: SUPPLY_ASSET_IMAGE_IDS.waterQuality
},
{ sourceId: "fx_20200423_pipes", geometry: "line", color: MAP_STYLE_TOKENS.supply.sourcePipe, minzoom: 10, lineVariant: "network" },
{ sourceId: "fx_20200811_dn100_pipes", geometry: "line", color: MAP_STYLE_TOKENS.supply.sourcePipeInventory, minzoom: 9, lineVariant: "inventory" },
{ sourceId: "fx_20200423_valves", geometry: "point", color: MAP_STYLE_TOKENS.supply.sourceValve, minzoom: 14, radius: 3.2, imageId: SUPPLY_ASSET_IMAGE_IDS.sourceValve },
{ sourceId: "fx_20200423_drain_valves", geometry: "point", color: MAP_STYLE_TOKENS.supply.sourceDrainValve, minzoom: 10, radius: 3.6, imageId: SUPPLY_ASSET_IMAGE_IDS.sourceDrainValve },
{ sourceId: "fx_20200423_flow_meters", geometry: "point", color: MAP_STYLE_TOKENS.supply.sourceFlowMeter, minzoom: 10, radius: 4.2, imageId: SUPPLY_ASSET_IMAGE_IDS.sourceFlowMeter },
{ sourceId: "fx_20200423_hydrants", geometry: "point", color: MAP_STYLE_TOKENS.supply.sourceHydrant, minzoom: 13, radius: 3.5, imageId: SUPPLY_ASSET_IMAGE_IDS.sourceHydrant },
{ sourceId: "fx_20200423_service_connections", geometry: "point", color: MAP_STYLE_TOKENS.supply.sourceConnection, minzoom: 13, radius: 3, imageId: SUPPLY_ASSET_IMAGE_IDS.sourceConnection },
{ sourceId: "fx_20200423_customer_meters", geometry: "point", color: MAP_STYLE_TOKENS.supply.sourceCustomerMeter, minzoom: 13, radius: 3, imageId: SUPPLY_ASSET_IMAGE_IDS.sourceCustomerMeter },
{ sourceId: "fx_20200423_non_residential_meters", geometry: "point", color: MAP_STYLE_TOKENS.supply.sourceNonResidentialMeter, minzoom: 15, radius: 2.8, imageId: SUPPLY_ASSET_IMAGE_IDS.sourceNonResidentialMeter }
];
const sourcePipeColorExpression: ExpressionSpecification = [
"case",
["<=", ["to-number", ["coalesce", ["get", "diameter"], 0]], 0],
MAP_STYLE_TOKENS.state.inactive,
[">=", ["to-number", ["coalesce", ["get", "diameter"], 0]], 600],
MAP_STYLE_TOKENS.supply.sourcePipeMajor,
[">=", ["to-number", ["coalesce", ["get", "diameter"], 0]], 300],
MAP_STYLE_TOKENS.supply.sourcePipeMedium,
MAP_STYLE_TOKENS.supply.sourcePipe
];
const hoveredOpacity: ExpressionSpecification = [
"case",
["boolean", ["feature-state", "hovered"], false],
1,
0
];
const selectedOpacity: ExpressionSpecification = [
"case",
["any", ["boolean", ["feature-state", "selected"], false], ["boolean", ["feature-state", "highlighted"], false]],
1,
0
];
function createBusinessLayers(definition: PublishedLayerDefinition): Layer[] {
const sourceLayer = SOURCE_LAYERS[definition.sourceId];
if (definition.geometry === "line") {
const lineColor = definition.lineVariant === "network"
? sourcePipeColorExpression
: definition.color;
const lineDash = definition.lineVariant === "inventory" ? [4, 2] as [number, number] : [1, 0] as [number, number];
return [
{
id: `${definition.sourceId}-casing`,
type: "line",
source: definition.sourceId,
"source-layer": sourceLayer,
minzoom: definition.minzoom,
paint: {
"line-color": MAP_STYLE_TOKENS.canvas.casing,
"line-width": SUPPLY_LAYER_VISUALS.pipes.casingWidth,
"line-opacity": 0.82,
"line-dasharray": lineDash
}
},
{
id: `${definition.sourceId}-line`,
type: "line",
source: definition.sourceId,
"source-layer": sourceLayer,
minzoom: definition.minzoom,
paint: {
"line-color": lineColor,
"line-width": SUPPLY_LAYER_VISUALS.pipes.width,
"line-opacity": definition.lineVariant === "inventory" ? 0.92 : 0.86,
"line-dasharray": lineDash
}
}
];
}
const radius = definition.radius ?? 3.2;
const iconSize = definition.sourceId === "water_quality_monitoring_points"
? ["interpolate", ["linear"], ["zoom"], definition.minzoom, 0.25, 18, 0.42] as ExpressionSpecification
: ["interpolate", ["linear"], ["zoom"], definition.minzoom, 0.36, 18, 0.54] as ExpressionSpecification;
const layers: Layer[] = [
{
id: `${definition.sourceId}-halo`,
type: "circle",
source: definition.sourceId,
"source-layer": sourceLayer,
minzoom: definition.minzoom,
paint: {
"circle-color": MAP_STYLE_TOKENS.canvas.casing,
"circle-radius": radius + 1.8,
"circle-opacity": 0.9
}
},
{
id: `${definition.sourceId}-point`,
type: "circle",
source: definition.sourceId,
"source-layer": sourceLayer,
minzoom: definition.minzoom,
paint: {
"circle-color": definition.color,
"circle-radius": ["interpolate", ["linear"], ["zoom"], definition.minzoom, radius, 18, radius * 1.35],
"circle-opacity": 0.9,
"circle-stroke-color": definition.sourceId === "water_quality_monitoring_points"
? MAP_STYLE_TOKENS.canvas.casing
: definition.color,
"circle-stroke-width": definition.sourceId === "water_quality_monitoring_points" ? 1.5 : 0
}
}
];
if (definition.imageId) {
layers.push({
id: `${definition.sourceId}-symbol`,
type: "symbol",
source: definition.sourceId,
"source-layer": sourceLayer,
minzoom: definition.minzoom,
layout: {
"icon-image": definition.imageId,
"icon-size": iconSize,
"icon-allow-overlap": true,
"icon-ignore-placement": true
},
paint: {
"icon-opacity": 0.96
}
});
}
return layers;
}
function createInteractionLayers(definition: PublishedLayerDefinition): Layer[] {
const sourceLayer = SOURCE_LAYERS[definition.sourceId];
if (definition.geometry === "line") {
const lineColor = definition.lineVariant === "network"
? sourcePipeColorExpression
: definition.color;
return [
{
id: `${definition.sourceId}-hover`,
type: "line",
source: definition.sourceId,
"source-layer": sourceLayer,
minzoom: definition.minzoom,
paint: {
"line-color": lineColor,
"line-width": SUPPLY_LAYER_VISUALS.pipes.hoverWidth,
"line-opacity": hoveredOpacity
}
},
{
id: `${definition.sourceId}-selected`,
type: "line",
source: definition.sourceId,
"source-layer": sourceLayer,
minzoom: definition.minzoom,
paint: {
"line-color": MAP_STYLE_TOKENS.state.selected,
"line-width": SUPPLY_LAYER_VISUALS.pipes.selectedWidth,
"line-opacity": selectedOpacity
}
}
];
}
const radius = definition.radius ?? 3.2;
const hoverRadius = definition.imageId
? ["interpolate", ["linear"], ["zoom"], definition.minzoom, 10, 18, 15] as ExpressionSpecification
: radius + 4;
const selectedRadius = definition.imageId
? ["interpolate", ["linear"], ["zoom"], definition.minzoom, 11, 18, 16] as ExpressionSpecification
: radius + 4.5;
return [
{
id: `${definition.sourceId}-hover`,
type: "circle",
source: definition.sourceId,
"source-layer": sourceLayer,
minzoom: definition.minzoom,
paint: {
"circle-color": MAP_STYLE_TOKENS.canvas.transparent,
"circle-radius": hoverRadius,
"circle-stroke-color": definition.color,
"circle-stroke-width": 2,
"circle-opacity": hoveredOpacity,
"circle-stroke-opacity": hoveredOpacity
}
},
{
id: `${definition.sourceId}-selected`,
type: "circle",
source: definition.sourceId,
"source-layer": sourceLayer,
minzoom: definition.minzoom,
paint: {
"circle-color": MAP_STYLE_TOKENS.canvas.transparent,
"circle-radius": selectedRadius,
"circle-stroke-color": MAP_STYLE_TOKENS.state.selected,
"circle-stroke-width": 2.5,
"circle-opacity": selectedOpacity,
"circle-stroke-opacity": selectedOpacity
}
}
];
}
function createHitLayer(definition: PublishedLayerDefinition): Layer {
const sourceLayer = SOURCE_LAYERS[definition.sourceId];
return definition.geometry === "line"
? {
id: `${definition.sourceId}-hit`,
type: "line",
source: definition.sourceId,
"source-layer": sourceLayer,
minzoom: definition.minzoom,
paint: {
"line-color": MAP_STYLE_TOKENS.canvas.transparent,
"line-width": SUPPLY_LAYER_VISUALS.pipes.hitWidth,
"line-opacity": 0
}
}
: {
id: `${definition.sourceId}-hit`,
type: "circle",
source: definition.sourceId,
"source-layer": sourceLayer,
minzoom: definition.minzoom,
paint: {
"circle-color": MAP_STYLE_TOKENS.canvas.transparent,
"circle-radius": definition.imageId
? ["interpolate", ["linear"], ["zoom"], definition.minzoom, 12, 18, 18]
: Math.max((definition.radius ?? 3.2) + 5, 8),
"circle-opacity": 0
}
};
}
export const publishedGisBusinessLayers = definitions.flatMap(createBusinessLayers);
export const publishedGisInteractionLayers = definitions.flatMap(createInteractionLayers);
export const publishedGisHitLayers = definitions.map(createHitLayer);
export const PUBLISHED_GIS_HIT_LAYER_IDS = publishedGisHitLayers.map((layer) => layer.id);
+16 -15
View File
@@ -1,7 +1,7 @@
import type { Map as MapLibreMap } from "maplibre-gl";
import { describe, expect, it, vi } from "vitest";
import {
SCADA_HIT_LAYER_ID,
SCADA_HIT_LAYER_IDS,
SCADA_ICON_EXPRESSION,
SCADA_IMAGE_IDS,
SCADA_MAP_ICON_PATHS,
@@ -24,8 +24,8 @@ describe("SCADA map presentation", () => {
});
it("keeps the hit target last in normal and fallback presentations", () => {
expect(scadaLayers.at(-1)?.id).toBe(SCADA_HIT_LAYER_ID);
expect(scadaFallbackLayers.at(-1)?.id).toBe(SCADA_HIT_LAYER_ID);
expect(scadaLayers.slice(-2).map((layer) => layer.id)).toEqual(SCADA_HIT_LAYER_IDS);
expect(scadaFallbackLayers.slice(-2).map((layer) => layer.id)).toEqual(SCADA_HIT_LAYER_IDS);
});
it("renders every SCADA layer from GeoJSON without a GeoServer source-layer", () => {
@@ -36,9 +36,9 @@ describe("SCADA map presentation", () => {
});
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");
const selectedLayer = scadaLayers.find((layer) => layer.id === "scada-selected");
const symbolLayer = scadaLayers.find((layer) => layer.id === "scada-pressure-symbol");
const outerLayer = scadaLayers.find((layer) => layer.id === "scada-pressure-selected-outer");
const selectedLayer = scadaLayers.find((layer) => layer.id === "scada-pressure-selected");
const junctionOuterLayer = waterNetworkLayers.find((layer) => layer.id === "junctions-selected-outer");
const junctionSelectedLayer = waterNetworkLayers.find((layer) => layer.id === "junctions-selected");
const junctionOuterPaint = junctionOuterLayer?.paint as Record<string, unknown> | undefined;
@@ -47,7 +47,7 @@ describe("SCADA map presentation", () => {
expect(symbolLayer).toMatchObject({
type: "symbol",
layout: {
"icon-image": SCADA_ICON_EXPRESSION,
"icon-image": SCADA_IMAGE_IDS.pressure,
"icon-size": SUPPLY_LAYER_VISUALS.scada.iconSize
}
});
@@ -68,12 +68,13 @@ describe("SCADA map presentation", () => {
});
it("uses distinct colors for authoritative pressure and pipe flow types", () => {
const fillLayer = scadaLayers.find((layer) => layer.id === "scada-fill");
const colorExpression = JSON.stringify((fillLayer as { paint?: unknown })?.paint);
const pressureFill = scadaLayers.find((layer) => layer.id === "scada-pressure-fill");
const flowFill = scadaLayers.find((layer) => layer.id === "scada-flow-fill");
expect(colorExpression).toContain("pipe_flow");
expect(colorExpression).toContain(MAP_STYLE_TOKENS.supply.scadaFlow);
expect(colorExpression).toContain(MAP_STYLE_TOKENS.supply.scadaPressure);
expect(JSON.stringify((pressureFill as { paint?: unknown })?.paint)).toContain(MAP_STYLE_TOKENS.supply.scadaPressure);
expect(JSON.stringify((flowFill as { paint?: unknown })?.paint)).toContain(MAP_STYLE_TOKENS.supply.scadaFlow);
expect(JSON.stringify(pressureFill && "filter" in pressureFill ? pressureFill.filter : undefined)).toContain("pressure");
expect(JSON.stringify(flowFill && "filter" in flowFill ? flowFill.filter : undefined)).toContain("pipe_flow");
});
it("uses MapLibre-compatible PNG assets", () => {
@@ -112,9 +113,9 @@ describe("SCADA map presentation", () => {
failedImageIds: Object.values(SCADA_IMAGE_IDS)
});
expect(scadaFallbackLayers.map((layer) => layer.id).slice(0, 3)).toEqual([
"scada-casing",
"scada-fill",
"scada-fallback"
"scada-pressure-casing",
"scada-pressure-fill",
"scada-pressure-fallback"
]);
});
});
+167 -63
View File
@@ -8,7 +8,6 @@ import {
} from "./supply-style-expressions";
export const SCADA_SOURCE_ID = "scada";
export const SCADA_HIT_LAYER_ID = "scada-hit";
export const SCADA_ICON_PATHS = {
flow: "/map/scada-flow.png",
@@ -29,6 +28,27 @@ export const SCADA_ICON_EXPRESSION: ExpressionSpecification = [
SCADA_IMAGE_IDS.pressure
];
export const SCADA_LAYER_CONTROL_ITEMS = [
{
id: "scada-pressure",
label: "压力监测点",
description: "83 个压力监测点",
deviceType: "pressure",
imageId: SCADA_IMAGE_IDS.pressure,
color: MAP_STYLE_TOKENS.supply.scadaPressure
},
{
id: "scada-flow",
label: "流量监测点",
description: "35 个流量监测点",
deviceType: "pipe_flow",
imageId: SCADA_IMAGE_IDS.flow,
color: MAP_STYLE_TOKENS.supply.scadaFlow
}
] as const;
export type ScadaLayerControlId = (typeof SCADA_LAYER_CONTROL_ITEMS)[number]["id"];
const stateOpacity = (state: "hovered" | "selected"): ExpressionSpecification => [
"case",
state === "selected"
@@ -38,12 +58,6 @@ const stateOpacity = (state: "hovered" | "selected"): ExpressionSpecification =>
0
];
const scadaColor: ExpressionSpecification = [
"match", ["downcase", ["to-string", ["coalesce", ["get", "device_type"], ""]]],
"pipe_flow", MAP_STYLE_TOKENS.supply.scadaFlow,
MAP_STYLE_TOKENS.supply.scadaPressure
];
type Layer = NonNullable<StyleSpecification["layers"]>[number];
export type ScadaImageRegistrationResult = {
@@ -51,74 +65,164 @@ export type ScadaImageRegistrationResult = {
failedImageIds: string[];
};
const scadaCasingLayer: Layer = {
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,
"circle-opacity": 0.94
}
};
const scadaFillLayer: Layer = {
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),
"circle-opacity": supplyFacilityOpacityExpression
}
};
const scadaSymbolLayer: Layer = {
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, 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,
"circle-opacity": supplyIconOpacityExpression
}
};
export const scadaInteractionLayers: Layer[] = [
{ 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 typeFilter = (deviceType: string): ExpressionSpecification => [
"==",
["downcase", ["to-string", ["coalesce", ["get", "device_type"], ""]]],
deviceType
];
const scadaHitLayer: Layer = {
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 }
};
function createScadaLayers(definition: (typeof SCADA_LAYER_CONTROL_ITEMS)[number]) {
const filter = typeFilter(definition.deviceType);
const color = createSupplyStateColor(definition.color);
const business: Layer[] = [
{
id: `${definition.id}-casing`,
type: "circle",
source: SCADA_SOURCE_ID,
filter,
minzoom: SUPPLY_LAYER_VISUALS.scada.minzoom,
paint: {
"circle-radius": SUPPLY_LAYER_VISUALS.scada.haloRadius,
"circle-color": MAP_STYLE_TOKENS.canvas.casing,
"circle-opacity": 0.94
}
},
{
id: `${definition.id}-fill`,
type: "circle",
source: SCADA_SOURCE_ID,
filter,
minzoom: SUPPLY_LAYER_VISUALS.scada.minzoom,
paint: {
"circle-radius": SUPPLY_LAYER_VISUALS.scada.radius,
"circle-color": color,
"circle-opacity": supplyFacilityOpacityExpression
}
},
{
id: `${definition.id}-symbol`,
type: "symbol",
source: SCADA_SOURCE_ID,
filter,
minzoom: SUPPLY_LAYER_VISUALS.scada.minzoom,
layout: {
"icon-image": definition.imageId,
"icon-size": SUPPLY_LAYER_VISUALS.scada.iconSize,
"icon-allow-overlap": true,
"icon-ignore-placement": true
},
paint: { "icon-opacity": supplyIconOpacityExpression }
}
];
const fallback: Layer[] = [
...business.slice(0, 2),
{
id: `${definition.id}-fallback`,
type: "circle",
source: SCADA_SOURCE_ID,
filter,
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,
"circle-opacity": supplyIconOpacityExpression
}
}
];
const interaction: Layer[] = [
{
id: `${definition.id}-hover`,
type: "circle",
source: SCADA_SOURCE_ID,
filter,
minzoom: SUPPLY_LAYER_VISUALS.scada.minzoom,
paint: {
"circle-radius": SUPPLY_LAYER_VISUALS.scada.hoverRadius,
"circle-color": MAP_STYLE_TOKENS.canvas.transparent,
"circle-stroke-color": definition.color,
"circle-stroke-width": 2,
"circle-opacity": stateOpacity("hovered"),
"circle-stroke-opacity": stateOpacity("hovered")
}
},
{
id: `${definition.id}-selected-outer`,
type: "circle",
source: SCADA_SOURCE_ID,
filter,
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: `${definition.id}-selected`,
type: "circle",
source: SCADA_SOURCE_ID,
filter,
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 hit: Layer = {
id: `${definition.id}-hit`,
type: "circle",
source: SCADA_SOURCE_ID,
filter,
minzoom: SUPPLY_LAYER_VISUALS.scada.minzoom,
paint: {
"circle-radius": SUPPLY_LAYER_VISUALS.scada.hitRadius,
"circle-color": MAP_STYLE_TOKENS.canvas.transparent,
"circle-opacity": 0
}
};
return { business, fallback, interaction, hit };
}
export const scadaBusinessLayers: Layer[] = [
scadaCasingLayer,
scadaFillLayer,
scadaSymbolLayer
];
const groups = SCADA_LAYER_CONTROL_ITEMS.map((definition) => ({
definition,
layers: createScadaLayers(definition)
}));
export const scadaFallbackBusinessLayers: Layer[] = [
scadaCasingLayer,
scadaFillLayer,
scadaFallbackLayer
];
export const scadaBusinessLayers = groups.flatMap((group) => group.layers.business);
export const scadaFallbackBusinessLayers = groups.flatMap((group) => group.layers.fallback);
export const scadaInteractionLayers = groups.flatMap((group) => group.layers.interaction);
export const scadaHitLayers = groups.map((group) => group.layers.hit);
export const SCADA_HIT_LAYER_IDS = scadaHitLayers.map((layer) => layer.id);
export const SCADA_HIT_LAYER_ID = SCADA_HIT_LAYER_IDS[0];
export const scadaHitLayers: Layer[] = [scadaHitLayer];
export const SCADA_LAYER_GROUPS = Object.fromEntries(groups.map(({ definition, layers }) => [
definition.id,
[
...layers.business.map((layer) => layer.id),
...layers.fallback.map((layer) => layer.id),
...layers.interaction.map((layer) => layer.id),
layers.hit.id
]
])) as Record<ScadaLayerControlId, string[]>;
export const scadaLayers: Layer[] = [
export const scadaLayers = [
...scadaBusinessLayers,
...scadaInteractionLayers,
scadaHitLayer
...scadaHitLayers
];
export const scadaFallbackLayers: Layer[] = [
export const scadaFallbackLayers = [
...scadaFallbackBusinessLayers,
...scadaInteractionLayers,
scadaHitLayer
...scadaHitLayers
];
export async function registerScadaImages(
+14 -19
View File
@@ -11,24 +11,22 @@ import {
describe("createWaterNetworkSources", () => {
it("catalogs published and pre-registered supply system layers", () => {
expect(SOURCE_LAYERS).toEqual({
expect(SOURCE_LAYERS).toMatchObject({
pipes: "pipes",
junctions: "junctions",
valves: "valves",
reservoirs: "reservoirs",
pumps: "pumps",
tanks: "tanks"
tanks: "tanks",
water_quality_monitoring_points: "water_quality_monitoring_points",
fx_20200423_pipes: "fx_20200423_pipes",
fx_20200811_dn100_pipes: "fx_20200811_dn100_pipes"
});
expect(WATER_NETWORK_SOURCE_IDS).toEqual(["pipes", "junctions", "valves", "reservoirs", "scada", "pumps", "tanks"]);
expect(SUPPLY_LAYER_CATALOG.map((layer) => [layer.id, layer.available, layer.availability])).toEqual([
["pipes", true, "required"],
["junctions", true, "required"],
["valves", true, "probe"],
["reservoirs", true, "probe"],
["scada", true, "required"],
["pumps", true, "probe"],
["tanks", true, "probe"]
]);
expect(WATER_NETWORK_SOURCE_IDS).toContain("water_quality_monitoring_points");
expect(WATER_NETWORK_SOURCE_IDS).toContain("fx_20200423_non_residential_meters");
expect(SUPPLY_LAYER_CATALOG.find((layer) => layer.id === "water_quality_monitoring_points"))
.toMatchObject({ available: true, availability: "probe", group: "monitoring" });
expect(SUPPLY_LAYER_CATALOG.filter((layer) => layer.group === "source-gis")).toHaveLength(9);
expect(SUPPLY_LAYER_CATALOG.find((layer) => layer.id === "valves")).toMatchObject({
geometry: "point",
sourceLayer: "valves"
@@ -36,7 +34,7 @@ describe("createWaterNetworkSources", () => {
});
it("loads only available supply system layers from GeoServer WMTS", () => {
expect(AVAILABLE_WATER_NETWORK_SOURCE_IDS).toEqual(["pipes", "junctions", "valves", "reservoirs", "scada", "pumps", "tanks"]);
expect(new Set(AVAILABLE_WATER_NETWORK_SOURCE_IDS)).toEqual(new Set(WATER_NETWORK_SOURCE_IDS));
expect(SUPPLY_LAYER_CATALOG.find((layer) => layer.id === "reservoirs")).toMatchObject({
label: "水库",
icon: "reservoir"
@@ -49,16 +47,13 @@ describe("createWaterNetworkSources", () => {
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(sources[sourceId].bounds).toBeUndefined();
});
expect("pumps" in sources).toBe(true);
expect("tanks" in sources).toBe(true);
expect("scada" in sources).toBe(false);
expect("water_quality_monitoring_points" in sources).toBe(true);
expect("fx_20200423_pipes" in sources).toBe(true);
});
it("uses device_id as the MapLibre feature ID for the API-backed SCADA source", () => {
+165 -13
View File
@@ -9,8 +9,7 @@ import {
import { SCADA_SOURCE_ID } from "./scada";
export const WATER_NETWORK_GLOBAL_VIEW = {
bbox3857: [13508801.930066336, 3608163.3499832638, 13555650.638648884, 3633685.137885742],
bbox4326: [121.35162408429075, 30.81049932304578, 121.77248479490075, 31.007207809222017]
bbox3857: [13508801.930066336, 3608163.3499832638, 13555650.638648884, 3633685.137885742]
} as const;
export const SOURCE_LAYERS = {
@@ -19,7 +18,17 @@ export const SOURCE_LAYERS = {
valves: "valves",
reservoirs: "reservoirs",
pumps: "pumps",
tanks: "tanks"
tanks: "tanks",
water_quality_monitoring_points: "water_quality_monitoring_points",
fx_20200423_drain_valves: "fx_20200423_drain_valves",
fx_20200423_service_connections: "fx_20200423_service_connections",
fx_20200423_flow_meters: "fx_20200423_flow_meters",
fx_20200423_hydrants: "fx_20200423_hydrants",
fx_20200423_customer_meters: "fx_20200423_customer_meters",
fx_20200423_pipes: "fx_20200423_pipes",
fx_20200423_valves: "fx_20200423_valves",
fx_20200423_non_residential_meters: "fx_20200423_non_residential_meters",
fx_20200811_dn100_pipes: "fx_20200811_dn100_pipes"
} as const;
export const WATER_NETWORK_SOURCE_IDS = [
@@ -29,7 +38,17 @@ export const WATER_NETWORK_SOURCE_IDS = [
"reservoirs",
"scada",
"pumps",
"tanks"
"tanks",
"water_quality_monitoring_points",
"fx_20200423_drain_valves",
"fx_20200423_service_connections",
"fx_20200423_flow_meters",
"fx_20200423_hydrants",
"fx_20200423_customer_meters",
"fx_20200423_pipes",
"fx_20200423_valves",
"fx_20200423_non_residential_meters",
"fx_20200811_dn100_pipes"
] as const;
export type WaterNetworkSourceId = (typeof WATER_NETWORK_SOURCE_IDS)[number];
@@ -42,6 +61,8 @@ export type SupplyLayerCatalogItem = {
availability: "required" | "probe";
label: string;
icon: string;
group: "model" | "monitoring" | "source-gis";
initialVisible: boolean;
};
export const SUPPLY_LAYER_CATALOG = [
@@ -52,7 +73,9 @@ export const SUPPLY_LAYER_CATALOG = [
available: true,
availability: "required",
label: "管线",
icon: "pipe"
icon: "pipe",
group: "model",
initialVisible: true
},
{
id: "junctions",
@@ -61,7 +84,9 @@ export const SUPPLY_LAYER_CATALOG = [
available: true,
availability: "required",
label: "节点",
icon: "junction"
icon: "junction",
group: "model",
initialVisible: true
},
{
id: "valves",
@@ -70,7 +95,9 @@ export const SUPPLY_LAYER_CATALOG = [
available: true,
availability: "probe",
label: "阀门",
icon: "valve"
icon: "valve",
group: "model",
initialVisible: true
},
{
id: "reservoirs",
@@ -79,7 +106,9 @@ export const SUPPLY_LAYER_CATALOG = [
available: true,
availability: "probe",
label: "水库",
icon: "reservoir"
icon: "reservoir",
group: "model",
initialVisible: true
},
{
id: "scada",
@@ -88,7 +117,9 @@ export const SUPPLY_LAYER_CATALOG = [
available: true,
availability: "required",
label: "SCADA",
icon: "scada-pressure"
icon: "scada-pressure",
group: "monitoring",
initialVisible: true
},
{
id: "pumps",
@@ -97,7 +128,9 @@ export const SUPPLY_LAYER_CATALOG = [
available: true,
availability: "probe",
label: "水泵",
icon: "pump"
icon: "pump",
group: "model",
initialVisible: true
},
{
id: "tanks",
@@ -106,10 +139,130 @@ export const SUPPLY_LAYER_CATALOG = [
available: true,
availability: "probe",
label: "水箱",
icon: "tank"
icon: "tank",
group: "model",
initialVisible: true
},
{
id: "water_quality_monitoring_points",
sourceLayer: SOURCE_LAYERS.water_quality_monitoring_points,
geometry: "point",
available: true,
availability: "probe",
label: "水质监测点",
icon: "water-quality",
group: "monitoring",
initialVisible: true
},
{
id: "fx_20200423_pipes",
sourceLayer: SOURCE_LAYERS.fx_20200423_pipes,
geometry: "line",
available: true,
availability: "probe",
label: "GIS 管线",
icon: "source-pipe",
group: "source-gis",
initialVisible: false
},
{
id: "fx_20200811_dn100_pipes",
sourceLayer: SOURCE_LAYERS.fx_20200811_dn100_pipes,
geometry: "line",
available: true,
availability: "probe",
label: "DN100+ 台账",
icon: "source-pipe",
group: "source-gis",
initialVisible: false
},
{
id: "fx_20200423_valves",
sourceLayer: SOURCE_LAYERS.fx_20200423_valves,
geometry: "point",
available: true,
availability: "probe",
label: "GIS 阀门",
icon: "source-valve",
group: "source-gis",
initialVisible: false
},
{
id: "fx_20200423_drain_valves",
sourceLayer: SOURCE_LAYERS.fx_20200423_drain_valves,
geometry: "point",
available: true,
availability: "probe",
label: "排水阀",
icon: "source-valve",
group: "source-gis",
initialVisible: false
},
{
id: "fx_20200423_flow_meters",
sourceLayer: SOURCE_LAYERS.fx_20200423_flow_meters,
geometry: "point",
available: true,
availability: "probe",
label: "流量仪",
icon: "source-meter",
group: "source-gis",
initialVisible: false
},
{
id: "fx_20200423_hydrants",
sourceLayer: SOURCE_LAYERS.fx_20200423_hydrants,
geometry: "point",
available: true,
availability: "probe",
label: "消火栓",
icon: "source-hydrant",
group: "source-gis",
initialVisible: false
},
{
id: "fx_20200423_service_connections",
sourceLayer: SOURCE_LAYERS.fx_20200423_service_connections,
geometry: "point",
available: true,
availability: "probe",
label: "接水点",
icon: "source-connection",
group: "source-gis",
initialVisible: false
},
{
id: "fx_20200423_customer_meters",
sourceLayer: SOURCE_LAYERS.fx_20200423_customer_meters,
geometry: "point",
available: true,
availability: "probe",
label: "用户水表",
icon: "source-meter",
group: "source-gis",
initialVisible: false
},
{
id: "fx_20200423_non_residential_meters",
sourceLayer: SOURCE_LAYERS.fx_20200423_non_residential_meters,
geometry: "point",
available: true,
availability: "probe",
label: "非居民水表",
icon: "source-meter",
group: "source-gis",
initialVisible: false
}
] as const satisfies readonly SupplyLayerCatalogItem[];
export const SOURCE_GIS_SOURCE_IDS = SUPPLY_LAYER_CATALOG.filter(
(layer) => layer.group === "source-gis"
).map((layer) => layer.id) as WaterNetworkSourceId[];
export function isSourceGisSourceId(value: string): value is WaterNetworkSourceId {
return (SOURCE_GIS_SOURCE_IDS as readonly string[]).includes(value);
}
export const AVAILABLE_WATER_NETWORK_SOURCE_IDS = SUPPLY_LAYER_CATALOG.filter(
(layer) => layer.available
).map((layer) => layer.id) as WaterNetworkSourceId[];
@@ -155,8 +308,7 @@ 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,
bounds: [...WATER_NETWORK_GLOBAL_VIEW.bbox4326]
maxzoom: 24
};
}
@@ -4,13 +4,28 @@ import { describe, expect, it, vi } from "vitest";
import {
SUPPLY_ASSET_ICON_PATHS,
SUPPLY_ASSET_IMAGE_IDS,
SUPPLY_ASSET_MAP_ICON_PATHS,
registerSupplyAssetImages
} from "./supply-icons";
describe("supply facility map icons", () => {
it("uses MapLibre-compatible PNG assets from the public map directory", () => {
expect(Object.keys(SUPPLY_ASSET_ICON_PATHS)).toEqual(["valve", "reservoir", "pump", "tank"]);
Object.values(SUPPLY_ASSET_ICON_PATHS).forEach((path) => {
expect(Object.keys(SUPPLY_ASSET_ICON_PATHS)).toEqual([
"valve",
"reservoir",
"pump",
"tank",
"waterQuality",
"sourceValve",
"sourceDrainValve",
"sourceFlowMeter",
"sourceHydrant",
"sourceConnection",
"sourceCustomerMeter",
"sourceNonResidentialMeter"
]);
expect(SUPPLY_ASSET_ICON_PATHS.waterQuality).toBe("/map/water-quality-monitoring.svg");
Object.values(SUPPLY_ASSET_MAP_ICON_PATHS).forEach((path) => {
expect(path).toMatch(/^\/map\/.+\.png$/);
});
});
+43 -4
View File
@@ -4,24 +4,63 @@ export const SUPPLY_ASSET_IMAGE_IDS = {
valve: "supply-valve",
reservoir: "supply-reservoir",
pump: "supply-pump",
tank: "supply-tank"
tank: "supply-tank",
waterQuality: "water-quality-monitoring",
sourceValve: "source-gis-valve",
sourceDrainValve: "source-gis-drain-valve",
sourceFlowMeter: "source-gis-flow-meter",
sourceHydrant: "source-gis-hydrant",
sourceConnection: "source-gis-service-connection",
sourceCustomerMeter: "source-gis-customer-meter",
sourceNonResidentialMeter: "source-gis-non-residential-meter"
} as const;
export const SUPPLY_ASSET_ICON_PATHS = {
valve: "/map/valve.png",
reservoir: "/map/reservoir.png",
pump: "/map/pump.png",
tank: "/map/tank.png"
tank: "/map/tank.png",
waterQuality: "/map/water-quality-monitoring.svg",
sourceValve: "/map/gis-valve.svg",
sourceDrainValve: "/map/gis-drain-valve.svg",
sourceFlowMeter: "/map/gis-flow-meter.svg",
sourceHydrant: "/map/gis-hydrant.svg",
sourceConnection: "/map/gis-service-connection.svg",
sourceCustomerMeter: "/map/gis-customer-meter.svg",
sourceNonResidentialMeter: "/map/gis-non-residential-meter.svg"
} as const;
export const SUPPLY_ASSET_MAP_ICON_PATHS = SUPPLY_ASSET_ICON_PATHS;
export const SUPPLY_ASSET_MAP_ICON_PATHS = {
...SUPPLY_ASSET_ICON_PATHS,
waterQuality: "/map/water-quality-monitoring.png",
sourceValve: "/map/gis-valve.png",
sourceDrainValve: "/map/gis-drain-valve.png",
sourceFlowMeter: "/map/gis-flow-meter.png",
sourceHydrant: "/map/gis-hydrant.png",
sourceConnection: "/map/gis-service-connection.png",
sourceCustomerMeter: "/map/gis-customer-meter.png",
sourceNonResidentialMeter: "/map/gis-non-residential-meter.png"
} as const;
export type SupplyAssetImageRegistrationResult = {
status: "ready" | "degraded";
failedImageIds: string[];
};
const ACTIVE_SUPPLY_ASSET_ICON_KEYS = ["valve", "reservoir", "pump", "tank"] as const;
const ACTIVE_SUPPLY_ASSET_ICON_KEYS = [
"valve",
"reservoir",
"pump",
"tank",
"waterQuality",
"sourceValve",
"sourceDrainValve",
"sourceFlowMeter",
"sourceHydrant",
"sourceConnection",
"sourceCustomerMeter",
"sourceNonResidentialMeter"
] as const;
export async function registerSupplyAssetImages(
map: Pick<MapLibreMap, "loadImage" | "addImage" | "hasImage">
+4 -4
View File
@@ -1,4 +1,4 @@
import { BarChart3, Layers3, MapPinned, MoreHorizontal, Ruler } from "lucide-react";
import { Layers3, ListTree, MapPinned, MoreHorizontal, Ruler } from "lucide-react";
import type { MapToolbarItem } from "@/features/map/core";
export const waterNetworkToolbarItems: MapToolbarItem[] = [
@@ -16,9 +16,9 @@ export const waterNetworkToolbarItems: MapToolbarItem[] = [
},
{
id: "analysis",
label: "分析",
description: "查看地图分析图例",
icon: BarChart3
label: "图例",
description: "查看当前地图符号",
icon: ListTree
},
{
id: "annotation",