feat(workbench): integrate runtime data and map tooling
This commit is contained in:
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
@@ -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"
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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$/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user