feat(map): refine SCADA feature experience

This commit is contained in:
2026-07-14 10:48:24 +08:00
parent 0b7e827024
commit 1c85d938a6
35 changed files with 1020 additions and 842 deletions
+16 -15
View File
@@ -1,5 +1,6 @@
import type { GeoJSONSourceSpecification, StyleSpecification } from "maplibre-gl";
import { annotationPoints, impactAreaPolygon } from "../data/workbench-simulation";
import { MAP_STYLE_TOKENS } from "./map-colors";
export const SIMULATION_SOURCE_IDS = {
impactArea: "simulation-impact-area",
@@ -23,8 +24,8 @@ export const simulationAnnotationLayers: StyleSpecification["layers"] = [
type: "fill",
source: SIMULATION_SOURCE_IDS.impactArea,
paint: {
"fill-color": "#ef4444",
"fill-opacity": 0.18
"fill-color": MAP_STYLE_TOKENS.state.risk,
"fill-opacity": MAP_STYLE_TOKENS.opacity.area
}
},
{
@@ -32,7 +33,7 @@ export const simulationAnnotationLayers: StyleSpecification["layers"] = [
type: "line",
source: SIMULATION_SOURCE_IDS.impactArea,
paint: {
"line-color": "#ef4444",
"line-color": MAP_STYLE_TOKENS.state.risk,
"line-width": 1.5,
"line-dasharray": [2, 2],
"line-opacity": 0.72
@@ -44,9 +45,9 @@ export const simulationAnnotationLayers: StyleSpecification["layers"] = [
source: SIMULATION_SOURCE_IDS.annotations,
filter: ["==", ["get", "kind"], "burst"],
paint: {
"circle-color": "#fef2f2",
"circle-color": MAP_STYLE_TOKENS.canvas.casing,
"circle-radius": 18,
"circle-stroke-color": "#ef4444",
"circle-stroke-color": MAP_STYLE_TOKENS.state.incident,
"circle-stroke-width": 3,
"circle-opacity": 0.88
}
@@ -57,9 +58,9 @@ export const simulationAnnotationLayers: StyleSpecification["layers"] = [
source: SIMULATION_SOURCE_IDS.annotations,
filter: ["==", ["get", "kind"], "burst"],
paint: {
"circle-color": "#ef4444",
"circle-color": MAP_STYLE_TOKENS.state.incident,
"circle-radius": 7,
"circle-stroke-color": "#ffffff",
"circle-stroke-color": MAP_STYLE_TOKENS.canvas.casing,
"circle-stroke-width": 3
}
},
@@ -75,8 +76,8 @@ export const simulationAnnotationLayers: StyleSpecification["layers"] = [
"text-allow-overlap": true
},
paint: {
"text-color": "#ffffff",
"text-halo-color": "#2563eb",
"text-color": MAP_STYLE_TOKENS.label.inverse,
"text-halo-color": MAP_STYLE_TOKENS.state.agent,
"text-halo-width": 9
}
},
@@ -93,8 +94,8 @@ export const simulationAnnotationLayers: StyleSpecification["layers"] = [
"text-allow-overlap": true
},
paint: {
"text-color": "#dc2626",
"text-halo-color": "#ffffff",
"text-color": MAP_STYLE_TOKENS.state.risk,
"text-halo-color": MAP_STYLE_TOKENS.canvas.casing,
"text-halo-width": 4
}
},
@@ -110,8 +111,8 @@ export const simulationAnnotationLayers: StyleSpecification["layers"] = [
"text-allow-overlap": true
},
paint: {
"text-color": "#334155",
"text-halo-color": "#ffffff",
"text-color": MAP_STYLE_TOKENS.label.secondary,
"text-halo-color": MAP_STYLE_TOKENS.canvas.casing,
"text-halo-width": 3
}
},
@@ -129,8 +130,8 @@ export const simulationAnnotationLayers: StyleSpecification["layers"] = [
"text-allow-overlap": true
},
paint: {
"text-color": "#0f172a",
"text-halo-color": "#ffffff",
"text-color": MAP_STYLE_TOKENS.label.primary,
"text-halo-color": MAP_STYLE_TOKENS.canvas.casing,
"text-halo-width": 5
}
}
+17 -19
View File
@@ -5,18 +5,16 @@ import {
getContrastRatio
} from "./color-contrast";
import {
CLOSED_CONDUIT_COLOR,
DRAINAGE_SELECTED_OUTLINE_COLORS,
DRAINAGE_SOURCE_COLORS,
DRAINAGE_SOURCE_OPACITIES,
MAP_BACKGROUND_COLORS
MAP_STYLE_TOKENS
} from "./map-colors";
import { createBaseStyle } from "./sources";
type DrainageColorId = keyof typeof DRAINAGE_SOURCE_COLORS;
const drainageColorIds = Object.keys(DRAINAGE_SOURCE_COLORS) as DrainageColorId[];
const mapBackgrounds = Object.values(MAP_BACKGROUND_COLORS);
const sourceColors = Object.values(MAP_STYLE_TOKENS.asset);
const mapBackgrounds = [
MAP_STYLE_TOKENS.canvas.primary,
MAP_STYLE_TOKENS.canvas.secondary,
MAP_STYLE_TOKENS.canvas.secondaryAlt
];
describe("map color contrast", () => {
it("calculates WCAG contrast and alpha composites", () => {
@@ -32,12 +30,12 @@ describe("map color contrast", () => {
});
it("keeps rendered source colors above 3:1 on every map background", () => {
drainageColorIds.forEach((sourceId) => {
sourceColors.forEach((sourceColor) => {
mapBackgrounds.forEach((background) => {
const renderedColor = compositeHexColor(
DRAINAGE_SOURCE_COLORS[sourceId],
sourceColor,
background,
DRAINAGE_SOURCE_OPACITIES[sourceId]
MAP_STYLE_TOKENS.opacity.core
);
expect(
@@ -48,14 +46,14 @@ describe("map color contrast", () => {
});
});
it("keeps selected outlines above 3:1 on every map background", () => {
drainageColorIds.forEach((sourceId) => {
it("keeps selected outlines above 4.5:1 on every map background", () => {
sourceColors.forEach(() => {
mapBackgrounds.forEach((background) => {
const selectedColor = DRAINAGE_SELECTED_OUTLINE_COLORS[sourceId];
const selectedColor = MAP_STYLE_TOKENS.state.selected;
expect(
getContrastRatio(selectedColor, background),
createCoolorsContrastUrl(selectedColor, background)
).toBeGreaterThanOrEqual(3);
).toBeGreaterThanOrEqual(4.5);
});
});
});
@@ -63,9 +61,9 @@ describe("map color contrast", () => {
it("keeps closed conduits above 3:1 and applies the primary map background", () => {
mapBackgrounds.forEach((background) => {
const renderedColor = compositeHexColor(
CLOSED_CONDUIT_COLOR,
MAP_STYLE_TOKENS.state.inactive,
background,
DRAINAGE_SOURCE_OPACITIES.conduits
MAP_STYLE_TOKENS.opacity.core
);
expect(getContrastRatio(renderedColor, background)).toBeGreaterThanOrEqual(3);
});
@@ -74,6 +72,6 @@ describe("map color contrast", () => {
if (backgroundLayer?.type !== "background") {
throw new Error("Expected the base style to contain a background layer.");
}
expect(backgroundLayer.paint?.["background-color"]).toBe(MAP_BACKGROUND_COLORS.primary);
expect(backgroundLayer.paint?.["background-color"]).toBe(MAP_STYLE_TOKENS.canvas.primary);
});
});
+17 -15
View File
@@ -6,6 +6,8 @@ import type {
} from "maplibre-gl";
import type { FeatureCollection, Geometry, LineString, Point, Polygon } from "geojson";
import { createCircleCoordinates } from "./geo";
import { MAP_STYLE_TOKENS } from "./map-colors";
import { WORKBENCH_INTERACTION_BEFORE_ID } from "./layers";
export const DRAWING_SOURCE_IDS = {
features: "workbench-drawing-features",
@@ -54,7 +56,7 @@ export const drawingLayers: StyleSpecification["layers"] = [
source: DRAWING_SOURCE_IDS.features,
filter: ["==", ["geometry-type"], "Polygon"],
paint: {
"fill-color": "#2563eb",
"fill-color": MAP_STYLE_TOKENS.tool.geometry,
"fill-opacity": 0.16
}
},
@@ -64,7 +66,7 @@ export const drawingLayers: StyleSpecification["layers"] = [
source: DRAWING_SOURCE_IDS.features,
filter: ["==", ["geometry-type"], "Polygon"],
paint: {
"line-color": "#2563eb",
"line-color": MAP_STYLE_TOKENS.tool.geometry,
"line-width": 2,
"line-opacity": 0.82
}
@@ -75,7 +77,7 @@ export const drawingLayers: StyleSpecification["layers"] = [
source: DRAWING_SOURCE_IDS.features,
filter: ["==", ["geometry-type"], "LineString"],
paint: {
"line-color": "#2563eb",
"line-color": MAP_STYLE_TOKENS.tool.geometry,
"line-width": 3,
"line-opacity": 0.88
}
@@ -86,9 +88,9 @@ export const drawingLayers: StyleSpecification["layers"] = [
source: DRAWING_SOURCE_IDS.features,
filter: ["==", ["geometry-type"], "Point"],
paint: {
"circle-color": "#2563eb",
"circle-color": MAP_STYLE_TOKENS.tool.geometry,
"circle-radius": 6,
"circle-stroke-color": "#ffffff",
"circle-stroke-color": MAP_STYLE_TOKENS.canvas.casing,
"circle-stroke-width": 2
}
},
@@ -98,7 +100,7 @@ export const drawingLayers: StyleSpecification["layers"] = [
source: DRAWING_SOURCE_IDS.features,
filter: ["all", ["==", ["geometry-type"], "Polygon"], ["==", ["get", "id"], ""]],
paint: {
"line-color": "#ff7a45",
"line-color": MAP_STYLE_TOKENS.state.selected,
"line-width": 4,
"line-opacity": 0.86
}
@@ -109,7 +111,7 @@ export const drawingLayers: StyleSpecification["layers"] = [
source: DRAWING_SOURCE_IDS.features,
filter: ["all", ["==", ["geometry-type"], "LineString"], ["==", ["get", "id"], ""]],
paint: {
"line-color": "#ff7a45",
"line-color": MAP_STYLE_TOKENS.state.selected,
"line-width": 6,
"line-opacity": 0.72
}
@@ -120,10 +122,10 @@ export const drawingLayers: StyleSpecification["layers"] = [
source: DRAWING_SOURCE_IDS.features,
filter: ["all", ["==", ["geometry-type"], "Point"], ["==", ["get", "id"], ""]],
paint: {
"circle-color": "#ff7a45",
"circle-color": MAP_STYLE_TOKENS.state.selected,
"circle-radius": 10,
"circle-opacity": 0.2,
"circle-stroke-color": "#ff7a45",
"circle-stroke-color": MAP_STYLE_TOKENS.state.selected,
"circle-stroke-width": 2
}
},
@@ -133,7 +135,7 @@ export const drawingLayers: StyleSpecification["layers"] = [
source: DRAWING_SOURCE_IDS.draft,
filter: ["==", ["geometry-type"], "Polygon"],
paint: {
"fill-color": "#ff7a45",
"fill-color": MAP_STYLE_TOKENS.tool.geometry,
"fill-opacity": 0.12
}
},
@@ -143,7 +145,7 @@ export const drawingLayers: StyleSpecification["layers"] = [
source: DRAWING_SOURCE_IDS.draft,
filter: ["any", ["==", ["geometry-type"], "LineString"], ["==", ["geometry-type"], "Polygon"]],
paint: {
"line-color": "#ff7a45",
"line-color": MAP_STYLE_TOKENS.tool.geometry,
"line-width": 2,
"line-dasharray": [2, 2],
"line-opacity": 0.9
@@ -155,9 +157,9 @@ export const drawingLayers: StyleSpecification["layers"] = [
source: DRAWING_SOURCE_IDS.draft,
filter: ["==", ["geometry-type"], "Point"],
paint: {
"circle-color": "#ff7a45",
"circle-color": MAP_STYLE_TOKENS.state.selected,
"circle-radius": 4,
"circle-stroke-color": "#ffffff",
"circle-stroke-color": MAP_STYLE_TOKENS.canvas.casing,
"circle-stroke-width": 1.5
}
}
@@ -167,7 +169,7 @@ export function ensureDrawingLayers(map: {
getSource: (id: string) => unknown;
addSource: (id: string, source: GeoJSONSourceSpecification) => void;
getLayer: (id: string) => unknown;
addLayer: (layer: NonNullable<StyleSpecification["layers"]>[number]) => void;
addLayer: (layer: NonNullable<StyleSpecification["layers"]>[number], beforeId?: string) => void;
}) {
if (!map.getSource(DRAWING_SOURCE_IDS.features)) {
map.addSource(DRAWING_SOURCE_IDS.features, drawingSources.features);
@@ -179,7 +181,7 @@ export function ensureDrawingLayers(map: {
drawingLayers.forEach((layer) => {
if (!map.getLayer(layer.id)) {
map.addLayer(layer);
map.addLayer(layer, WORKBENCH_INTERACTION_BEFORE_ID);
}
});
}
+9 -1
View File
@@ -1,4 +1,5 @@
import maplibregl, { type Map as MapLibreMap, type StyleSpecification } from "maplibre-gl";
import type { MapFeatureInteractionState } from "../hooks/use-map-interactions";
const MAX_EXPORT_DIMENSION = 4096;
const EXPORT_IDLE_TIMEOUT_MS = 6000;
@@ -7,9 +8,10 @@ export type ExportMapViewOptions = {
scale?: number;
targetLongEdge?: number;
filename?: string;
selectedFeature?: Pick<MapFeatureInteractionState, "source" | "sourceLayer" | "id"> | null;
};
export async function exportMapViewImage(map: MapLibreMap, { scale, targetLongEdge, filename }: ExportMapViewOptions = {}) {
export async function exportMapViewImage(map: MapLibreMap, { scale, targetLongEdge, filename, selectedFeature }: ExportMapViewOptions = {}) {
const canvas = map.getCanvas();
const sourceWidth = canvas.clientWidth || canvas.width;
const sourceHeight = canvas.clientHeight || canvas.height;
@@ -47,6 +49,12 @@ export async function exportMapViewImage(map: MapLibreMap, { scale, targetLongEd
padding: 0,
duration: 0
});
if (selectedFeature) {
exportMap.setFeatureState(
{ source: selectedFeature.source, sourceLayer: selectedFeature.sourceLayer, id: selectedFeature.id },
{ selected: true, hovered: false }
);
}
await waitForMapIdle(exportMap);
const link = document.createElement("a");
@@ -64,5 +64,10 @@ function getFeaturePresentation(
title: `排放口 ${displayId}`,
subtitle: `类型 ${formatValue(properties.outfall_type)} · 井底高程 ${formatValue(properties.invert_elevation)} m`
};
case "scada":
return {
title: String(properties.point_name || `SCADA 测点 ${displayId}`),
subtitle: `${formatValue(properties.device_type_name)} · 设备 ${formatValue(properties.device_external_id)}`
};
}
}
+53 -47
View File
@@ -3,10 +3,7 @@ import {
INTERACTIVE_HIT_LAYER_IDS,
waterNetworkLayers
} from "./layers";
import {
DRAINAGE_SELECTED_OUTLINE_COLORS,
DRAINAGE_SOURCE_COLORS
} from "./map-colors";
import { MAP_STYLE_TOKENS } from "./map-colors";
import { DRAINAGE_LAYER_VISUALS, MAP_MAX_ZOOM } from "./map-layer-visuals";
describe("drainage network interaction layers", () => {
@@ -56,61 +53,49 @@ describe("drainage network interaction layers", () => {
it("provides persistent selected layers for every source", () => {
const selectedLayerIds = [
"pipes-selected-halo",
"pipes-selected-outline",
"pipes-selected",
"junctions-selected-halo",
"junctions-selected",
"orifices-selected-halo",
"orifices-selected-outline",
"orifices-selected",
"pumps-selected-halo",
"pumps-selected-outline",
"pumps-selected",
"outfalls-selected-halo",
"outfalls-selected"
];
selectedLayerIds.forEach((layerId) => {
const layer = waterNetworkLayers.find((candidate) => candidate.id === layerId);
expect(layer && "filter" in layer ? layer.filter : undefined).toEqual(["==", ["get", "id"], ""]);
expect(layer && "minzoom" in layer ? layer.minzoom : undefined).toBeUndefined();
expect(layer && "filter" in layer ? layer.filter : undefined).toBeUndefined();
const paint = layer?.paint as Record<string, unknown> | undefined;
expect(JSON.stringify(paint)).toContain("feature-state");
expect(JSON.stringify(paint)).toContain("selected");
});
});
it("renders selected features as a halo, complementary outline, and source-color core", () => {
const expectedSelectedColors = [
["pipes-selected-outline", "line-color", DRAINAGE_SELECTED_OUTLINE_COLORS.conduits],
["pipes-selected", "line-color", DRAINAGE_SOURCE_COLORS.conduits],
["junctions-selected", "circle-stroke-color", DRAINAGE_SELECTED_OUTLINE_COLORS.junctions],
["junctions-selected", "circle-color", DRAINAGE_SOURCE_COLORS.junctions],
["orifices-selected-outline", "line-color", DRAINAGE_SELECTED_OUTLINE_COLORS.orifices],
["orifices-selected", "line-color", DRAINAGE_SOURCE_COLORS.orifices],
["pumps-selected-outline", "line-color", DRAINAGE_SELECTED_OUTLINE_COLORS.pumps],
["pumps-selected", "line-color", DRAINAGE_SOURCE_COLORS.pumps],
["outfalls-selected", "circle-stroke-color", DRAINAGE_SELECTED_OUTLINE_COLORS.outfalls],
["outfalls-selected", "circle-color", DRAINAGE_SOURCE_COLORS.outfalls]
it("renders selected features as opaque hollow outlines", () => {
const expectedLineOutlines = [
["pipes-selected-outline", DRAINAGE_LAYER_VISUALS.conduits],
["orifices-selected-outline", DRAINAGE_LAYER_VISUALS.orifices],
["pumps-selected-outline", DRAINAGE_LAYER_VISUALS.pumps]
] as const;
expectedSelectedColors.forEach(([layerId, paintProperty, color]) => {
expectedLineOutlines.forEach(([layerId, visuals]) => {
const layer = waterNetworkLayers.find((candidate) => candidate.id === layerId);
const paint = layer?.paint as Record<string, unknown> | undefined;
expect(paint?.[paintProperty]).toBe(color);
expect(paint?.["line-color"]).toBe(MAP_STYLE_TOKENS.state.selected);
expect(paint?.["line-gap-width"]).toBe(visuals.selectedWidth);
expect(paint?.["line-width"]).toBe(visuals.selectedBorderWidth);
expect(paint?.["line-opacity"]).toEqual(["case", ["boolean", ["feature-state", "selected"], false], 1, 0]);
});
const layerIds = waterNetworkLayers.map((layer) => layer.id);
["pipes", "orifices", "pumps"].forEach((prefix) => {
expect(layerIds.indexOf(`${prefix}-selected-halo`)).toBeLessThan(
layerIds.indexOf(`${prefix}-selected-outline`)
);
expect(layerIds.indexOf(`${prefix}-selected-outline`)).toBeLessThan(
layerIds.indexOf(`${prefix}-selected`)
);
});
["junctions", "outfalls"].forEach((prefix) => {
expect(layerIds.indexOf(`${prefix}-selected-halo`)).toBeLessThan(
layerIds.indexOf(`${prefix}-selected`)
);
const expectedPointOutlines = [
"junctions-selected",
"outfalls-selected"
] as const;
expectedPointOutlines.forEach((layerId) => {
const layer = waterNetworkLayers.find((candidate) => candidate.id === layerId);
const paint = layer?.paint as Record<string, unknown> | undefined;
expect(paint?.["circle-color"]).toBe(MAP_STYLE_TOKENS.canvas.transparent);
expect(JSON.stringify(paint?.["circle-opacity"])).toContain("feature-state");
expect(paint?.["circle-stroke-color"]).toBe(MAP_STYLE_TOKENS.state.selected);
expect(JSON.stringify(paint?.["circle-stroke-opacity"])).toContain("selected");
});
});
@@ -118,7 +103,7 @@ describe("drainage network interaction layers", () => {
expect(MAP_MAX_ZOOM).toBe(24);
expect(DRAINAGE_LAYER_VISUALS.junctions.minzoom).toBe(12);
expect(DRAINAGE_LAYER_VISUALS.junctions.radius).toEqual([
"interpolate", ["linear"], ["zoom"], 12, 1, 16, 3, 20, 5.5, 24, 8
"interpolate", ["linear"], ["zoom"], 12, 0.6, 14, 1, 15, 1.5, 16, 3, 20, 5.5, 24, 8
]);
expect(DRAINAGE_LAYER_VISUALS.outfalls.radius).toEqual([
"interpolate", ["linear"], ["zoom"], 11, 3, 16, 5, 20, 7, 24, 9
@@ -134,18 +119,39 @@ describe("drainage network interaction layers", () => {
expect((DRAINAGE_LAYER_VISUALS.junctions.hitRadius as unknown[]).at(-1)).toBe(14);
});
it("prioritizes continuous conduits before full junction detail at low zoom", () => {
expect(DRAINAGE_LAYER_VISUALS.conduits.width).toEqual([
"interpolate", ["linear"], ["zoom"],
9, 1.1,
13, 2,
17, ["+", 2.5, ["/", ["coalesce", ["get", "diameter"], 100], 360]],
22, ["+", 3.5, ["/", ["coalesce", ["get", "diameter"], 100], 300]],
24, ["+", 4, ["/", ["coalesce", ["get", "diameter"], 100], 280]]
]);
expect(DRAINAGE_LAYER_VISUALS.junctions.haloOpacity).toEqual([
"interpolate", ["linear"], ["zoom"], 12, 0, 14, 0, 15, 1
]);
const junctionHalo = waterNetworkLayers.find((layer) => layer.id === "junctions-halo");
const orifices = waterNetworkLayers.find((layer) => layer.id === "orifices");
const pumps = waterNetworkLayers.find((layer) => layer.id === "pumps");
expect((junctionHalo?.paint as Record<string, unknown>)?.["circle-opacity"]).toBe(DRAINAGE_LAYER_VISUALS.junctions.haloOpacity);
expect((orifices?.paint as Record<string, unknown>)?.["line-dasharray"]).toEqual([4, 2]);
expect((pumps?.paint as Record<string, unknown>)?.["line-dasharray"]).toEqual([6, 2, 1.5, 2]);
});
it("uses the centralized geometry tokens in rendered layers", () => {
const expectedGeometry = [
["pipes-flow", "line-width", DRAINAGE_LAYER_VISUALS.conduits.width],
["pipes-selected-halo", "line-width", DRAINAGE_LAYER_VISUALS.conduits.selectedHaloWidth],
["pipes-selected-outline", "line-width", DRAINAGE_LAYER_VISUALS.conduits.selectedBorderWidth],
["junctions", "circle-radius", DRAINAGE_LAYER_VISUALS.junctions.radius],
["junctions-selected-halo", "circle-radius", DRAINAGE_LAYER_VISUALS.junctions.selectedHaloRadius],
["junctions-selected", "circle-radius", DRAINAGE_LAYER_VISUALS.junctions.selectedRadius],
["orifices", "line-width", DRAINAGE_LAYER_VISUALS.orifices.width],
["orifices-selected", "line-width", DRAINAGE_LAYER_VISUALS.orifices.selectedWidth],
["orifices-selected-outline", "line-width", DRAINAGE_LAYER_VISUALS.orifices.selectedBorderWidth],
["pumps", "line-width", DRAINAGE_LAYER_VISUALS.pumps.width],
["pumps-selected", "line-width", DRAINAGE_LAYER_VISUALS.pumps.selectedWidth],
["pumps-selected-outline", "line-width", DRAINAGE_LAYER_VISUALS.pumps.selectedBorderWidth],
["outfalls", "circle-radius", DRAINAGE_LAYER_VISUALS.outfalls.radius],
["outfalls-selected-halo", "circle-radius", DRAINAGE_LAYER_VISUALS.outfalls.selectedHaloRadius]
["outfalls-selected", "circle-radius", DRAINAGE_LAYER_VISUALS.outfalls.selectedRadius]
] as const;
expectedGeometry.forEach(([layerId, paintProperty, visualToken]) => {
+84 -487
View File
@@ -1,493 +1,90 @@
import type { StyleSpecification } from "maplibre-gl";
import {
CLOSED_CONDUIT_COLOR,
DRAINAGE_SELECTED_OUTLINE_COLORS,
DRAINAGE_SOURCE_COLORS,
DRAINAGE_SOURCE_OPACITIES
} from "./map-colors";
import type { ExpressionSpecification, StyleSpecification } from "maplibre-gl";
import { DRAINAGE_LAYER_VISUALS } from "./map-layer-visuals";
import { MAP_STYLE_TOKENS } from "./map-colors";
import { SOURCE_LAYERS } from "./sources";
export { DRAINAGE_SOURCE_COLORS } from "./map-colors";
export const INTERACTIVE_HIT_LAYER_IDS = [
"conduits-hit",
"junctions-hit",
"orifices-hit",
"pumps-hit",
"outfalls-hit"
];
"conduits-hit", "junctions-hit", "orifices-hit", "pumps-hit", "outfalls-hit"
] as const;
export const waterNetworkLayers: StyleSpecification["layers"] = [
{
id: "pipes-casing",
type: "line",
source: "conduits",
"source-layer": SOURCE_LAYERS.conduits,
minzoom: DRAINAGE_LAYER_VISUALS.conduits.minzoom,
paint: {
"line-color": "rgba(255,255,255,0.86)",
"line-width": DRAINAGE_LAYER_VISUALS.conduits.casingWidth,
"line-opacity": 0.9
}
},
{
id: "pipes-flow",
type: "line",
source: "conduits",
"source-layer": SOURCE_LAYERS.conduits,
minzoom: DRAINAGE_LAYER_VISUALS.conduits.minzoom,
paint: {
"line-color": [
"case",
["==", ["get", "status"], "Closed"],
CLOSED_CONDUIT_COLOR,
DRAINAGE_SOURCE_COLORS.conduits
],
"line-width": DRAINAGE_LAYER_VISUALS.conduits.width,
"line-opacity": DRAINAGE_SOURCE_OPACITIES.conduits
}
},
{
id: "pipes-risk-glow",
type: "line",
source: "conduits",
"source-layer": SOURCE_LAYERS.conduits,
minzoom: 12,
filter: [">=", ["coalesce", ["get", "diameter"], 0], 600],
paint: {
"line-color": DRAINAGE_SOURCE_COLORS.conduits,
"line-width": DRAINAGE_LAYER_VISUALS.conduits.riskGlowWidth,
"line-blur": 2.5,
"line-opacity": 0.22
}
},
{
id: "pipes-hover-outline",
type: "line",
source: "conduits",
"source-layer": SOURCE_LAYERS.conduits,
minzoom: DRAINAGE_LAYER_VISUALS.conduits.minzoom,
filter: ["==", ["get", "id"], ""],
paint: {
"line-color": "#ffffff",
"line-width": DRAINAGE_LAYER_VISUALS.conduits.hoverOutlineWidth,
"line-opacity": 0.92
}
},
{
id: "pipes-hover",
type: "line",
source: "conduits",
"source-layer": SOURCE_LAYERS.conduits,
minzoom: DRAINAGE_LAYER_VISUALS.conduits.minzoom,
filter: ["==", ["get", "id"], ""],
paint: {
"line-color": DRAINAGE_SOURCE_COLORS.conduits,
"line-width": DRAINAGE_LAYER_VISUALS.conduits.hoverWidth,
"line-opacity": 1
}
},
{
id: "pipes-selected-halo",
type: "line",
source: "conduits",
"source-layer": SOURCE_LAYERS.conduits,
filter: ["==", ["get", "id"], ""],
paint: {
"line-color": "#ffffff",
"line-width": DRAINAGE_LAYER_VISUALS.conduits.selectedHaloWidth,
"line-opacity": 1
}
},
{
id: "pipes-selected-outline",
type: "line",
source: "conduits",
"source-layer": SOURCE_LAYERS.conduits,
filter: ["==", ["get", "id"], ""],
paint: {
"line-color": DRAINAGE_SELECTED_OUTLINE_COLORS.conduits,
"line-width": DRAINAGE_LAYER_VISUALS.conduits.selectedOutlineWidth,
"line-opacity": 1
}
},
{
id: "pipes-selected",
type: "line",
source: "conduits",
"source-layer": SOURCE_LAYERS.conduits,
filter: ["==", ["get", "id"], ""],
paint: {
"line-color": DRAINAGE_SOURCE_COLORS.conduits,
"line-width": DRAINAGE_LAYER_VISUALS.conduits.selectedWidth,
"line-opacity": 1
}
},
{
id: "junctions-halo",
type: "circle",
source: "junctions",
"source-layer": SOURCE_LAYERS.junctions,
minzoom: DRAINAGE_LAYER_VISUALS.junctions.minzoom,
paint: {
"circle-color": "#ffffff",
"circle-radius": DRAINAGE_LAYER_VISUALS.junctions.haloRadius,
"circle-opacity": 0.76,
"circle-stroke-color": "rgba(15,23,42,0.22)",
"circle-stroke-width": 1
}
},
{
id: "junctions",
type: "circle",
source: "junctions",
"source-layer": SOURCE_LAYERS.junctions,
minzoom: DRAINAGE_LAYER_VISUALS.junctions.minzoom,
paint: {
"circle-color": DRAINAGE_SOURCE_COLORS.junctions,
"circle-radius": DRAINAGE_LAYER_VISUALS.junctions.radius,
"circle-opacity": DRAINAGE_SOURCE_OPACITIES.junctions
}
},
{
id: "junctions-hover",
type: "circle",
source: "junctions",
"source-layer": SOURCE_LAYERS.junctions,
minzoom: DRAINAGE_LAYER_VISUALS.junctions.minzoom,
filter: ["==", ["get", "id"], ""],
paint: {
"circle-color": DRAINAGE_SOURCE_COLORS.junctions,
"circle-radius": DRAINAGE_LAYER_VISUALS.junctions.hoverRadius,
"circle-opacity": 0.24,
"circle-stroke-color": DRAINAGE_SOURCE_COLORS.junctions,
"circle-stroke-width": 2
}
},
{
id: "junctions-selected-halo",
type: "circle",
source: "junctions",
"source-layer": SOURCE_LAYERS.junctions,
filter: ["==", ["get", "id"], ""],
paint: {
"circle-color": "#ffffff",
"circle-radius": DRAINAGE_LAYER_VISUALS.junctions.selectedHaloRadius,
"circle-opacity": 0.92
}
},
{
id: "junctions-selected",
type: "circle",
source: "junctions",
"source-layer": SOURCE_LAYERS.junctions,
filter: ["==", ["get", "id"], ""],
paint: {
"circle-color": DRAINAGE_SOURCE_COLORS.junctions,
"circle-radius": DRAINAGE_LAYER_VISUALS.junctions.selectedRadius,
"circle-opacity": 1,
"circle-stroke-color": DRAINAGE_SELECTED_OUTLINE_COLORS.junctions,
"circle-stroke-width": 3
}
},
{
id: "orifices-casing",
type: "line",
source: "orifices",
"source-layer": SOURCE_LAYERS.orifices,
minzoom: DRAINAGE_LAYER_VISUALS.orifices.minzoom,
paint: {
"line-color": "#ffffff",
"line-width": DRAINAGE_LAYER_VISUALS.orifices.casingWidth,
"line-opacity": 0.9
}
},
{
id: "orifices",
type: "line",
source: "orifices",
"source-layer": SOURCE_LAYERS.orifices,
minzoom: DRAINAGE_LAYER_VISUALS.orifices.minzoom,
paint: {
"line-color": DRAINAGE_SOURCE_COLORS.orifices,
"line-width": DRAINAGE_LAYER_VISUALS.orifices.width,
"line-opacity": DRAINAGE_SOURCE_OPACITIES.orifices
}
},
{
id: "orifices-hover-outline",
type: "line",
source: "orifices",
"source-layer": SOURCE_LAYERS.orifices,
minzoom: DRAINAGE_LAYER_VISUALS.orifices.minzoom,
filter: ["==", ["get", "id"], ""],
paint: {
"line-color": "#ffffff",
"line-width": DRAINAGE_LAYER_VISUALS.orifices.hoverOutlineWidth,
"line-opacity": 0.92
}
},
{
id: "orifices-hover",
type: "line",
source: "orifices",
"source-layer": SOURCE_LAYERS.orifices,
minzoom: DRAINAGE_LAYER_VISUALS.orifices.minzoom,
filter: ["==", ["get", "id"], ""],
paint: {
"line-color": DRAINAGE_SOURCE_COLORS.orifices,
"line-width": DRAINAGE_LAYER_VISUALS.orifices.hoverWidth,
"line-opacity": 1
}
},
{
id: "orifices-selected-halo",
type: "line",
source: "orifices",
"source-layer": SOURCE_LAYERS.orifices,
filter: ["==", ["get", "id"], ""],
paint: {
"line-color": "#ffffff",
"line-width": DRAINAGE_LAYER_VISUALS.orifices.selectedHaloWidth,
"line-opacity": 1
}
},
{
id: "orifices-selected-outline",
type: "line",
source: "orifices",
"source-layer": SOURCE_LAYERS.orifices,
filter: ["==", ["get", "id"], ""],
paint: {
"line-color": DRAINAGE_SELECTED_OUTLINE_COLORS.orifices,
"line-width": DRAINAGE_LAYER_VISUALS.orifices.selectedOutlineWidth,
"line-opacity": 1
}
},
{
id: "orifices-selected",
type: "line",
source: "orifices",
"source-layer": SOURCE_LAYERS.orifices,
filter: ["==", ["get", "id"], ""],
paint: {
"line-color": DRAINAGE_SOURCE_COLORS.orifices,
"line-width": DRAINAGE_LAYER_VISUALS.orifices.selectedWidth,
"line-opacity": 1
}
},
{
id: "pumps-casing",
type: "line",
source: "pumps",
"source-layer": SOURCE_LAYERS.pumps,
minzoom: DRAINAGE_LAYER_VISUALS.pumps.minzoom,
paint: {
"line-color": "#ffffff",
"line-width": DRAINAGE_LAYER_VISUALS.pumps.casingWidth,
"line-opacity": 0.9
}
},
{
id: "pumps",
type: "line",
source: "pumps",
"source-layer": SOURCE_LAYERS.pumps,
minzoom: DRAINAGE_LAYER_VISUALS.pumps.minzoom,
paint: {
"line-color": DRAINAGE_SOURCE_COLORS.pumps,
"line-width": DRAINAGE_LAYER_VISUALS.pumps.width,
"line-opacity": DRAINAGE_SOURCE_OPACITIES.pumps
}
},
{
id: "pumps-hover-outline",
type: "line",
source: "pumps",
"source-layer": SOURCE_LAYERS.pumps,
minzoom: DRAINAGE_LAYER_VISUALS.pumps.minzoom,
filter: ["==", ["get", "id"], ""],
paint: {
"line-color": "#ffffff",
"line-width": DRAINAGE_LAYER_VISUALS.pumps.hoverOutlineWidth,
"line-opacity": 0.92
}
},
{
id: "pumps-hover",
type: "line",
source: "pumps",
"source-layer": SOURCE_LAYERS.pumps,
minzoom: DRAINAGE_LAYER_VISUALS.pumps.minzoom,
filter: ["==", ["get", "id"], ""],
paint: {
"line-color": DRAINAGE_SOURCE_COLORS.pumps,
"line-width": DRAINAGE_LAYER_VISUALS.pumps.hoverWidth,
"line-opacity": 1
}
},
{
id: "pumps-selected-halo",
type: "line",
source: "pumps",
"source-layer": SOURCE_LAYERS.pumps,
filter: ["==", ["get", "id"], ""],
paint: {
"line-color": "#ffffff",
"line-width": DRAINAGE_LAYER_VISUALS.pumps.selectedHaloWidth,
"line-opacity": 1
}
},
{
id: "pumps-selected-outline",
type: "line",
source: "pumps",
"source-layer": SOURCE_LAYERS.pumps,
filter: ["==", ["get", "id"], ""],
paint: {
"line-color": DRAINAGE_SELECTED_OUTLINE_COLORS.pumps,
"line-width": DRAINAGE_LAYER_VISUALS.pumps.selectedOutlineWidth,
"line-opacity": 1
}
},
{
id: "pumps-selected",
type: "line",
source: "pumps",
"source-layer": SOURCE_LAYERS.pumps,
filter: ["==", ["get", "id"], ""],
paint: {
"line-color": DRAINAGE_SOURCE_COLORS.pumps,
"line-width": DRAINAGE_LAYER_VISUALS.pumps.selectedWidth,
"line-opacity": 1
}
},
{
id: "outfalls-halo",
type: "circle",
source: "outfalls",
"source-layer": SOURCE_LAYERS.outfalls,
minzoom: DRAINAGE_LAYER_VISUALS.outfalls.minzoom,
paint: {
"circle-color": "#ffffff",
"circle-radius": DRAINAGE_LAYER_VISUALS.outfalls.haloRadius,
"circle-opacity": 0.9
}
},
{
id: "outfalls",
type: "circle",
source: "outfalls",
"source-layer": SOURCE_LAYERS.outfalls,
minzoom: DRAINAGE_LAYER_VISUALS.outfalls.minzoom,
paint: {
"circle-color": DRAINAGE_SOURCE_COLORS.outfalls,
"circle-radius": DRAINAGE_LAYER_VISUALS.outfalls.radius,
"circle-stroke-color": "#7F1D2D",
"circle-stroke-width": 1,
"circle-opacity": DRAINAGE_SOURCE_OPACITIES.outfalls
}
},
{
id: "outfalls-hover",
type: "circle",
source: "outfalls",
"source-layer": SOURCE_LAYERS.outfalls,
minzoom: DRAINAGE_LAYER_VISUALS.outfalls.minzoom,
filter: ["==", ["get", "id"], ""],
paint: {
"circle-color": DRAINAGE_SOURCE_COLORS.outfalls,
"circle-radius": DRAINAGE_LAYER_VISUALS.outfalls.hoverRadius,
"circle-opacity": 0.24,
"circle-stroke-color": DRAINAGE_SOURCE_COLORS.outfalls,
"circle-stroke-width": 2
}
},
{
id: "outfalls-selected-halo",
type: "circle",
source: "outfalls",
"source-layer": SOURCE_LAYERS.outfalls,
filter: ["==", ["get", "id"], ""],
paint: {
"circle-color": "#ffffff",
"circle-radius": DRAINAGE_LAYER_VISUALS.outfalls.selectedHaloRadius,
"circle-opacity": 0.92
}
},
{
id: "outfalls-selected",
type: "circle",
source: "outfalls",
"source-layer": SOURCE_LAYERS.outfalls,
filter: ["==", ["get", "id"], ""],
paint: {
"circle-color": DRAINAGE_SOURCE_COLORS.outfalls,
"circle-radius": DRAINAGE_LAYER_VISUALS.outfalls.selectedRadius,
"circle-opacity": 1,
"circle-stroke-color": DRAINAGE_SELECTED_OUTLINE_COLORS.outfalls,
"circle-stroke-width": 3
}
},
{
id: "conduits-hit",
type: "line",
source: "conduits",
"source-layer": SOURCE_LAYERS.conduits,
minzoom: DRAINAGE_LAYER_VISUALS.conduits.minzoom,
paint: {
"line-color": "#000000",
"line-width": DRAINAGE_LAYER_VISUALS.conduits.hitWidth,
"line-opacity": 0
}
},
{
id: "junctions-hit",
type: "circle",
source: "junctions",
"source-layer": SOURCE_LAYERS.junctions,
minzoom: DRAINAGE_LAYER_VISUALS.junctions.minzoom,
paint: {
"circle-color": "#000000",
"circle-radius": DRAINAGE_LAYER_VISUALS.junctions.hitRadius,
"circle-opacity": 0
}
},
{
id: "orifices-hit",
type: "line",
source: "orifices",
"source-layer": SOURCE_LAYERS.orifices,
minzoom: DRAINAGE_LAYER_VISUALS.orifices.minzoom,
paint: {
"line-color": "#000000",
"line-width": DRAINAGE_LAYER_VISUALS.orifices.hitWidth,
"line-opacity": 0
}
},
{
id: "pumps-hit",
type: "line",
source: "pumps",
"source-layer": SOURCE_LAYERS.pumps,
minzoom: DRAINAGE_LAYER_VISUALS.pumps.minzoom,
paint: {
"line-color": "#000000",
"line-width": DRAINAGE_LAYER_VISUALS.pumps.hitWidth,
"line-opacity": 0
}
},
{
id: "outfalls-hit",
type: "circle",
source: "outfalls",
"source-layer": SOURCE_LAYERS.outfalls,
minzoom: DRAINAGE_LAYER_VISUALS.outfalls.minzoom,
paint: {
"circle-color": "#000000",
"circle-radius": DRAINAGE_LAYER_VISUALS.outfalls.hitRadius,
"circle-opacity": 0
}
}
const hoveredOpacity: ExpressionSpecification = [
"case",
["all", ["boolean", ["feature-state", "hovered"], false], ["!", ["boolean", ["feature-state", "selected"], false]]],
1,
0
];
const selectedOpacity: ExpressionSpecification = [
"case", ["boolean", ["feature-state", "selected"], false], 1, 0
];
const stateColor = (sourceColor: string): ExpressionSpecification => [
"case",
["in", ["downcase", ["to-string", ["coalesce", ["get", "status"], ""]]], ["literal", ["closed", "off", "inactive"]]],
MAP_STYLE_TOKENS.state.inactive,
sourceColor
];
const opacityTransition = { duration: 120, delay: 0 } as const;
type Layer = NonNullable<StyleSpecification["layers"]>[number];
type AuthoredLayer = { id: string; type: string; [key: string]: unknown };
const layers: AuthoredLayer[] = [];
// Business sources: white casing, then category-colored core.
layers.push(
{ id: "pipes-casing", type: "line", source: "conduits", "source-layer": SOURCE_LAYERS.conduits, minzoom: DRAINAGE_LAYER_VISUALS.conduits.minzoom, paint: { "line-color": MAP_STYLE_TOKENS.canvas.casing, "line-width": DRAINAGE_LAYER_VISUALS.conduits.casingWidth, "line-opacity": MAP_STYLE_TOKENS.opacity.casing } },
{ id: "pipes-flow", type: "line", source: "conduits", "source-layer": SOURCE_LAYERS.conduits, minzoom: DRAINAGE_LAYER_VISUALS.conduits.minzoom, paint: { "line-color": stateColor(MAP_STYLE_TOKENS.asset.conduit), "line-width": DRAINAGE_LAYER_VISUALS.conduits.width, "line-opacity": MAP_STYLE_TOKENS.opacity.core } },
{ id: "junctions-halo", type: "circle", source: "junctions", "source-layer": SOURCE_LAYERS.junctions, minzoom: DRAINAGE_LAYER_VISUALS.junctions.minzoom, paint: { "circle-color": MAP_STYLE_TOKENS.canvas.casing, "circle-radius": DRAINAGE_LAYER_VISUALS.junctions.haloRadius, "circle-opacity": DRAINAGE_LAYER_VISUALS.junctions.haloOpacity } },
{ id: "junctions", type: "circle", source: "junctions", "source-layer": SOURCE_LAYERS.junctions, minzoom: DRAINAGE_LAYER_VISUALS.junctions.minzoom, paint: { "circle-color": stateColor(MAP_STYLE_TOKENS.asset.junction), "circle-radius": DRAINAGE_LAYER_VISUALS.junctions.radius, "circle-opacity": MAP_STYLE_TOKENS.opacity.core } },
{ id: "orifices-casing", type: "line", source: "orifices", "source-layer": SOURCE_LAYERS.orifices, minzoom: DRAINAGE_LAYER_VISUALS.orifices.minzoom, paint: { "line-color": MAP_STYLE_TOKENS.canvas.casing, "line-width": DRAINAGE_LAYER_VISUALS.orifices.casingWidth, "line-opacity": 1 } },
{ id: "orifices", type: "line", source: "orifices", "source-layer": SOURCE_LAYERS.orifices, minzoom: DRAINAGE_LAYER_VISUALS.orifices.minzoom, paint: { "line-color": stateColor(MAP_STYLE_TOKENS.asset.orifice), "line-width": DRAINAGE_LAYER_VISUALS.orifices.width, "line-dasharray": [4, 2], "line-opacity": MAP_STYLE_TOKENS.opacity.core } },
{ id: "pumps-casing", type: "line", source: "pumps", "source-layer": SOURCE_LAYERS.pumps, minzoom: DRAINAGE_LAYER_VISUALS.pumps.minzoom, paint: { "line-color": MAP_STYLE_TOKENS.canvas.casing, "line-width": DRAINAGE_LAYER_VISUALS.pumps.casingWidth, "line-opacity": 1 } },
{ id: "pumps", type: "line", source: "pumps", "source-layer": SOURCE_LAYERS.pumps, minzoom: DRAINAGE_LAYER_VISUALS.pumps.minzoom, paint: { "line-color": stateColor(MAP_STYLE_TOKENS.asset.pump), "line-width": DRAINAGE_LAYER_VISUALS.pumps.width, "line-dasharray": [6, 2, 1.5, 2], "line-opacity": MAP_STYLE_TOKENS.opacity.core } },
{ id: "outfalls-halo", type: "circle", source: "outfalls", "source-layer": SOURCE_LAYERS.outfalls, minzoom: DRAINAGE_LAYER_VISUALS.outfalls.minzoom, paint: { "circle-color": MAP_STYLE_TOKENS.canvas.casing, "circle-radius": DRAINAGE_LAYER_VISUALS.outfalls.haloRadius, "circle-opacity": 1 } },
{ id: "outfalls", type: "circle", source: "outfalls", "source-layer": SOURCE_LAYERS.outfalls, minzoom: DRAINAGE_LAYER_VISUALS.outfalls.minzoom, paint: { "circle-color": MAP_STYLE_TOKENS.canvas.casing, "circle-radius": DRAINAGE_LAYER_VISUALS.outfalls.radius, "circle-opacity": 1, "circle-stroke-color": stateColor(MAP_STYLE_TOKENS.asset.outfall), "circle-stroke-width": 2 } }
);
const lineInteractions = [
["pipes", "conduits", SOURCE_LAYERS.conduits, DRAINAGE_LAYER_VISUALS.conduits, MAP_STYLE_TOKENS.asset.conduit],
["orifices", "orifices", SOURCE_LAYERS.orifices, DRAINAGE_LAYER_VISUALS.orifices, MAP_STYLE_TOKENS.asset.orifice],
["pumps", "pumps", SOURCE_LAYERS.pumps, DRAINAGE_LAYER_VISUALS.pumps, MAP_STYLE_TOKENS.asset.pump]
] as const;
lineInteractions.forEach(([prefix, source, sourceLayer, visual, color]) => {
layers.push(
{ id: `${prefix}-hover-outline`, type: "line", source, "source-layer": sourceLayer, minzoom: visual.minzoom, paint: { "line-color": MAP_STYLE_TOKENS.canvas.casing, "line-width": visual.hoverOutlineWidth, "line-opacity": hoveredOpacity, "line-opacity-transition": opacityTransition } },
{ id: `${prefix}-hover`, type: "line", source, "source-layer": sourceLayer, minzoom: visual.minzoom, paint: { "line-color": stateColor(color), "line-width": visual.hoverWidth, "line-opacity": hoveredOpacity, "line-opacity-transition": opacityTransition } },
{ id: `${prefix}-selected-outer`, type: "line", source, "source-layer": sourceLayer, minzoom: visual.minzoom, paint: { "line-color": MAP_STYLE_TOKENS.canvas.casing, "line-gap-width": visual.selectedWidth, "line-width": 4.5, "line-opacity": selectedOpacity, "line-opacity-transition": opacityTransition } },
{ id: `${prefix}-selected-outline`, type: "line", source, "source-layer": sourceLayer, minzoom: visual.minzoom, paint: { "line-color": MAP_STYLE_TOKENS.state.selected, "line-gap-width": visual.selectedWidth, "line-width": visual.selectedBorderWidth, "line-opacity": selectedOpacity, "line-opacity-transition": opacityTransition } },
{ id: `${prefix}-selected-separator`, type: "line", source, "source-layer": sourceLayer, minzoom: visual.minzoom, paint: { "line-color": MAP_STYLE_TOKENS.canvas.casing, "line-gap-width": visual.selectedWidth, "line-width": 1, "line-offset": -1.5, "line-opacity": selectedOpacity, "line-opacity-transition": opacityTransition } }
);
});
const pointInteractions = [
["junctions", "junctions", SOURCE_LAYERS.junctions, DRAINAGE_LAYER_VISUALS.junctions, MAP_STYLE_TOKENS.asset.junction],
["outfalls", "outfalls", SOURCE_LAYERS.outfalls, DRAINAGE_LAYER_VISUALS.outfalls, MAP_STYLE_TOKENS.asset.outfall]
] as const;
pointInteractions.forEach(([prefix, source, sourceLayer, visual, color]) => {
layers.push(
{ id: `${prefix}-hover`, type: "circle", source, "source-layer": sourceLayer, minzoom: visual.minzoom, paint: { "circle-color": stateColor(color), "circle-radius": visual.hoverRadius, "circle-opacity": hoveredOpacity, "circle-stroke-color": MAP_STYLE_TOKENS.canvas.casing, "circle-stroke-width": 2, "circle-opacity-transition": opacityTransition, "circle-stroke-opacity": hoveredOpacity, "circle-stroke-opacity-transition": opacityTransition } },
{ id: `${prefix}-selected-outer`, type: "circle", source, "source-layer": sourceLayer, minzoom: visual.minzoom, paint: { "circle-color": MAP_STYLE_TOKENS.canvas.transparent, "circle-radius": visual.selectedRadius, "circle-opacity": selectedOpacity, "circle-stroke-color": MAP_STYLE_TOKENS.canvas.casing, "circle-stroke-width": 6, "circle-stroke-opacity": selectedOpacity, "circle-opacity-transition": opacityTransition, "circle-stroke-opacity-transition": opacityTransition } },
{ id: `${prefix}-selected`, type: "circle", source, "source-layer": sourceLayer, minzoom: visual.minzoom, paint: { "circle-color": MAP_STYLE_TOKENS.canvas.transparent, "circle-radius": visual.selectedRadius, "circle-opacity": selectedOpacity, "circle-stroke-color": MAP_STYLE_TOKENS.state.selected, "circle-stroke-width": 3, "circle-stroke-opacity": selectedOpacity, "circle-opacity-transition": opacityTransition, "circle-stroke-opacity-transition": opacityTransition } }
);
});
// Transparent hit layers stay last so pointer targeting is independent of paint order.
layers.push(
{ id: "conduits-hit", type: "line", source: "conduits", "source-layer": SOURCE_LAYERS.conduits, minzoom: DRAINAGE_LAYER_VISUALS.conduits.minzoom, paint: { "line-color": MAP_STYLE_TOKENS.canvas.transparent, "line-width": DRAINAGE_LAYER_VISUALS.conduits.hitWidth, "line-opacity": 0 } },
{ id: "junctions-hit", type: "circle", source: "junctions", "source-layer": SOURCE_LAYERS.junctions, minzoom: DRAINAGE_LAYER_VISUALS.junctions.minzoom, paint: { "circle-color": MAP_STYLE_TOKENS.canvas.transparent, "circle-radius": DRAINAGE_LAYER_VISUALS.junctions.hitRadius, "circle-opacity": 0 } },
{ id: "orifices-hit", type: "line", source: "orifices", "source-layer": SOURCE_LAYERS.orifices, minzoom: DRAINAGE_LAYER_VISUALS.orifices.minzoom, paint: { "line-color": MAP_STYLE_TOKENS.canvas.transparent, "line-width": DRAINAGE_LAYER_VISUALS.orifices.hitWidth, "line-opacity": 0 } },
{ id: "pumps-hit", type: "line", source: "pumps", "source-layer": SOURCE_LAYERS.pumps, minzoom: DRAINAGE_LAYER_VISUALS.pumps.minzoom, paint: { "line-color": MAP_STYLE_TOKENS.canvas.transparent, "line-width": DRAINAGE_LAYER_VISUALS.pumps.hitWidth, "line-opacity": 0 } },
{ id: "outfalls-hit", type: "circle", source: "outfalls", "source-layer": SOURCE_LAYERS.outfalls, minzoom: DRAINAGE_LAYER_VISUALS.outfalls.minzoom, paint: { "circle-color": MAP_STYLE_TOKENS.canvas.transparent, "circle-radius": DRAINAGE_LAYER_VISUALS.outfalls.hitRadius, "circle-opacity": 0 } }
);
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]);
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 WORKBENCH_INTERACTION_BEFORE_ID = "pipes-hover-outline";
+37 -30
View File
@@ -1,31 +1,38 @@
export const MAP_BACKGROUND_COLORS = {
primary: "#F9FAFB",
secondary: "#F0F1F3",
secondaryAlt: "#FAFBFC"
/** Runtime map colors. Keep literal colors in this file only. */
export const MAP_STYLE_TOKENS = {
canvas: {
primary: "#F9FAFB",
secondary: "#F0F1F3",
secondaryAlt: "#FAFBFC",
casing: "#FFFFFF",
transparent: "rgba(0,0,0,0)"
},
asset: {
conduit: "#0F766E",
junction: "#0369A1",
orifice: "#475569",
pump: "#155E75",
outfall: "#334155"
},
state: {
selected: "#2563EB",
inactive: "#64748B",
risk: "#C2410C",
incident: "#B91C1C",
success: "#15803D",
agent: "#6D28D9"
},
tool: {
geometry: "#334155"
},
label: {
primary: "#0F172A",
secondary: "#334155",
inverse: "#FFFFFF"
},
opacity: {
core: 0.9,
casing: 1,
area: 0.14
}
} as const;
export const DRAINAGE_SOURCE_COLORS = {
conduits: "#0F766E",
junctions: "#B45309",
orifices: "#6D28D9",
pumps: "#1D4ED8",
outfalls: "#B91C1C"
} as const;
export const DRAINAGE_SOURCE_OPACITIES = {
conduits: 0.88,
junctions: 0.9,
orifices: 0.92,
pumps: 0.92,
outfalls: 0.95
} as const;
export const DRAINAGE_SELECTED_OUTLINE_COLORS = {
conduits: "#BE123C",
junctions: "#1D4ED8",
orifices: "#3F6212",
pumps: "#C2410C",
outfalls: "#0E7490"
} as const;
export const CLOSED_CONDUIT_COLOR = "#64748B";
@@ -16,7 +16,8 @@ describe("workbench source layer controls", () => {
"junctions",
"orifices",
"outfalls",
"pumps"
"pumps",
"scada"
]);
expect(items.slice(0, 5).map((item) => item.description)).toEqual([
"lingang:geo_conduits_mat",
@@ -37,16 +38,16 @@ describe("workbench source layer controls", () => {
"orifices",
"orifices-hover-outline",
"orifices-hover",
"orifices-selected-halo",
"orifices-selected-outer",
"orifices-selected-outline",
"orifices-selected",
"orifices-selected-separator",
"orifices-hit"
]);
expect(getWorkbenchLayerIds(map, "outfalls")).toEqual([
"outfalls-halo",
"outfalls",
"outfalls-hover",
"outfalls-selected-halo",
"outfalls-selected-outer",
"outfalls-selected",
"outfalls-hit"
]);
+17 -8
View File
@@ -2,7 +2,7 @@ import type { Map as MapLibreMap } from "maplibre-gl";
import type { BaseLayerOption } from "@/features/map/core/components/base-layers-control";
import type { MapLayerControlItem } from "@/features/map/core/components/layer-control";
import type { MapLegendItem } from "@/features/map/core/components/legend";
import { DRAINAGE_SOURCE_COLORS } from "./map-colors";
import { MAP_STYLE_TOKENS } from "./map-colors";
import {
SOURCE_LAYERS,
WATER_NETWORK_SOURCE_IDS,
@@ -28,6 +28,7 @@ export const INITIAL_LAYER_VISIBILITY: Record<string, boolean> = {
orifices: true,
outfalls: true,
pumps: true,
scada: true,
simulation: false
};
@@ -36,16 +37,24 @@ const SOURCE_CONTROL_LABELS: Record<WaterNetworkSourceId, { label: string; descr
junctions: { label: "检查井", description: `lingang:${SOURCE_LAYERS.junctions}` },
orifices: { label: "孔口", description: `lingang:${SOURCE_LAYERS.orifices}` },
outfalls: { label: "排放口", description: `lingang:${SOURCE_LAYERS.outfalls}` },
pumps: { label: "泵", description: `lingang:${SOURCE_LAYERS.pumps}` }
pumps: { label: "泵", description: `lingang:${SOURCE_LAYERS.pumps}` },
scada: { label: "SCADA 测点", description: `lingang:${SOURCE_LAYERS.scada}` }
};
export const MAP_LEGEND_ITEMS: MapLegendItem[] = [
{ id: "conduit", label: "管渠(线宽表示口径)", color: DRAINAGE_SOURCE_COLORS.conduits, shape: "line" },
{ id: "junction", label: "检查井", color: DRAINAGE_SOURCE_COLORS.junctions, shape: "dot" },
{ id: "orifice", label: "孔口", color: DRAINAGE_SOURCE_COLORS.orifices, shape: "line" },
{ id: "pump", label: "泵", color: DRAINAGE_SOURCE_COLORS.pumps, shape: "line" },
{ id: "outfall", label: "排放口", color: DRAINAGE_SOURCE_COLORS.outfalls, shape: "dot" },
{ id: "impact-area", label: "影响范围", color: "#ef4444", shape: "square" }
{ id: "conduit", label: "管渠(线宽表示口径)", color: MAP_STYLE_TOKENS.asset.conduit, shape: "line" },
{ id: "junction", label: "检查井", color: MAP_STYLE_TOKENS.asset.junction, shape: "dot" },
{ id: "orifice", label: "孔口", color: MAP_STYLE_TOKENS.asset.orifice, shape: "dashed-line" },
{ id: "pump", label: "泵", color: MAP_STYLE_TOKENS.asset.pump, shape: "dash-dot-line" },
{ id: "outfall", label: "排放口", color: MAP_STYLE_TOKENS.asset.outfall, shape: "ring" },
{ id: "scada-quality", label: "水质仪", color: "#0F766E", imageSrc: "/icons/scada-water-quality.svg" },
{ id: "scada-level", label: "雷达液位计", color: "#2563EB", imageSrc: "/icons/scada-radar-level.svg" },
{ id: "scada-flow", label: "超声流量计", color: "#EA580C", imageSrc: "/icons/scada-ultrasonic-flow.svg" },
{ id: "inactive", label: "停用 / 关闭", color: MAP_STYLE_TOKENS.state.inactive, shape: "line" },
{ id: "impact-area", label: "风险影响范围", color: MAP_STYLE_TOKENS.state.risk, shape: "square" },
{ id: "incident", label: "事故 / 爆管", color: MAP_STYLE_TOKENS.state.incident, shape: "dot" },
{ id: "agent", label: "Agent 推断", color: MAP_STYLE_TOKENS.state.agent, shape: "dot" },
{ id: "selected", label: "当前选中", color: MAP_STYLE_TOKENS.state.selected, shape: "ring" }
];
export const BASE_LAYER_IDS = ["mapbox-light", "mapbox-satellite"] as const;
+10 -15
View File
@@ -41,34 +41,32 @@ export const DRAINAGE_LAYER_VISUALS = {
minzoom: 9,
casingWidth: [
"interpolate", ["linear"], ["zoom"],
9, 1.8,
13, 3,
9, 1.7,
13, 2.8,
17, ["+", 4.5, ["/", ["coalesce", ["get", "diameter"], 100], 300]],
22, ["+", 6, ["/", ["coalesce", ["get", "diameter"], 100], 260]],
24, ["+", 7, ["/", ["coalesce", ["get", "diameter"], 100], 240]]
] as LineWidth,
width: [
"interpolate", ["linear"], ["zoom"],
9, 0.9,
13, 1.8,
9, 1.1,
13, 2,
17, ["+", 2.5, ["/", ["coalesce", ["get", "diameter"], 100], 360]],
22, ["+", 3.5, ["/", ["coalesce", ["get", "diameter"], 100], 300]],
24, ["+", 4, ["/", ["coalesce", ["get", "diameter"], 100], 280]]
] as LineWidth,
riskGlowWidth: ["interpolate", ["linear"], ["zoom"], 12, 2.6, 17, 7.5, 22, 9.5, 24, 11] as LineWidth,
hoverOutlineWidth: ["interpolate", ["linear"], ["zoom"], 9, 5.5, 13, 8, 17, 12.5, 22, 15, 24, 17] as LineWidth,
hoverWidth: ["interpolate", ["linear"], ["zoom"], 9, 3, 13, 5.2, 17, 9, 22, 11.5, 24, 13] as LineWidth,
selectedHaloWidth: ["interpolate", ["linear"], ["zoom"], 9, 7, 13, 10, 17, 14.5, 22, 17.5, 24, 20] as LineWidth,
selectedOutlineWidth: ["interpolate", ["linear"], ["zoom"], 9, 5.5, 13, 8.2, 17, 12.5, 22, 15.5, 24, 18] as LineWidth,
selectedWidth: ["interpolate", ["linear"], ["zoom"], 9, 4, 13, 6.5, 17, 10.5, 22, 13.5, 24, 16] as LineWidth,
selectedBorderWidth: ["interpolate", ["linear"], ["zoom"], 9, 1.2, 13, 1.5, 17, 2, 22, 2.5, 24, 3] as LineWidth,
hitWidth: lineHitWidth
},
junctions: {
minzoom: 12,
haloRadius: ["interpolate", ["linear"], ["zoom"], 12, 2.5, 16, 4.5, 20, 7, 24, 9.5] as CircleRadius,
radius: ["interpolate", ["linear"], ["zoom"], 12, 1, 16, 3, 20, 5.5, 24, 8] as CircleRadius,
haloRadius: ["interpolate", ["linear"], ["zoom"], 12, 1.2, 14, 1.8, 15, 2.8, 16, 4.5, 20, 7, 24, 9.5] as CircleRadius,
haloOpacity: ["interpolate", ["linear"], ["zoom"], 12, 0, 14, 0, 15, 1] as const,
radius: ["interpolate", ["linear"], ["zoom"], 12, 0.6, 14, 1, 15, 1.5, 16, 3, 20, 5.5, 24, 8] as CircleRadius,
hoverRadius: ["interpolate", ["linear"], ["zoom"], 12, 5, 16, 7, 20, 10, 24, 12] as CircleRadius,
selectedHaloRadius: ["interpolate", ["linear"], ["zoom"], 12, 10.5, 16, 12.5, 20, 15.5, 24, 18] as CircleRadius,
selectedRadius: ["interpolate", ["linear"], ["zoom"], 12, 5.5, 16, 7.5, 20, 10.5, 24, 13] as CircleRadius,
hitRadius: pointHitRadius
},
@@ -78,9 +76,8 @@ export const DRAINAGE_LAYER_VISUALS = {
width: ["interpolate", ["linear"], ["zoom"], 11, 1.6, 17, 4.5, 22, 6, 24, 7] as LineWidth,
hoverOutlineWidth: ["interpolate", ["linear"], ["zoom"], 11, 6, 17, 10.5, 22, 12.5, 24, 14] as LineWidth,
hoverWidth: ["interpolate", ["linear"], ["zoom"], 11, 4, 17, 7, 22, 9, 24, 10.5] as LineWidth,
selectedHaloWidth: ["interpolate", ["linear"], ["zoom"], 11, 8, 17, 13, 22, 16, 24, 18] as LineWidth,
selectedOutlineWidth: ["interpolate", ["linear"], ["zoom"], 11, 6.75, 17, 11, 22, 14, 24, 16] as LineWidth,
selectedWidth: ["interpolate", ["linear"], ["zoom"], 11, 5.5, 17, 9, 22, 12, 24, 14] as LineWidth,
selectedBorderWidth: ["interpolate", ["linear"], ["zoom"], 11, 1.25, 17, 1.75, 22, 2.25, 24, 2.5] as LineWidth,
hitWidth: lineHitWidth
},
pumps: {
@@ -89,9 +86,8 @@ export const DRAINAGE_LAYER_VISUALS = {
width: ["interpolate", ["linear"], ["zoom"], 11, 2.4, 17, 6.2, 22, 8, 24, 9] as LineWidth,
hoverOutlineWidth: ["interpolate", ["linear"], ["zoom"], 11, 7, 17, 12, 22, 14, 24, 16] as LineWidth,
hoverWidth: ["interpolate", ["linear"], ["zoom"], 11, 4.5, 17, 8.5, 22, 10.5, 24, 12] as LineWidth,
selectedHaloWidth: ["interpolate", ["linear"], ["zoom"], 11, 9, 17, 14, 22, 17, 24, 19] as LineWidth,
selectedOutlineWidth: ["interpolate", ["linear"], ["zoom"], 11, 7.5, 17, 12, 22, 15, 24, 17] as LineWidth,
selectedWidth: ["interpolate", ["linear"], ["zoom"], 11, 6, 17, 10, 22, 13, 24, 15] as LineWidth,
selectedBorderWidth: ["interpolate", ["linear"], ["zoom"], 11, 1.25, 17, 1.75, 22, 2.25, 24, 2.5] as LineWidth,
hitWidth: lineHitWidth
},
outfalls: {
@@ -99,7 +95,6 @@ export const DRAINAGE_LAYER_VISUALS = {
haloRadius: ["interpolate", ["linear"], ["zoom"], 11, 5, 16, 7, 20, 9, 24, 11] as CircleRadius,
radius: ["interpolate", ["linear"], ["zoom"], 11, 3, 16, 5, 20, 7, 24, 9] as CircleRadius,
hoverRadius: ["interpolate", ["linear"], ["zoom"], 11, 6, 16, 8, 20, 11, 24, 13] as CircleRadius,
selectedHaloRadius: ["interpolate", ["linear"], ["zoom"], 11, 12.5, 16, 14.5, 20, 17.5, 24, 20] as CircleRadius,
selectedRadius: ["interpolate", ["linear"], ["zoom"], 11, 7.5, 16, 9.5, 20, 12.5, 24, 15] as CircleRadius,
hitRadius: pointHitRadius
}
+9 -7
View File
@@ -6,6 +6,8 @@ import type {
} from "maplibre-gl";
import type { FeatureCollection, Geometry, LineString, Point, Polygon } from "geojson";
import { SOURCE_LAYERS } from "./sources";
import { MAP_STYLE_TOKENS } from "./map-colors";
import { WORKBENCH_INTERACTION_BEFORE_ID } from "./layers";
export const MEASUREMENT_SOURCE_ID = "workbench-measurement";
export const MEASUREMENT_SELECTED_PIPES_LAYER_ID = "workbench-measurement-selected-pipes";
@@ -31,7 +33,7 @@ export const measurementLayers: StyleSpecification["layers"] = [
minzoom: 9,
filter: ["in", ["get", "id"], ["literal", []]],
paint: {
"line-color": "#2563eb",
"line-color": MAP_STYLE_TOKENS.state.selected,
"line-width": ["interpolate", ["linear"], ["zoom"], 9, 4, 13, 7, 17, 11],
"line-opacity": 0.68
}
@@ -42,7 +44,7 @@ export const measurementLayers: StyleSpecification["layers"] = [
source: MEASUREMENT_SOURCE_ID,
filter: ["==", ["get", "kind"], "polygon"],
paint: {
"fill-color": "#2563eb",
"fill-color": MAP_STYLE_TOKENS.tool.geometry,
"fill-opacity": 0.12
}
},
@@ -52,7 +54,7 @@ export const measurementLayers: StyleSpecification["layers"] = [
source: MEASUREMENT_SOURCE_ID,
filter: ["any", ["==", ["get", "kind"], "line"], ["==", ["get", "kind"], "polygon"]],
paint: {
"line-color": "#2563eb",
"line-color": MAP_STYLE_TOKENS.tool.geometry,
"line-width": 2.5,
"line-dasharray": [2, 1.5],
"line-opacity": 0.9
@@ -64,9 +66,9 @@ export const measurementLayers: StyleSpecification["layers"] = [
source: MEASUREMENT_SOURCE_ID,
filter: ["==", ["get", "kind"], "vertex"],
paint: {
"circle-color": "#2563eb",
"circle-color": MAP_STYLE_TOKENS.state.selected,
"circle-radius": 4,
"circle-stroke-color": "#ffffff",
"circle-stroke-color": MAP_STYLE_TOKENS.canvas.casing,
"circle-stroke-width": 1.5
}
}
@@ -76,7 +78,7 @@ export function ensureMeasurementLayers(map: {
getSource: (id: string) => unknown;
addSource: (id: string, source: GeoJSONSourceSpecification) => void;
getLayer: (id: string) => unknown;
addLayer: (layer: NonNullable<StyleSpecification["layers"]>[number]) => void;
addLayer: (layer: NonNullable<StyleSpecification["layers"]>[number], beforeId?: string) => void;
}) {
if (!map.getSource(MEASUREMENT_SOURCE_ID)) {
map.addSource(MEASUREMENT_SOURCE_ID, measurementSource);
@@ -84,7 +86,7 @@ export function ensureMeasurementLayers(map: {
measurementLayers.forEach((layer) => {
if (!map.getLayer(layer.id)) {
map.addLayer(layer);
map.addLayer(layer, WORKBENCH_INTERACTION_BEFORE_ID);
}
});
}
+78
View File
@@ -0,0 +1,78 @@
import type { Map as MapLibreMap } from "maplibre-gl";
import { describe, expect, it, vi } from "vitest";
import {
SCADA_HIT_LAYER_ID,
SCADA_ICON_EXPRESSION,
SCADA_IMAGE_IDS,
SCADA_MAP_ICON_PATHS,
registerScadaImages,
scadaFallbackLayers,
scadaLayers
} from "./scada";
describe("SCADA map presentation", () => {
it("maps the three authoritative device types and falls back to unknown", () => {
expect(SCADA_ICON_EXPRESSION).toEqual([
"match", ["to-number", ["get", "device_type_id"]],
72, SCADA_IMAGE_IDS.waterQuality,
58, SCADA_IMAGE_IDS.radarLevel,
45, SCADA_IMAGE_IDS.ultrasonicFlow,
SCADA_IMAGE_IDS.unknown
]);
});
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);
});
it("uses MapLibre-compatible PNG assets", () => {
expect(Object.values(SCADA_MAP_ICON_PATHS)).toHaveLength(4);
Object.values(SCADA_MAP_ICON_PATHS).forEach((path) => expect(path).toMatch(/\.png$/));
});
it("registers all map images at two-times pixel density", async () => {
const image = { width: 64, height: 64 };
const map = createImageMap(() => Promise.resolve({ data: image }));
await expect(registerScadaImages(map)).resolves.toEqual({ status: "ready", failedImageIds: [] });
expect(map.loadImage).toHaveBeenCalledTimes(4);
expect(map.addImage).toHaveBeenCalledTimes(4);
expect(map.addImage).toHaveBeenCalledWith(expect.any(String), image, { pixelRatio: 2 });
});
it("falls back to the unknown image when one category fails", async () => {
const image = { width: 64, height: 64 };
const map = createImageMap((path) => path.includes("radar-level")
? Promise.reject(new Error("decode failed"))
: Promise.resolve({ data: image }));
await expect(registerScadaImages(map)).resolves.toEqual({
status: "degraded",
failedImageIds: [SCADA_IMAGE_IDS.radarLevel]
});
expect(map.addImage).toHaveBeenCalledWith(SCADA_IMAGE_IDS.radarLevel, image, { pixelRatio: 2 });
});
it("returns unavailable instead of rejecting when the fallback image fails", async () => {
const map = createImageMap(() => Promise.reject(new Error("unsupported image")));
await expect(registerScadaImages(map)).resolves.toEqual({
status: "unavailable",
failedImageIds: Object.values(SCADA_IMAGE_IDS)
});
expect(scadaFallbackLayers[0]?.id).toBe("scada-fallback");
});
});
function createImageMap(loadImage: (path: string) => Promise<{ data: { width: number; height: number } }>) {
const loadedImageIds = new Set<string>();
return {
loadImage: vi.fn(loadImage),
hasImage: vi.fn((imageId: string) => loadedImageIds.has(imageId)),
addImage: vi.fn((imageId: string) => loadedImageIds.add(imageId))
} as unknown as Pick<MapLibreMap, "loadImage" | "addImage" | "hasImage"> & {
loadImage: ReturnType<typeof vi.fn>;
addImage: ReturnType<typeof vi.fn>;
};
}
+122
View File
@@ -0,0 +1,122 @@
import type { ExpressionSpecification, Map as MapLibreMap, StyleSpecification } from "maplibre-gl";
export const SCADA_SOURCE_ID = "scada";
export const SCADA_SOURCE_LAYER = "scada_points";
export const SCADA_HIT_LAYER_ID = "scada-hit";
export const SCADA_ICON_PATHS = {
waterQuality: "/icons/scada-water-quality.svg",
radarLevel: "/icons/scada-radar-level.svg",
ultrasonicFlow: "/icons/scada-ultrasonic-flow.svg",
unknown: "/icons/scada-unknown.svg"
} as const;
export const SCADA_MAP_ICON_PATHS = {
waterQuality: "/icons/scada-water-quality.png",
radarLevel: "/icons/scada-radar-level.png",
ultrasonicFlow: "/icons/scada-ultrasonic-flow.png",
unknown: "/icons/scada-unknown.png"
} as const;
export const SCADA_IMAGE_IDS = {
waterQuality: "scada-water-quality",
radarLevel: "scada-radar-level",
ultrasonicFlow: "scada-ultrasonic-flow",
unknown: "scada-unknown"
} as const;
export const SCADA_ICON_EXPRESSION: ExpressionSpecification = [
"match", ["to-number", ["get", "device_type_id"]],
72, SCADA_IMAGE_IDS.waterQuality,
58, SCADA_IMAGE_IDS.radarLevel,
45, SCADA_IMAGE_IDS.ultrasonicFlow,
SCADA_IMAGE_IDS.unknown
];
const stateOpacity = (state: "hovered" | "selected"): ExpressionSpecification => [
"case", ["boolean", ["feature-state", state], false], 1, 0
];
type Layer = NonNullable<StyleSpecification["layers"]>[number];
export type ScadaImageRegistrationResult = {
status: "ready" | "degraded" | "unavailable";
failedImageIds: string[];
};
const scadaSymbolLayer: Layer = {
id: "scada-symbol", type: "symbol", source: SCADA_SOURCE_ID, "source-layer": SCADA_SOURCE_LAYER, minzoom: 12,
layout: { "icon-image": SCADA_ICON_EXPRESSION, "icon-size": ["interpolate", ["linear"], ["zoom"], 12, 0.625, 20, 0.875], "icon-allow-overlap": true, "icon-ignore-placement": true }
};
const scadaFallbackLayer: Layer = {
id: "scada-fallback", type: "circle", source: SCADA_SOURCE_ID, "source-layer": SCADA_SOURCE_LAYER, minzoom: 12,
paint: { "circle-radius": ["interpolate", ["linear"], ["zoom"], 12, 5, 20, 8], "circle-color": "#0E7490", "circle-stroke-color": "white", "circle-stroke-width": 2 }
};
const scadaInteractionLayers: Layer[] = [
{ id: "scada-hover", type: "circle", source: SCADA_SOURCE_ID, "source-layer": SCADA_SOURCE_LAYER, minzoom: 12, paint: { "circle-radius": 14, "circle-color": "rgba(255,255,255,0)", "circle-stroke-color": "#0E7490", "circle-stroke-width": 2, "circle-opacity": stateOpacity("hovered"), "circle-stroke-opacity": stateOpacity("hovered") } },
{ id: "scada-selected-outer", type: "circle", source: SCADA_SOURCE_ID, "source-layer": SCADA_SOURCE_LAYER, minzoom: 12, paint: { "circle-radius": 16, "circle-color": "rgba(255,255,255,0)", "circle-stroke-color": "white", "circle-stroke-width": 6, "circle-opacity": stateOpacity("selected"), "circle-stroke-opacity": stateOpacity("selected") } },
{ id: "scada-selected", type: "circle", source: SCADA_SOURCE_ID, "source-layer": SCADA_SOURCE_LAYER, minzoom: 12, paint: { "circle-radius": 16, "circle-color": "rgba(255,255,255,0)", "circle-stroke-color": "#2563EB", "circle-stroke-width": 3, "circle-opacity": stateOpacity("selected"), "circle-stroke-opacity": stateOpacity("selected") } },
{ id: SCADA_HIT_LAYER_ID, type: "circle", source: SCADA_SOURCE_ID, "source-layer": SCADA_SOURCE_LAYER, minzoom: 12, paint: { "circle-radius": 16, "circle-color": "rgba(0,0,0,0)", "circle-opacity": 0 } }
];
export const scadaLayers: Layer[] = [
scadaSymbolLayer,
...scadaInteractionLayers
];
export const scadaFallbackLayers: Layer[] = [
scadaFallbackLayer,
...scadaInteractionLayers
];
export async function registerScadaImages(
map: Pick<MapLibreMap, "loadImage" | "addImage" | "hasImage">
): Promise<ScadaImageRegistrationResult> {
let unknown: Awaited<ReturnType<typeof loadImage>>;
try {
unknown = await loadImage(map, SCADA_MAP_ICON_PATHS.unknown);
addImageIfMissing(map, SCADA_IMAGE_IDS.unknown, unknown);
} catch {
return { status: "unavailable", failedImageIds: Object.values(SCADA_IMAGE_IDS) };
}
const failedImageIds: string[] = [];
for (const [key, path] of Object.entries(SCADA_MAP_ICON_PATHS)) {
if (key === "unknown") continue;
const imageId = SCADA_IMAGE_IDS[key as Exclude<keyof typeof SCADA_IMAGE_IDS, "unknown">];
if (map.hasImage(imageId)) continue;
try {
const image = await loadImage(map, path);
addImageIfMissing(map, imageId, image);
} catch {
failedImageIds.push(imageId);
try {
addImageIfMissing(map, imageId, unknown);
} catch {
return { status: "unavailable", failedImageIds };
}
}
}
return {
status: failedImageIds.length > 0 ? "degraded" : "ready",
failedImageIds
};
}
async function loadImage(map: Pick<MapLibreMap, "loadImage">, path: string) {
const response = await map.loadImage(path);
return response.data;
}
function addImageIfMissing(
map: Pick<MapLibreMap, "addImage" | "hasImage">,
imageId: string,
image: Awaited<ReturnType<typeof loadImage>>
) {
if (!map.hasImage(imageId)) map.addImage(imageId, image, { pixelRatio: 2 });
}
+11 -6
View File
@@ -1,5 +1,6 @@
import type { VectorSourceSpecification, StyleSpecification } from "maplibre-gl";
import { MAP_BACKGROUND_COLORS } from "./map-colors";
import { MAP_URL } from "../../../lib/config";
import { MAP_STYLE_TOKENS } from "./map-colors";
export const WATER_NETWORK_GLOBAL_VIEW = {
bbox3857: [
@@ -15,7 +16,8 @@ export const SOURCE_LAYERS = {
junctions: "geo_junctions_mat",
orifices: "geo_orifices_mat",
outfalls: "geo_outfalls_mat",
pumps: "geo_pumps_mat"
pumps: "geo_pumps_mat",
scada: "scada_points"
} as const;
export const WATER_NETWORK_SOURCE_IDS = [
@@ -23,12 +25,13 @@ export const WATER_NETWORK_SOURCE_IDS = [
"junctions",
"orifices",
"outfalls",
"pumps"
"pumps",
"scada"
] as const;
export type WaterNetworkSourceId = (typeof WATER_NETWORK_SOURCE_IDS)[number];
const GEOSERVER_WMTS_ROOT = "https://geoserver.waternetwork.cn/geoserver/gwc/service/wmts/rest";
const GEOSERVER_WMTS_ROOT = `${MAP_URL}/gwc/service/wmts/rest`;
export function createWaterNetworkSources() {
return {
@@ -56,7 +59,8 @@ export function createWaterNetworkSources() {
SOURCE_LAYERS.pumps,
"line",
[121.73585149761436, 30.861759757577705, 121.9498481645973, 30.983213202338085]
)
),
scada: createLingangVectorSource(SOURCE_LAYERS.scada, "point", [121.7350340307058, 30.84636502815, 121.97051839928491, 30.994737681416165])
};
}
@@ -67,6 +71,7 @@ function createLingangVectorSource(
): VectorSourceSpecification {
return {
type: "vector",
promoteId: "id",
tiles: [
`${GEOSERVER_WMTS_ROOT}/lingang:${sourceLayer}/${style}/WebMercatorQuad/{z}/{y}/{x}?format=application/vnd.mapbox-vector-tile`
],
@@ -83,7 +88,7 @@ export function createBaseStyle(mapboxToken?: string): StyleSpecification {
id: "background",
type: "background",
paint: {
"background-color": MAP_BACKGROUND_COLORS.primary
"background-color": MAP_STYLE_TOKENS.canvas.primary
}
}
];