feat: initialize TJWater WebGIS frontend

This commit is contained in:
2026-07-21 18:31:08 +08:00
commit ad5e5d3133
191 changed files with 37963 additions and 0 deletions
@@ -0,0 +1,140 @@
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",
annotations: "simulation-annotations"
} as const;
export const simulationSources = {
impactArea: {
type: "geojson",
data: impactAreaPolygon
} satisfies GeoJSONSourceSpecification,
annotations: {
type: "geojson",
data: annotationPoints
} satisfies GeoJSONSourceSpecification
};
export const simulationAnnotationLayers: StyleSpecification["layers"] = [
{
id: "simulation-impact-fill",
type: "fill",
source: SIMULATION_SOURCE_IDS.impactArea,
paint: {
"fill-color": MAP_STYLE_TOKENS.state.risk,
"fill-opacity": MAP_STYLE_TOKENS.opacity.area
}
},
{
id: "simulation-impact-outline",
type: "line",
source: SIMULATION_SOURCE_IDS.impactArea,
paint: {
"line-color": MAP_STYLE_TOKENS.state.risk,
"line-width": 1.5,
"line-dasharray": [2, 2],
"line-opacity": 0.72
}
},
{
id: "simulation-burst-halo",
type: "circle",
source: SIMULATION_SOURCE_IDS.annotations,
filter: ["==", ["get", "kind"], "burst"],
paint: {
"circle-color": MAP_STYLE_TOKENS.canvas.casing,
"circle-radius": 18,
"circle-stroke-color": MAP_STYLE_TOKENS.state.incident,
"circle-stroke-width": 3,
"circle-opacity": 0.88
}
},
{
id: "simulation-burst-point",
type: "circle",
source: SIMULATION_SOURCE_IDS.annotations,
filter: ["==", ["get", "kind"], "burst"],
paint: {
"circle-color": MAP_STYLE_TOKENS.state.incident,
"circle-radius": 7,
"circle-stroke-color": MAP_STYLE_TOKENS.canvas.casing,
"circle-stroke-width": 3
}
},
{
id: "simulation-pipe-label",
type: "symbol",
source: SIMULATION_SOURCE_IDS.annotations,
filter: ["==", ["get", "kind"], "pipe-label"],
layout: {
"text-field": ["get", "label"],
"text-size": 13,
"text-font": ["Open Sans Bold"],
"text-allow-overlap": true
},
paint: {
"text-color": MAP_STYLE_TOKENS.label.inverse,
"text-halo-color": MAP_STYLE_TOKENS.state.agent,
"text-halo-width": 9
}
},
{
id: "simulation-impact-label",
type: "symbol",
source: SIMULATION_SOURCE_IDS.annotations,
filter: ["==", ["get", "kind"], "tooltip"],
layout: {
"text-field": ["get", "label"],
"text-size": 14,
"text-font": ["Open Sans Bold"],
"text-offset": [0, 0],
"text-allow-overlap": true
},
paint: {
"text-color": MAP_STYLE_TOKENS.state.risk,
"text-halo-color": MAP_STYLE_TOKENS.canvas.casing,
"text-halo-width": 4
}
},
{
id: "simulation-district-label",
type: "symbol",
source: SIMULATION_SOURCE_IDS.annotations,
filter: ["==", ["get", "kind"], "district"],
layout: {
"text-field": ["get", "label"],
"text-size": 13,
"text-font": ["Open Sans Regular"],
"text-allow-overlap": true
},
paint: {
"text-color": MAP_STYLE_TOKENS.label.secondary,
"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-size": 12,
"text-font": ["Open Sans Regular"],
"text-offset": [5.2, -3.4],
"text-anchor": "left",
"text-allow-overlap": true
},
paint: {
"text-color": MAP_STYLE_TOKENS.label.primary,
"text-halo-color": MAP_STYLE_TOKENS.canvas.casing,
"text-halo-width": 5
}
}
];
export const simulationLayerIds = simulationAnnotationLayers.map((layer) => layer.id);
+72
View File
@@ -0,0 +1,72 @@
import type { Map as MapLibreMap } from "maplibre-gl";
import { describe, expect, it, vi } from "vitest";
import {
fitNetworkBounds,
getResponsiveWorkbenchPadding,
getWorkbenchPadding
} from "./camera";
import { WATER_NETWORK_GLOBAL_VIEW } from "./sources";
function createMap(width = 1280, height = 720) {
const fitBounds = vi.fn();
const map = {
fitBounds,
getCanvas: () => ({ clientWidth: width, clientHeight: height, width, height })
} as unknown as MapLibreMap;
return { fitBounds, map };
}
describe("workbench camera padding", () => {
it.each([
{ agentOpen: false, conditionOpen: false, expected: { top: 72, right: 72, bottom: 32, left: 96 } },
{ agentOpen: true, conditionOpen: false, expected: { top: 72, right: 72, bottom: 32, left: 484 } },
{ agentOpen: false, conditionOpen: true, expected: { top: 72, right: 504, bottom: 32, left: 96 } },
{ agentOpen: true, conditionOpen: true, expected: { top: 72, right: 504, bottom: 32, left: 484 } }
])("covers agent=$agentOpen and condition=$conditionOpen", ({ agentOpen, conditionOpen, expected }) => {
expect(getWorkbenchPadding(agentOpen, conditionOpen)).toEqual(expected);
});
it("caps combined padding on constrained desktop widths", () => {
const { map } = createMap(1024, 720);
const padding = getResponsiveWorkbenchPadding(map, true, true);
expect(padding.left + padding.right).toBeLessThanOrEqual(Math.floor(1024 * 0.58) + 1);
expect(padding.top + padding.bottom).toBeLessThanOrEqual(Math.floor(720 * 0.58) + 1);
expect(padding.left).toBeGreaterThan(padding.top);
expect(padding.right).toBeGreaterThan(padding.top);
});
it("uses mobile-safe padding below the dock breakpoint", () => {
const { map } = createMap(375, 667);
expect(getResponsiveWorkbenchPadding(map, true, true)).toEqual({
top: 72,
right: 24,
bottom: 72,
left: 24
});
});
});
describe("fitNetworkBounds", () => {
it("uses the configured global extent for initial and home views", () => {
const { fitBounds, map } = createMap();
fitNetworkBounds(map, WATER_NETWORK_GLOBAL_VIEW);
expect(fitBounds).toHaveBeenCalledTimes(1);
const [bounds, options] = fitBounds.mock.calls[0] ?? [];
expect(bounds[0][0]).toBeCloseTo(121.351632, 6);
expect(bounds[0][1]).toBeCloseTo(30.810500, 6);
expect(bounds[1][0]).toBeCloseTo(121.772482, 6);
expect(bounds[1][1]).toBeCloseTo(31.007207, 6);
expect(options).toMatchObject({
duration: 600,
maxZoom: 13,
padding: { top: 96, right: 48, bottom: 48, left: 48 }
});
expect(options).not.toHaveProperty("zoom");
});
});
+98
View File
@@ -0,0 +1,98 @@
import type { Map as MapLibreMap, PaddingOptions } from "maplibre-gl";
import {
getWorkbenchCameraPadding
} from "../layout/workbench-layout";
import { WATER_NETWORK_GLOBAL_VIEW } from "./sources";
type LngLatBounds = [[number, number], [number, number]];
type WebMercatorBbox = readonly [number, number, number, number];
export type NetworkViewParams = {
bbox3857: WebMercatorBbox;
};
const WEB_MERCATOR_RADIUS = 6378137;
const GLOBAL_VIEW_MAX_ZOOM = 13;
const GLOBAL_VIEW_PADDING: PaddingOptions = { top: 96, right: 48, bottom: 48, left: 48 };
export function getWorkbenchPadding(leftPanelOpen: boolean, rightPanelOpen: boolean): PaddingOptions {
return getWorkbenchCameraPadding(1280, {
agentOpen: leftPanelOpen,
conditionOpen: rightPanelOpen
});
}
export function getResponsiveWorkbenchPadding(
map: MapLibreMap,
leftPanelOpen: boolean,
rightPanelOpen: boolean
): PaddingOptions {
const width = getMapViewportSize(map).width;
return getResponsivePadding(
map,
getWorkbenchCameraPadding(width, {
agentOpen: leftPanelOpen,
conditionOpen: rightPanelOpen
})
);
}
export function fitNetworkBounds(
map: MapLibreMap,
viewParams: NetworkViewParams = WATER_NETWORK_GLOBAL_VIEW,
padding: PaddingOptions = GLOBAL_VIEW_PADDING
) {
map.fitBounds(
getLngLatBoundsFromBbox3857(viewParams.bbox3857),
{
padding: getResponsivePadding(map, padding),
maxZoom: GLOBAL_VIEW_MAX_ZOOM,
duration: 600
}
);
}
export function getResponsivePadding(map: MapLibreMap, padding: PaddingOptions): PaddingOptions {
const { width, height } = getMapViewportSize(map);
const horizontalPadding = padding.left + padding.right;
const verticalPadding = padding.top + padding.bottom;
const maxHorizontalPadding = width * 0.58;
const maxVerticalPadding = height * 0.58;
const horizontalScale = horizontalPadding > 0 ? maxHorizontalPadding / horizontalPadding : 1;
const verticalScale = verticalPadding > 0 ? maxVerticalPadding / verticalPadding : 1;
const scale = Math.min(1, horizontalScale, verticalScale);
if (scale >= 1) {
return padding;
}
return {
top: Math.round(padding.top * scale),
right: Math.round(padding.right * scale),
bottom: Math.round(padding.bottom * scale),
left: Math.round(padding.left * scale)
};
}
function getMapViewportSize(map: MapLibreMap) {
const canvas = map.getCanvas();
return {
width: canvas.clientWidth || canvas.width,
height: canvas.clientHeight || canvas.height
};
}
function getLngLatBoundsFromBbox3857(bbox3857: WebMercatorBbox): LngLatBounds {
const [minX, minY, maxX, maxY] = bbox3857;
return [
webMercatorToLngLat(minX, minY),
webMercatorToLngLat(maxX, maxY)
];
}
function webMercatorToLngLat(x: number, y: number): [number, number] {
const lng = (x / WEB_MERCATOR_RADIUS) * (180 / Math.PI);
const lat = (2 * Math.atan(Math.exp(y / WEB_MERCATOR_RADIUS)) - Math.PI / 2) * (180 / Math.PI);
return [lng, lat];
}
@@ -0,0 +1,77 @@
import { describe, expect, it } from "vitest";
import {
compositeHexColor,
createCoolorsContrastUrl,
getContrastRatio
} from "./color-contrast";
import {
MAP_STYLE_TOKENS
} from "./map-colors";
import { createBaseStyle } from "./sources";
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", () => {
expect(getContrastRatio("#000000", "#FFFFFF")).toBe(21);
expect(compositeHexColor("#0F766E", "#F9FAFB", 0.88)).toBe("#2B867F");
expect(() => compositeHexColor("#0F766E", "#F9FAFB", 1.01)).toThrow(RangeError);
});
it("generates a direct Coolors contrast-checker link", () => {
expect(createCoolorsContrastUrl("#0F766E", "#F9FAFB")).toBe(
"https://coolors.co/contrast-checker/0f766e-f9fafb"
);
});
it("keeps rendered source colors above 3:1 on every map background", () => {
sourceColors.forEach((sourceColor) => {
mapBackgrounds.forEach((background) => {
const renderedColor = compositeHexColor(
sourceColor,
background,
MAP_STYLE_TOKENS.opacity.core
);
expect(
getContrastRatio(renderedColor, background),
createCoolorsContrastUrl(renderedColor, background)
).toBeGreaterThanOrEqual(3);
});
});
});
it("keeps selected outlines above 4.5:1 on every map background", () => {
sourceColors.forEach(() => {
mapBackgrounds.forEach((background) => {
const selectedColor = MAP_STYLE_TOKENS.state.selected;
expect(
getContrastRatio(selectedColor, background),
createCoolorsContrastUrl(selectedColor, background)
).toBeGreaterThanOrEqual(4.5);
});
});
});
it("keeps closed conduits above 3:1 and applies the primary map background", () => {
mapBackgrounds.forEach((background) => {
const renderedColor = compositeHexColor(
MAP_STYLE_TOKENS.state.inactive,
background,
MAP_STYLE_TOKENS.opacity.core
);
expect(getContrastRatio(renderedColor, background)).toBeGreaterThanOrEqual(3);
});
const backgroundLayer = createBaseStyle().layers?.find((layer) => layer.id === "background");
if (backgroundLayer?.type !== "background") {
throw new Error("Expected the base style to contain a background layer.");
}
expect(backgroundLayer.paint?.["background-color"]).toBe(MAP_STYLE_TOKENS.canvas.primary);
});
});
@@ -0,0 +1,62 @@
const HEX_COLOR_PATTERN = /^#?([0-9a-f]{6})$/i;
export function getRelativeLuminance(color: string) {
const [red, green, blue] = parseHexColor(color).map((channel) => {
const normalized = channel / 255;
return normalized <= 0.04045
? normalized / 12.92
: ((normalized + 0.055) / 1.055) ** 2.4;
});
return 0.2126 * red + 0.7152 * green + 0.0722 * blue;
}
export function getContrastRatio(firstColor: string, secondColor: string) {
const firstLuminance = getRelativeLuminance(firstColor);
const secondLuminance = getRelativeLuminance(secondColor);
const lighter = Math.max(firstLuminance, secondLuminance);
const darker = Math.min(firstLuminance, secondLuminance);
return (lighter + 0.05) / (darker + 0.05);
}
export function compositeHexColor(
foreground: string,
background: string,
opacity: number
) {
if (opacity < 0 || opacity > 1) {
throw new RangeError("Opacity must be between 0 and 1.");
}
const foregroundChannels = parseHexColor(foreground);
const backgroundChannels = parseHexColor(background);
const compositeChannels = foregroundChannels.map((channel, index) =>
Math.round(channel * opacity + backgroundChannels[index] * (1 - opacity))
);
return `#${compositeChannels
.map((channel) => channel.toString(16).padStart(2, "0"))
.join("")
.toUpperCase()}`;
}
export function createCoolorsContrastUrl(foreground: string, background: string) {
const foregroundHex = toPathHex(foreground);
const backgroundHex = toPathHex(background);
return `https://coolors.co/contrast-checker/${foregroundHex}-${backgroundHex}`;
}
function parseHexColor(color: string) {
const match = color.match(HEX_COLOR_PATTERN);
if (!match) {
throw new TypeError(`Expected a six-digit HEX color, received "${color}".`);
}
return match[1].match(/.{2}/g)!.map((channel) => Number.parseInt(channel, 16));
}
function toPathHex(color: string) {
parseHexColor(color);
return color.replace("#", "").toLowerCase();
}
@@ -0,0 +1,293 @@
import type {
FilterSpecification,
GeoJSONSource,
GeoJSONSourceSpecification,
StyleSpecification
} 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",
draft: "workbench-drawing-draft"
} as const;
export const DRAWING_INTERACTIVE_LAYER_IDS = [
"workbench-drawing-selected-polygon-outline",
"workbench-drawing-selected-line",
"workbench-drawing-selected-point",
"workbench-drawing-polygon-fill",
"workbench-drawing-polygon-outline",
"workbench-drawing-line",
"workbench-drawing-point"
];
export type DrawingFeatureProperties = {
id: string;
label: string;
kind: "point" | "line" | "polygon" | "circle";
createdAt: string;
};
export type DrawingFeatureCollection = FeatureCollection<Geometry, DrawingFeatureProperties>;
export const emptyDrawingFeatureCollection: DrawingFeatureCollection = {
type: "FeatureCollection",
features: []
};
export const drawingSources = {
features: {
type: "geojson",
data: emptyDrawingFeatureCollection
} satisfies GeoJSONSourceSpecification,
draft: {
type: "geojson",
data: emptyDrawingFeatureCollection
} satisfies GeoJSONSourceSpecification
};
export const drawingLayers: StyleSpecification["layers"] = [
{
id: "workbench-drawing-polygon-fill",
type: "fill",
source: DRAWING_SOURCE_IDS.features,
filter: ["==", ["geometry-type"], "Polygon"],
paint: {
"fill-color": MAP_STYLE_TOKENS.tool.geometry,
"fill-opacity": 0.16
}
},
{
id: "workbench-drawing-polygon-outline",
type: "line",
source: DRAWING_SOURCE_IDS.features,
filter: ["==", ["geometry-type"], "Polygon"],
paint: {
"line-color": MAP_STYLE_TOKENS.tool.geometry,
"line-width": 2,
"line-opacity": 0.82
}
},
{
id: "workbench-drawing-line",
type: "line",
source: DRAWING_SOURCE_IDS.features,
filter: ["==", ["geometry-type"], "LineString"],
paint: {
"line-color": MAP_STYLE_TOKENS.tool.geometry,
"line-width": 3,
"line-opacity": 0.88
}
},
{
id: "workbench-drawing-point",
type: "circle",
source: DRAWING_SOURCE_IDS.features,
filter: ["==", ["geometry-type"], "Point"],
paint: {
"circle-color": MAP_STYLE_TOKENS.tool.geometry,
"circle-radius": 6,
"circle-stroke-color": MAP_STYLE_TOKENS.canvas.casing,
"circle-stroke-width": 2
}
},
{
id: "workbench-drawing-selected-polygon-outline",
type: "line",
source: DRAWING_SOURCE_IDS.features,
filter: ["all", ["==", ["geometry-type"], "Polygon"], ["==", ["get", "id"], ""]],
paint: {
"line-color": MAP_STYLE_TOKENS.state.selected,
"line-width": 4,
"line-opacity": 0.86
}
},
{
id: "workbench-drawing-selected-line",
type: "line",
source: DRAWING_SOURCE_IDS.features,
filter: ["all", ["==", ["geometry-type"], "LineString"], ["==", ["get", "id"], ""]],
paint: {
"line-color": MAP_STYLE_TOKENS.state.selected,
"line-width": 6,
"line-opacity": 0.72
}
},
{
id: "workbench-drawing-selected-point",
type: "circle",
source: DRAWING_SOURCE_IDS.features,
filter: ["all", ["==", ["geometry-type"], "Point"], ["==", ["get", "id"], ""]],
paint: {
"circle-color": MAP_STYLE_TOKENS.state.selected,
"circle-radius": 10,
"circle-opacity": 0.2,
"circle-stroke-color": MAP_STYLE_TOKENS.state.selected,
"circle-stroke-width": 2
}
},
{
id: "workbench-drawing-draft-polygon-fill",
type: "fill",
source: DRAWING_SOURCE_IDS.draft,
filter: ["==", ["geometry-type"], "Polygon"],
paint: {
"fill-color": MAP_STYLE_TOKENS.tool.geometry,
"fill-opacity": 0.12
}
},
{
id: "workbench-drawing-draft-line",
type: "line",
source: DRAWING_SOURCE_IDS.draft,
filter: ["any", ["==", ["geometry-type"], "LineString"], ["==", ["geometry-type"], "Polygon"]],
paint: {
"line-color": MAP_STYLE_TOKENS.tool.geometry,
"line-width": 2,
"line-dasharray": [2, 2],
"line-opacity": 0.9
}
},
{
id: "workbench-drawing-draft-vertex",
type: "circle",
source: DRAWING_SOURCE_IDS.draft,
filter: ["==", ["geometry-type"], "Point"],
paint: {
"circle-color": MAP_STYLE_TOKENS.state.selected,
"circle-radius": 4,
"circle-stroke-color": MAP_STYLE_TOKENS.canvas.casing,
"circle-stroke-width": 1.5
}
}
];
export function ensureDrawingLayers(map: {
getSource: (id: string) => unknown;
addSource: (id: string, source: GeoJSONSourceSpecification) => void;
getLayer: (id: string) => unknown;
addLayer: (layer: NonNullable<StyleSpecification["layers"]>[number], beforeId?: string) => void;
}) {
if (!map.getSource(DRAWING_SOURCE_IDS.features)) {
map.addSource(DRAWING_SOURCE_IDS.features, drawingSources.features);
}
if (!map.getSource(DRAWING_SOURCE_IDS.draft)) {
map.addSource(DRAWING_SOURCE_IDS.draft, drawingSources.draft);
}
drawingLayers.forEach((layer) => {
if (!map.getLayer(layer.id)) {
map.addLayer(layer, WORKBENCH_INTERACTION_BEFORE_ID);
}
});
}
export function setDrawingSourceData(
map: { getSource: (id: string) => unknown },
sourceId: string,
data: DrawingFeatureCollection
) {
const source = map.getSource(sourceId) as GeoJSONSource | undefined;
source?.setData(data);
}
export function setSelectedDrawingFeatureId(
map: { getLayer: (id: string) => unknown; setFilter: (id: string, filter?: FilterSpecification) => void },
featureId: string | null
) {
const id = featureId ?? "";
[
["workbench-drawing-selected-polygon-outline", "Polygon"],
["workbench-drawing-selected-line", "LineString"],
["workbench-drawing-selected-point", "Point"]
].forEach(([layerId, geometryType]) => {
if (map.getLayer(layerId)) {
map.setFilter(layerId, ["all", ["==", ["geometry-type"], geometryType], ["==", ["get", "id"], id]]);
}
});
}
export function createPointFeature(
id: string,
coordinates: [number, number],
label: string
): DrawingFeatureCollection["features"][number] {
return {
type: "Feature",
id,
properties: createDrawingProperties(id, label, "point"),
geometry: {
type: "Point",
coordinates
} satisfies Point
};
}
export function createLineFeature(
id: string,
coordinates: [number, number][],
label: string
): DrawingFeatureCollection["features"][number] {
return {
type: "Feature",
id,
properties: createDrawingProperties(id, label, "line"),
geometry: {
type: "LineString",
coordinates
} satisfies LineString
};
}
export function createPolygonFeature(
id: string,
coordinates: [number, number][],
label: string
): DrawingFeatureCollection["features"][number] {
return {
type: "Feature",
id,
properties: createDrawingProperties(id, label, "polygon"),
geometry: {
type: "Polygon",
coordinates: [[...coordinates, coordinates[0]]]
} satisfies Polygon
};
}
export function createCircleFeature(
id: string,
center: [number, number],
radiusMeters: number,
label: string
): DrawingFeatureCollection["features"][number] {
const coordinates = createCircleCoordinates(center, radiusMeters);
return {
type: "Feature",
id,
properties: createDrawingProperties(id, label, "circle"),
geometry: {
type: "Polygon",
coordinates: [coordinates]
} satisfies Polygon
};
}
function createDrawingProperties(
id: string,
label: string,
kind: DrawingFeatureProperties["kind"]
): DrawingFeatureProperties {
return {
id,
label,
kind,
createdAt: new Date().toISOString()
};
}
@@ -0,0 +1,89 @@
import {
createCircleFeature,
createLineFeature,
createPolygonFeature,
type DrawingFeatureCollection
} from "./drawing-layers";
import { getDistanceMeters, type LngLatTuple } from "./geo";
export type WorkbenchDrawMode = "point" | "line" | "polygon" | "circle";
export type DrawingDraftState =
| {
mode: "line" | "polygon";
coordinates: LngLatTuple[];
}
| {
mode: "circle";
center: LngLatTuple;
edge?: LngLatTuple;
};
export function createDrawingId(mode: WorkbenchDrawMode, index: number) {
return `${mode}-${index.toString().padStart(3, "0")}`;
}
export function createDrawingLabel(mode: WorkbenchDrawMode, index: number) {
return `${getDrawingKindLabel(mode)} ${index.toString().padStart(2, "0")}`;
}
export function createFeatureFromDraft(
id: string,
draft: DrawingDraftState,
label: string
): DrawingFeatureCollection["features"][number] | null {
if (draft.mode === "line") {
return createLineFeature(id, draft.coordinates, label);
}
if (draft.mode === "polygon") {
return createPolygonFeature(id, draft.coordinates, label);
}
if (draft.mode !== "circle" || !draft.edge) {
return null;
}
return createCircleFeature(id, draft.center, getDistanceMeters(draft.center, draft.edge), label);
}
export function getDraftPointCount(draft: DrawingDraftState) {
if (draft.mode === "circle") {
return draft.edge ? 2 : 1;
}
return draft.coordinates.length;
}
export function getDrawingKindLabel(kind: WorkbenchDrawMode) {
if (kind === "point") {
return "点标注";
}
if (kind === "line") {
return "线标注";
}
if (kind === "circle") {
return "圆形标注";
}
return "范围标注";
}
export function getVisibleFeatureCollection(
featureCollection: DrawingFeatureCollection,
hiddenFeatureIds: Set<string>
): DrawingFeatureCollection {
return {
type: "FeatureCollection",
features: featureCollection.features.filter((feature) => !hiddenFeatureIds.has(feature.properties.id))
};
}
export function formatDrawingTime(value: string) {
return new Intl.DateTimeFormat("zh-CN", {
hour: "2-digit",
minute: "2-digit"
}).format(new Date(value));
}
+123
View File
@@ -0,0 +1,123 @@
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;
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, selectedFeature }: ExportMapViewOptions = {}) {
const canvas = map.getCanvas();
const sourceWidth = canvas.clientWidth || canvas.width;
const sourceHeight = canvas.clientHeight || canvas.height;
if (!sourceWidth || !sourceHeight) {
throw new Error("地图画布尺寸不可用。");
}
const requestedScale = targetLongEdge ? targetLongEdge / Math.max(sourceWidth, sourceHeight) : scale ?? 1;
const exportScale = Math.max(1, Math.min(requestedScale, MAX_EXPORT_DIMENSION / sourceWidth, MAX_EXPORT_DIMENSION / sourceHeight));
const exportWidth = Math.round(sourceWidth * exportScale);
const exportHeight = Math.round(sourceHeight * exportScale);
const container = createExportContainer(exportWidth, exportHeight);
const style = cloneMapStyle(map.getStyle());
const bounds = map.getBounds();
document.body.appendChild(container);
const exportMap = new maplibregl.Map({
container,
style,
center: map.getCenter(),
zoom: map.getZoom(),
pitch: map.getPitch(),
bearing: map.getBearing(),
preserveDrawingBuffer: true,
interactive: false,
attributionControl: false,
fadeDuration: 0
});
try {
await onceMapEvent(exportMap, "load");
exportMap.fitBounds(bounds, {
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");
link.download = filename ?? getDefaultExportFilename(getExportLabel(targetLongEdge, exportScale));
link.href = exportMap.getCanvas().toDataURL("image/png");
link.click();
return {
width: exportWidth,
height: exportHeight,
scale: exportScale
};
} finally {
exportMap.remove();
container.remove();
}
}
function createExportContainer(width: number, height: number) {
const container = document.createElement("div");
container.style.position = "fixed";
container.style.left = "-10000px";
container.style.top = "0";
container.style.width = `${width}px`;
container.style.height = `${height}px`;
container.style.pointerEvents = "none";
container.style.opacity = "0";
return container;
}
function cloneMapStyle(style: StyleSpecification) {
if (typeof structuredClone === "function") {
return structuredClone(style);
}
return JSON.parse(JSON.stringify(style)) as StyleSpecification;
}
function onceMapEvent(map: MapLibreMap, eventName: "load" | "idle") {
return new Promise<void>((resolve, reject) => {
map.once(eventName, () => resolve());
map.once("error", (event) => reject(event.error ?? new Error("地图导出渲染失败。")));
});
}
function waitForMapIdle(map: MapLibreMap) {
return Promise.race([
onceMapEvent(map, "idle"),
new Promise<void>((resolve) => {
window.setTimeout(resolve, EXPORT_IDLE_TIMEOUT_MS);
})
]);
}
function getExportLabel(targetLongEdge: number | undefined, scale: number) {
if (targetLongEdge === 3840) {
return "4k";
}
return `${scale.toFixed(1)}x`;
}
function getDefaultExportFilename(label: string) {
const timestamp = new Date().toISOString().slice(0, 19).replace(/[:T]/g, "-");
return `drainage-network-view-${label}-${timestamp}.png`;
}
@@ -0,0 +1,68 @@
import type { MapGeoJSONFeature } from "maplibre-gl";
import type { DetailFeature } from "../types";
import { formatValue } from "../utils/format-value";
import { SOURCE_LAYERS, type WaterNetworkSourceId } from "./sources";
export function getFeatureId(feature: MapGeoJSONFeature) {
const raw = feature.properties?.id ?? feature.id;
return raw === undefined || raw === null ? "" : String(raw);
}
export function getWaterNetworkSourceId(feature: MapGeoJSONFeature): WaterNetworkSourceId | null {
for (const [sourceId, sourceLayer] of Object.entries(SOURCE_LAYERS) as [WaterNetworkSourceId, string][]) {
if (feature.source === sourceId || feature.sourceLayer === sourceLayer) {
return sourceId;
}
}
return null;
}
export function toDetailFeature(feature: MapGeoJSONFeature): DetailFeature {
const sourceId = getWaterNetworkSourceId(feature) ?? "junctions";
const id = getFeatureId(feature);
const labels = getFeatureLabels(sourceId, id, feature.properties ?? {});
return {
id,
layer: sourceId,
title: labels.title,
subtitle: labels.subtitle,
properties: feature.properties ?? {}
};
}
function getFeatureLabels(sourceId: WaterNetworkSourceId, id: string, properties: Record<string, unknown>) {
if (sourceId === "pipes") {
return {
title: `管线 ${id || "未命名"}`,
subtitle: `DN${formatValue(properties.diameter)} · ${formatValue(properties.length)} m`
};
}
if (sourceId === "valves") {
return {
title: `阀门 ${id || "未命名"}`,
subtitle: `DN${formatValue(properties.diameter)} · ${formatValue(properties.v_type)}`
};
}
if (sourceId === "reservoirs") {
return {
title: `水库 ${id || "未命名"}`,
subtitle: `水头 ${formatValue(properties.head)} · 模式 ${formatValue(properties.pattern)}`
};
}
if (sourceId === "scada") {
return {
title: `SCADA ${id || "未命名"}`,
subtitle: `${formatValue(properties.type)} · 关联 ${formatValue(properties.associated_element_id)}`
};
}
return {
title: `节点 ${id || "未命名"}`,
subtitle: `需水量 ${formatValue(properties.demand)} · 高程 ${formatValue(properties.elevation)} m`
};
}
@@ -0,0 +1,21 @@
import { describe, expect, it, vi } from "vitest";
import { createFlowOverlayState, stopFlowAnimation } from "./flow-overlay";
describe("flow overlay lifecycle", () => {
it("starts empty so conduit data can be loaded once per map session", () => {
expect(createFlowOverlayState()).toEqual({ data: null, animationFrameId: null, overlay: null });
});
it("cancels an active animation frame without clearing cached data", () => {
const cancelAnimationFrame = vi.fn();
vi.stubGlobal("window", { cancelAnimationFrame });
const state = createFlowOverlayState();
state.data = [];
state.animationFrameId = 42;
stopFlowAnimation(state);
expect(cancelAnimationFrame).toHaveBeenCalledWith(42);
expect(state.animationFrameId).toBeNull();
expect(state.data).toBeTruthy();
vi.unstubAllGlobals();
});
});
@@ -0,0 +1,99 @@
import type { Feature, FeatureCollection, LineString, MultiLineString } from "geojson";
import type { Map as MapLibreMap } from "maplibre-gl";
import type { MapboxOverlay } from "@deck.gl/mapbox";
import { WORKBENCH_INTERACTION_BEFORE_ID } from "./layers";
type FlowFeature = Feature<LineString | MultiLineString, { diameter?: number | string; [key: string]: unknown }>;
export type FlowOverlayState = {
data: FlowFeature[] | null;
animationFrameId: number | null;
overlay: MapboxOverlay | null;
};
export function createFlowOverlayState(): FlowOverlayState {
return { data: null, animationFrameId: null, overlay: null };
}
export async function showFlowOverlay(
map: MapLibreMap,
state: FlowOverlayState,
loadData: () => Promise<FeatureCollection>,
reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches
) {
if (!state.data) {
const collection = await loadData();
state.data = collection.features.filter(isFlowFeature);
}
const [{ MapboxOverlay }, { PathLayer }, { FlowPathExtension }] = await Promise.all([
import("@deck.gl/mapbox"),
import("@deck.gl/layers"),
import("./flow-path-extension")
]);
const extension = new FlowPathExtension();
let renderingError: Error | null = null;
const render = (phase: number) => {
const layer = new PathLayer<FlowFeature>({
id: "workbench-network-flow",
beforeId: WORKBENCH_INTERACTION_BEFORE_ID,
data: state.data ?? [],
getPath: (feature: FlowFeature) => feature.geometry.type === "LineString" ? feature.geometry.coordinates : feature.geometry.coordinates.flat(),
getColor: [15, 118, 110, 210],
getWidth: (feature: FlowFeature) => Math.min(5, Math.max(1, Number(feature.properties?.diameter ?? 200) / 180)),
widthUnits: "pixels",
widthMinPixels: 1,
widthMaxPixels: 5,
capRounded: true,
jointRounded: true,
pickable: false,
parameters: { depthTest: false },
extensions: [extension],
flowPhase: phase
} as never);
state.overlay?.setProps({
layers: [layer],
onError: (error) => {
renderingError = error;
}
});
};
if (!state.overlay) {
const overlay = new MapboxOverlay({ interleaved: true, layers: [] });
map.addControl(overlay);
state.overlay = overlay;
}
stopFlowAnimation(state);
render(0.2);
await new Promise<void>((resolve) => window.requestAnimationFrame(() => resolve()));
if (renderingError) throw renderingError;
if (!reducedMotion) {
const startedAt = performance.now();
const tick = (now: number) => {
render(((now - startedAt) / 1800) % 1);
state.animationFrameId = window.requestAnimationFrame(tick);
};
state.animationFrameId = window.requestAnimationFrame(tick);
}
}
export function hideFlowOverlay(map: MapLibreMap, state: FlowOverlayState) {
stopFlowAnimation(state);
if (state.overlay) {
map.removeControl(state.overlay as never);
state.overlay = null;
}
}
export function stopFlowAnimation(state: FlowOverlayState) {
if (state.animationFrameId !== null) {
window.cancelAnimationFrame(state.animationFrameId);
state.animationFrameId = null;
}
}
function isFlowFeature(feature: Feature): feature is FlowFeature {
return feature.geometry?.type === "LineString" || feature.geometry?.type === "MultiLineString";
}
@@ -0,0 +1,34 @@
import { LayerExtension, type Layer } from "@deck.gl/core";
const flowPathUniforms = {
name: "flowPath",
fs: `layout(std140) uniform flowPathUniforms { float phase; } flowPath;`,
uniformTypes: { phase: "f32" }
} as const;
export class FlowPathExtension extends LayerExtension {
static extensionName = "FlowPathExtension";
static defaultProps = { flowPhase: { type: "number", value: 0 } };
getShaders() {
return {
modules: [flowPathUniforms],
inject: {
"fs:DECKGL_FILTER_COLOR": `
float flowStripe = fract((geometry.uv.y * 0.16) - flowPath.phase);
float flowPulse = smoothstep(0.0, 0.18, flowStripe) * (1.0 - smoothstep(0.55, 0.82, flowStripe));
color.a *= 0.22 + flowPulse * 0.78;
`
}
};
}
draw(this: Layer) {
const state = this.state as unknown as {
model?: { shaderInputs?: { setProps: (props: { flowPath: { phase: number } }) => void } };
};
state.model?.shaderInputs?.setProps({
flowPath: { phase: Number((this.props as Record<string, unknown>).flowPhase ?? 0) }
});
}
}
+96
View File
@@ -0,0 +1,96 @@
export type LngLatTuple = [number, number];
const EARTH_RADIUS_METERS = 6371008.8;
const SAME_COORDINATE_EPSILON = 0.0000001;
export function getDistanceMeters(from: LngLatTuple, to: LngLatTuple) {
const fromLatitude = toRadians(from[1]);
const toLatitude = toRadians(to[1]);
const deltaLatitude = toRadians(to[1] - from[1]);
const deltaLongitude = toRadians(to[0] - from[0]);
const a =
Math.sin(deltaLatitude / 2) ** 2 +
Math.cos(fromLatitude) * Math.cos(toLatitude) * Math.sin(deltaLongitude / 2) ** 2;
return 2 * EARTH_RADIUS_METERS * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}
export function getLineDistanceMeters(coordinates: LngLatTuple[]) {
return coordinates.reduce((total, coordinate, index) => {
if (index === 0) {
return total;
}
return total + getDistanceMeters(coordinates[index - 1], coordinate);
}, 0);
}
export function getLastSegmentDistanceMeters(coordinates: LngLatTuple[]) {
if (coordinates.length < 2) {
return 0;
}
return getDistanceMeters(coordinates[coordinates.length - 2], coordinates[coordinates.length - 1]);
}
export function getPolygonAreaSquareMeters(coordinates: LngLatTuple[]) {
if (coordinates.length < 3) {
return 0;
}
const closedCoordinates = [...coordinates, coordinates[0]];
const area = closedCoordinates.slice(0, -1).reduce((total, coordinate, index) => {
const nextCoordinate = closedCoordinates[index + 1];
return (
total +
toRadians(nextCoordinate[0] - coordinate[0]) *
(2 + Math.sin(toRadians(coordinate[1])) + Math.sin(toRadians(nextCoordinate[1])))
);
}, 0);
return Math.abs((area * EARTH_RADIUS_METERS * EARTH_RADIUS_METERS) / 2);
}
export function createCircleCoordinates(center: LngLatTuple, radiusMeters: number, steps = 48) {
const angularDistance = radiusMeters / EARTH_RADIUS_METERS;
const centerLongitude = toRadians(center[0]);
const centerLatitude = toRadians(center[1]);
const coordinates: LngLatTuple[] = [];
for (let index = 0; index <= steps; index += 1) {
const bearing = (2 * Math.PI * index) / steps;
const latitude = Math.asin(
Math.sin(centerLatitude) * Math.cos(angularDistance) +
Math.cos(centerLatitude) * Math.sin(angularDistance) * Math.cos(bearing)
);
const longitude =
centerLongitude +
Math.atan2(
Math.sin(bearing) * Math.sin(angularDistance) * Math.cos(centerLatitude),
Math.cos(angularDistance) - Math.sin(centerLatitude) * Math.sin(latitude)
);
coordinates.push([toDegrees(longitude), toDegrees(latitude)]);
}
return coordinates;
}
export function isSameCoordinate(previous: LngLatTuple | undefined, next: LngLatTuple) {
if (!previous) {
return false;
}
return (
Math.abs(previous[0] - next[0]) < SAME_COORDINATE_EPSILON &&
Math.abs(previous[1] - next[1]) < SAME_COORDINATE_EPSILON
);
}
export function toRadians(value: number) {
return (value * Math.PI) / 180;
}
function toDegrees(value: number) {
return (value * 180) / Math.PI;
}
@@ -0,0 +1,73 @@
import type { Map as MapLibreMap } from "maplibre-gl";
import { describe, expect, it, vi } from "vitest";
import {
applyLayerGroupStyle,
resetLayerGroupStyle,
validateLayerGroupStylePatch
} from "./layer-group-style";
import { SUPPLY_LAYER_VISUALS } from "./map-layer-visuals";
import {
supplyPipeColorExpression,
supplyPipeDashExpression
} from "./supply-style-expressions";
describe("layer group style validation", () => {
it("accepts geometry-aware style patches", () => {
expect(validateLayerGroupStylePatch("pipes", { color: "#0F766E", opacity: 0.5, widthMultiplier: 3, dash: "solid" })).toBe(true);
expect(validateLayerGroupStylePatch("valves", { fillColor: "#8B5CF6", strokeColor: "#FFFFFF", radiusMultiplier: 0.5 })).toBe(true);
expect(validateLayerGroupStylePatch("junctions", { fillColor: "#0369A1", strokeColor: "#FFFFFF", radiusMultiplier: 0.5 })).toBe(true);
expect(validateLayerGroupStylePatch("reservoirs", { fillColor: "#0284C7", strokeColor: "#FFFFFF", radiusMultiplier: 0.5 })).toBe(true);
});
it("rejects cross-geometry fields and out-of-range values", () => {
expect(validateLayerGroupStylePatch("pipes", { radiusMultiplier: 1 })).toBe(false);
expect(validateLayerGroupStylePatch("junctions", { widthMultiplier: 1 })).toBe(false);
expect(validateLayerGroupStylePatch("scada", { iconMultiplier: 2.1 })).toBe(false);
expect(validateLayerGroupStylePatch("pipes", { color: "red" })).toBe(false);
expect(validateLayerGroupStylePatch("unknown", {})).toBe(false);
});
it("restores the data-driven pipe color, dash, and widths", () => {
const map = createStyleMap();
resetLayerGroupStyle(map, "pipes");
expect(map.setPaintProperty).toHaveBeenCalledWith("pipes-flow", "line-color", supplyPipeColorExpression);
expect(map.setPaintProperty).toHaveBeenCalledWith("pipes-flow", "line-dasharray", supplyPipeDashExpression);
expect(map.setPaintProperty).toHaveBeenCalledWith("pipes-casing", "line-width", SUPPLY_LAYER_VISUALS.pipes.casingWidth);
expect(map.setPaintProperty).toHaveBeenCalledWith("pipes-flow", "line-width", SUPPLY_LAYER_VISUALS.pipes.width);
});
it("applies width multipliers from defaults instead of compounding", () => {
const map = createStyleMap();
applyLayerGroupStyle(map, "pipes", { widthMultiplier: 2 });
applyLayerGroupStyle(map, "pipes", { widthMultiplier: 2 });
const calls = map.setPaintProperty.mock.calls.filter(
([layerId, property]) => layerId === "pipes-flow" && property === "line-width"
);
expect(calls).toHaveLength(2);
expect(calls[0]?.[2]).toEqual(["*", SUPPLY_LAYER_VISUALS.pipes.width, 2]);
expect(calls[1]?.[2]).toEqual(calls[0]?.[2]);
});
it("keeps the reservoir core transparent when restoring its reference icon", () => {
const map = createStyleMap();
resetLayerGroupStyle(map, "reservoirs");
expect(map.setPaintProperty).toHaveBeenCalledWith("reservoirs", "circle-opacity", 0);
});
});
function createStyleMap() {
return {
getLayer: vi.fn(() => ({})),
setPaintProperty: vi.fn()
} as unknown as Pick<MapLibreMap, "getLayer" | "setPaintProperty"> & {
setPaintProperty: ReturnType<typeof vi.fn>;
} as MapLibreMap & {
setPaintProperty: ReturnType<typeof vi.fn>;
};
}
@@ -0,0 +1,182 @@
import type { Map as MapLibreMap } from "maplibre-gl";
import { MAP_STYLE_TOKENS } from "./map-colors";
import { SUPPLY_LAYER_VISUALS } from "./map-layer-visuals";
import {
createSupplyActiveOpacity,
createSupplyStateColor,
supplyFacilityOpacityExpression,
supplyPipeColorExpression,
supplyPipeDashExpression
} from "./supply-style-expressions";
export type LineLayerGroupStylePatch = {
color?: string;
opacity?: number;
widthMultiplier?: number;
dash?: "original" | "solid" | "dashed";
};
export type PointLayerGroupStylePatch = {
fillColor?: string;
strokeColor?: string;
opacity?: number;
radiusMultiplier?: number;
strokeMultiplier?: number;
};
export type LayerGroupStylePatch = LineLayerGroupStylePatch | PointLayerGroupStylePatch;
export type LayerGroupGeometry = "line" | "point";
export const LAYER_GROUP_GEOMETRIES = {
pipes: "line",
junctions: "point",
valves: "point",
reservoirs: "point"
} as const satisfies Record<string, LayerGroupGeometry>;
export type LayerGroupId = keyof typeof LAYER_GROUP_GEOMETRIES;
const LINE_CORE_LAYERS: Record<Extract<LayerGroupId, "pipes">, string> = {
pipes: "pipes-flow"
};
const LINE_DEFAULT_WIDTHS = {
"pipes-flow": SUPPLY_LAYER_VISUALS.pipes.width,
"pipes-casing": SUPPLY_LAYER_VISUALS.pipes.casingWidth,
"pipes-inactive": SUPPLY_LAYER_VISUALS.pipes.width,
"pipes-inactive-casing": SUPPLY_LAYER_VISUALS.pipes.casingWidth,
"pipes-hover-outline": SUPPLY_LAYER_VISUALS.pipes.hoverOutlineWidth,
"pipes-hover": SUPPLY_LAYER_VISUALS.pipes.hoverWidth
} as const;
const POINT_DEFAULT_COLORS = {
junctions: MAP_STYLE_TOKENS.supply.junction,
valves: MAP_STYLE_TOKENS.supply.valve,
reservoirs: MAP_STYLE_TOKENS.supply.reservoir
} as const;
const POINT_DEFAULT_OPACITIES = {
junctions: 0.9,
valves: supplyFacilityOpacityExpression,
reservoirs: 0
} as const;
const POINT_DEFAULT_VISUALS = {
junctions: SUPPLY_LAYER_VISUALS.junctions,
valves: SUPPLY_LAYER_VISUALS.valves,
reservoirs: SUPPLY_LAYER_VISUALS.reservoirs
};
export function validateLayerGroupStylePatch(groupId: string, patch: unknown): patch is LayerGroupStylePatch {
if (!(groupId in LAYER_GROUP_GEOMETRIES) || !patch || typeof patch !== "object" || Array.isArray(patch)) return false;
const geometry = LAYER_GROUP_GEOMETRIES[groupId as LayerGroupId];
const values = patch as Record<string, unknown>;
const allowed = geometry === "line"
? ["color", "opacity", "widthMultiplier", "dash"]
: ["fillColor", "strokeColor", "opacity", "radiusMultiplier", "strokeMultiplier"];
if (Object.keys(values).some((key) => !allowed.includes(key))) return false;
if ("color" in values && !isColor(values.color)) return false;
if ("fillColor" in values && !isColor(values.fillColor)) return false;
if ("strokeColor" in values && !isColor(values.strokeColor)) return false;
if ("opacity" in values && !inRange(values.opacity, 0, 1)) return false;
if ("widthMultiplier" in values && !inRange(values.widthMultiplier, 0.5, 3)) return false;
if ("radiusMultiplier" in values && !inRange(values.radiusMultiplier, 0.5, 3)) return false;
if ("strokeMultiplier" in values && !inRange(values.strokeMultiplier, 0.5, 3)) return false;
if ("dash" in values && !["original", "solid", "dashed"].includes(String(values.dash))) return false;
return true;
}
export function applyLayerGroupStyle(map: MapLibreMap, groupId: LayerGroupId, patch: LayerGroupStylePatch) {
const geometry = LAYER_GROUP_GEOMETRIES[groupId];
if (geometry === "line") applyLineStyle(map, groupId as "pipes", patch as LineLayerGroupStylePatch);
else applyPointStyle(map, groupId as "junctions" | "valves" | "reservoirs", patch as PointLayerGroupStylePatch);
}
export function resetLayerGroupStyle(map: MapLibreMap, groupId: LayerGroupId) {
const geometry = LAYER_GROUP_GEOMETRIES[groupId];
if (geometry === "line") resetLineStyle(map, groupId as "pipes");
else resetPointStyle(map, groupId as "junctions" | "valves" | "reservoirs");
}
function applyLineStyle(map: MapLibreMap, groupId: "pipes", patch: LineLayerGroupStylePatch) {
const prefix = groupId;
const coreId = LINE_CORE_LAYERS[groupId];
if (patch.color !== undefined) setPaint(map, coreId, "line-color", createSupplyStateColor(patch.color));
if (patch.opacity !== undefined) setPaint(map, coreId, "line-opacity", createSupplyActiveOpacity(patch.opacity));
if (patch.dash !== undefined) {
const dash = patch.dash === "solid"
? [1, 0]
: patch.dash === "dashed"
? [3, 2]
: supplyPipeDashExpression;
setPaint(map, coreId, "line-dasharray", dash);
setPaint(map, `${prefix}-casing`, "line-dasharray", dash);
}
if (patch.widthMultiplier !== undefined) {
for (const [id, value] of Object.entries(LINE_DEFAULT_WIDTHS)) {
setPaint(map, id, "line-width", scale(value, patch.widthMultiplier));
}
}
}
function resetLineStyle(map: MapLibreMap, groupId: "pipes") {
const coreId = LINE_CORE_LAYERS[groupId];
setPaint(map, coreId, "line-color", supplyPipeColorExpression);
setPaint(map, coreId, "line-opacity", createSupplyActiveOpacity(0.92));
setPaint(map, coreId, "line-dasharray", supplyPipeDashExpression);
setPaint(map, `${groupId}-casing`, "line-dasharray", supplyPipeDashExpression);
for (const [id, value] of Object.entries(LINE_DEFAULT_WIDTHS)) {
setPaint(map, id, "line-width", value);
}
}
function applyPointStyle(map: MapLibreMap, groupId: "junctions" | "valves" | "reservoirs", patch: PointLayerGroupStylePatch) {
const coreId = groupId;
if (patch.fillColor !== undefined) setPaint(map, coreId, "circle-color", createSupplyStateColor(patch.fillColor));
if (patch.strokeColor !== undefined) setPaint(map, coreId, "circle-stroke-color", createSupplyStateColor(patch.strokeColor));
if (patch.opacity !== undefined) setPaint(map, coreId, "circle-opacity", patch.opacity);
if (patch.radiusMultiplier !== undefined) {
const visual = POINT_DEFAULT_VISUALS[groupId];
const radii = {
[coreId]: visual.radius,
[`${groupId}-halo`]: visual.haloRadius,
[`${groupId}-hover`]: visual.hoverRadius,
[`${groupId}-selected`]: visual.selectedRadius,
[`${groupId}-selected-outer`]: visual.selectedRadius
};
for (const [id, value] of Object.entries(radii)) {
setPaint(map, id, "circle-radius", scale(value, patch.radiusMultiplier));
}
}
if (patch.strokeMultiplier !== undefined) {
setPaint(map, coreId, "circle-stroke-width", 2 * patch.strokeMultiplier);
}
}
function resetPointStyle(map: MapLibreMap, groupId: "junctions" | "valves" | "reservoirs") {
const visual = POINT_DEFAULT_VISUALS[groupId];
setPaint(map, groupId, "circle-color", createSupplyStateColor(POINT_DEFAULT_COLORS[groupId]));
setPaint(map, groupId, "circle-opacity", POINT_DEFAULT_OPACITIES[groupId]);
setPaint(map, groupId, "circle-stroke-width", 0);
setPaint(map, groupId, "circle-radius", visual.radius);
setPaint(map, `${groupId}-halo`, "circle-radius", visual.haloRadius);
setPaint(map, `${groupId}-hover`, "circle-radius", visual.hoverRadius);
setPaint(map, `${groupId}-selected`, "circle-radius", visual.selectedRadius);
setPaint(map, `${groupId}-selected-outer`, "circle-radius", visual.selectedRadius);
}
function scale(value: unknown, multiplier: number): unknown {
return ["*", value, multiplier];
}
function setPaint(map: MapLibreMap, layerId: string, property: string, value: unknown) {
if (map.getLayer(layerId)) map.setPaintProperty(layerId, property, (value ?? null) as never);
}
function inRange(value: unknown, min: number, max: number): value is number {
return typeof value === "number" && Number.isFinite(value) && value >= min && value <= max;
}
function isColor(value: unknown): value is string {
return typeof value === "string" && /^#[\da-f]{6}$/i.test(value);
}
+56
View File
@@ -0,0 +1,56 @@
import { describe, expect, it } from "vitest";
import { waterNetworkBusinessLayers, waterNetworkLayers } from "./layers";
import { SUPPLY_LAYER_VISUALS } from "./map-layer-visuals";
import { SUPPLY_ASSET_IMAGE_IDS } from "./supply-icons";
import {
supplyFacilityOpacityExpression,
supplyPipeColorExpression,
supplyPipeDashExpression
} from "./supply-style-expressions";
describe("supply network layer styling", () => {
it("encodes pipe hierarchy by diameter and zoom without inventing risk", () => {
const pipe = waterNetworkLayers.find((layer) => layer.id === "pipes-flow");
const paint = pipe?.paint as Record<string, unknown> | undefined;
expect(paint?.["line-color"]).toEqual(supplyPipeColorExpression);
expect(paint?.["line-width"]).toEqual(SUPPLY_LAYER_VISUALS.pipes.width);
expect(paint?.["line-dasharray"]).toEqual(supplyPipeDashExpression);
expect(JSON.stringify(paint?.["line-width"])).toContain("diameter");
expect(JSON.stringify(paint?.["line-width"])).toContain("zoom");
expect(waterNetworkBusinessLayers.some((layer) => layer.id === "pipes-risk-glow")).toBe(false);
expect(getLayer("pipes-inactive")).toMatchObject({
type: "line",
paint: { "line-dasharray": [2, 1.5] }
});
});
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 });
expect(getLayer("reservoirs")).toMatchObject({ minzoom: 8.5 });
expect(getLayer("reservoirs-symbol")).toMatchObject({
minzoom: 8.5,
layout: { "icon-image": SUPPLY_ASSET_IMAGE_IDS.reservoir }
});
expect(getLayer("junctions")).toMatchObject({ minzoom: 15 });
const valvePaint = getLayer("valves")?.paint as Record<string, unknown> | undefined;
expect(valvePaint?.["circle-opacity"]).toEqual(supplyFacilityOpacityExpression);
const reservoirPaint = getLayer("reservoirs")?.paint as Record<string, unknown> | undefined;
expect(reservoirPaint?.["circle-opacity"]).toBe(0);
});
it("keeps interaction and hit layers above business layers", () => {
const ids = waterNetworkLayers.map((layer) => layer.id);
expect(ids.indexOf("pipes-flow")).toBeLessThan(ids.indexOf("pipes-hover"));
expect(ids.indexOf("pipes-hover")).toBeLessThan(ids.indexOf("pipes-hit"));
expect(ids.at(-1)).toBe("reservoirs-hit");
});
});
function getLayer(id: string) {
return waterNetworkLayers.find((layer) => layer.id === id);
}
+417
View File
@@ -0,0 +1,417 @@
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 { SOURCE_LAYERS } from "./sources";
import { SUPPLY_ASSET_IMAGE_IDS } from "./supply-icons";
import {
createSupplyActiveOpacity,
createSupplyStateColor,
supplyFacilityOpacityExpression,
supplyInactiveExpression,
supplyIconOpacityExpression,
supplyPipeColorExpression,
supplyPipeDashExpression
} from "./supply-style-expressions";
type Layer = NonNullable<StyleSpecification["layers"]>[number];
type AuthoredLayer = { id: string; type: string; [key: string]: unknown };
export const INTERACTIVE_HIT_LAYER_IDS = [
"pipes-hit",
"junctions-hit",
"valves-hit",
"reservoirs-hit",
SCADA_HIT_LAYER_ID
] as const;
const hoveredOpacity: ExpressionSpecification = [
"case",
[
"all",
["boolean", ["feature-state", "hovered"], false],
["!", ["any", ["boolean", ["feature-state", "selected"], false], ["boolean", ["feature-state", "highlighted"], false]]]
],
1,
0
];
const selectedOpacity: ExpressionSpecification = [
"case",
["any", ["boolean", ["feature-state", "selected"], false], ["boolean", ["feature-state", "highlighted"], false]],
1,
0
];
const opacityTransition = { duration: 120, delay: 0 } as const;
const layers: AuthoredLayer[] = [];
// Business layers: white casing or halo first, then the supply asset core.
layers.push(
{
id: "pipes-casing",
type: "line",
source: "pipes",
"source-layer": SOURCE_LAYERS.pipes,
minzoom: SUPPLY_LAYER_VISUALS.pipes.minzoom,
paint: {
"line-color": MAP_STYLE_TOKENS.canvas.casing,
"line-width": SUPPLY_LAYER_VISUALS.pipes.casingWidth,
"line-opacity": createSupplyActiveOpacity(0.86),
"line-dasharray": supplyPipeDashExpression
}
},
{
id: "pipes-flow",
type: "line",
source: "pipes",
"source-layer": SOURCE_LAYERS.pipes,
minzoom: SUPPLY_LAYER_VISUALS.pipes.minzoom,
paint: {
"line-color": supplyPipeColorExpression,
"line-width": SUPPLY_LAYER_VISUALS.pipes.width,
"line-opacity": createSupplyActiveOpacity(0.92),
"line-dasharray": supplyPipeDashExpression
}
},
{
id: "pipes-inactive-casing",
type: "line",
source: "pipes",
"source-layer": SOURCE_LAYERS.pipes,
minzoom: SUPPLY_LAYER_VISUALS.pipes.minzoom,
filter: supplyInactiveExpression,
paint: {
"line-color": MAP_STYLE_TOKENS.canvas.casing,
"line-width": SUPPLY_LAYER_VISUALS.pipes.casingWidth,
"line-opacity": 0.86,
"line-dasharray": [2, 1.5]
}
},
{
id: "pipes-inactive",
type: "line",
source: "pipes",
"source-layer": SOURCE_LAYERS.pipes,
minzoom: SUPPLY_LAYER_VISUALS.pipes.minzoom,
filter: supplyInactiveExpression,
paint: {
"line-color": MAP_STYLE_TOKENS.state.inactive,
"line-width": SUPPLY_LAYER_VISUALS.pipes.width,
"line-opacity": 0.92,
"line-dasharray": [2, 1.5]
}
},
{
id: "junctions-halo",
type: "circle",
source: "junctions",
"source-layer": SOURCE_LAYERS.junctions,
minzoom: SUPPLY_LAYER_VISUALS.junctions.minzoom,
paint: {
"circle-color": MAP_STYLE_TOKENS.canvas.casing,
"circle-radius": SUPPLY_LAYER_VISUALS.junctions.haloRadius,
"circle-opacity": SUPPLY_LAYER_VISUALS.junctions.haloOpacity,
"circle-stroke-width": 0
}
},
{
id: "junctions",
type: "circle",
source: "junctions",
"source-layer": SOURCE_LAYERS.junctions,
minzoom: SUPPLY_LAYER_VISUALS.junctions.minzoom,
paint: {
"circle-color": MAP_STYLE_TOKENS.supply.junction,
"circle-radius": SUPPLY_LAYER_VISUALS.junctions.radius,
"circle-opacity": 0.9
}
},
{
id: "valves-casing",
type: "circle",
source: "valves",
"source-layer": SOURCE_LAYERS.valves,
minzoom: SUPPLY_LAYER_VISUALS.valves.minzoom,
paint: {
"circle-color": MAP_STYLE_TOKENS.canvas.casing,
"circle-radius": SUPPLY_LAYER_VISUALS.valves.haloRadius,
"circle-opacity": 0.94
}
},
{
id: "valves",
type: "circle",
source: "valves",
"source-layer": SOURCE_LAYERS.valves,
minzoom: SUPPLY_LAYER_VISUALS.valves.minzoom,
paint: {
"circle-color": createSupplyStateColor(MAP_STYLE_TOKENS.supply.valve),
"circle-radius": SUPPLY_LAYER_VISUALS.valves.radius,
"circle-opacity": supplyFacilityOpacityExpression,
"circle-stroke-width": 0
}
},
{
id: "valves-symbol",
type: "symbol",
source: "valves",
"source-layer": SOURCE_LAYERS.valves,
minzoom: SUPPLY_LAYER_VISUALS.valves.minzoom,
layout: {
"icon-image": SUPPLY_ASSET_IMAGE_IDS.valve,
"icon-size": SUPPLY_LAYER_VISUALS.valves.iconSize,
"icon-allow-overlap": true,
"icon-ignore-placement": true
},
paint: {
"icon-opacity": supplyIconOpacityExpression
}
},
{
id: "reservoirs-halo",
type: "circle",
source: "reservoirs",
"source-layer": SOURCE_LAYERS.reservoirs,
minzoom: SUPPLY_LAYER_VISUALS.reservoirs.minzoom,
paint: {
"circle-color": MAP_STYLE_TOKENS.canvas.casing,
"circle-radius": SUPPLY_LAYER_VISUALS.reservoirs.haloRadius,
"circle-opacity": 0.94
}
},
{
id: "reservoirs",
type: "circle",
source: "reservoirs",
"source-layer": SOURCE_LAYERS.reservoirs,
minzoom: SUPPLY_LAYER_VISUALS.reservoirs.minzoom,
paint: {
"circle-color": createSupplyStateColor(MAP_STYLE_TOKENS.supply.reservoir),
"circle-radius": SUPPLY_LAYER_VISUALS.reservoirs.radius,
"circle-opacity": 0,
"circle-stroke-width": 0
}
},
{
id: "reservoirs-symbol",
type: "symbol",
source: "reservoirs",
"source-layer": SOURCE_LAYERS.reservoirs,
minzoom: SUPPLY_LAYER_VISUALS.reservoirs.minzoom,
layout: {
"icon-image": SUPPLY_ASSET_IMAGE_IDS.reservoir,
"icon-size": SUPPLY_LAYER_VISUALS.reservoirs.iconSize,
"icon-allow-overlap": true,
"icon-ignore-placement": true
},
paint: {
"icon-opacity": supplyIconOpacityExpression
}
}
);
const lineInteractions = [
["pipes", "pipes", SOURCE_LAYERS.pipes, SUPPLY_LAYER_VISUALS.pipes, supplyPipeColorExpression, undefined]
] as const;
lineInteractions.forEach(([prefix, source, sourceLayer, visual, color, dash]) => {
const hoverPaint: Record<string, unknown> = {
"line-color": color,
"line-width": visual.hoverWidth,
"line-opacity": hoveredOpacity,
"line-opacity-transition": opacityTransition
};
if (dash) hoverPaint["line-dasharray"] = dash;
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: hoverPaint
},
{
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, SUPPLY_LAYER_VISUALS.junctions, MAP_STYLE_TOKENS.supply.junction],
["valves", "valves", SOURCE_LAYERS.valves, SUPPLY_LAYER_VISUALS.valves, MAP_STYLE_TOKENS.supply.valve],
["reservoirs", "reservoirs", SOURCE_LAYERS.reservoirs, SUPPLY_LAYER_VISUALS.reservoirs, MAP_STYLE_TOKENS.supply.reservoir]
] 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": MAP_STYLE_TOKENS.canvas.transparent,
"circle-radius": visual.hoverRadius,
"circle-opacity": hoveredOpacity,
"circle-stroke-color": color,
"circle-stroke-width": 2,
"circle-stroke-opacity": hoveredOpacity,
"circle-opacity-transition": opacityTransition,
"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": 5,
"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": 2.5,
"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: "pipes-hit",
type: "line",
source: "pipes",
"source-layer": SOURCE_LAYERS.pipes,
minzoom: SUPPLY_LAYER_VISUALS.pipes.minzoom,
paint: {
"line-color": MAP_STYLE_TOKENS.canvas.transparent,
"line-width": SUPPLY_LAYER_VISUALS.pipes.hitWidth,
"line-opacity": 0
}
},
{
id: "junctions-hit",
type: "circle",
source: "junctions",
"source-layer": SOURCE_LAYERS.junctions,
minzoom: SUPPLY_LAYER_VISUALS.junctions.minzoom,
paint: {
"circle-color": MAP_STYLE_TOKENS.canvas.transparent,
"circle-radius": SUPPLY_LAYER_VISUALS.junctions.hitRadius,
"circle-opacity": 0
}
},
{
id: "valves-hit",
type: "circle",
source: "valves",
"source-layer": SOURCE_LAYERS.valves,
minzoom: SUPPLY_LAYER_VISUALS.valves.minzoom,
paint: {
"circle-color": MAP_STYLE_TOKENS.canvas.transparent,
"circle-radius": SUPPLY_LAYER_VISUALS.valves.hitRadius,
"circle-opacity": 0
}
},
{
id: "reservoirs-hit",
type: "circle",
source: "reservoirs",
"source-layer": SOURCE_LAYERS.reservoirs,
minzoom: SUPPLY_LAYER_VISUALS.reservoirs.minzoom,
paint: {
"circle-color": MAP_STYLE_TOKENS.canvas.transparent,
"circle-radius": SUPPLY_LAYER_VISUALS.reservoirs.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";
+48
View File
@@ -0,0 +1,48 @@
/** 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"
},
supply: {
pipeMinor: "#5A9E8A",
pipeMedium: "#217C83",
pipeMajor: "#245E75",
junction: "#5B8E91",
valve: "#8A8A8A",
reservoir: "#195F84",
scadaFlow: "#0094BC",
scadaPressure: "#FFB624"
},
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;
@@ -0,0 +1,86 @@
import type { Map as MapLibreMap } from "maplibre-gl";
import { describe, expect, it } from "vitest";
import { waterNetworkLayers } from "./layers";
import { MAP_STYLE_TOKENS } from "./map-colors";
import {
INITIAL_LAYER_VISIBILITY,
MAP_LEGEND_ITEMS,
createLayerControlItems,
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);
expect(items.map((item) => item.id)).toEqual([
"pipes",
"junctions",
"valves",
"reservoirs",
"scada",
"simulation"
]);
expect(items.map((item) => item.id)).not.toContain("pumps");
expect(items.map((item) => item.id)).not.toContain("tanks");
expect(items.slice(0, 5).map((item) => item.description)).toEqual([
"tjwater:geo_pipes_mat",
"tjwater:geo_junctions_mat",
"tjwater:geo_valves",
"tjwater:geo_reservoirs",
"tjwater:geo_scada"
]);
});
it("shows only current visible business icons in the legend", () => {
expect(MAP_LEGEND_ITEMS.map((item) => item.id)).toEqual([
"major-pipe",
"medium-pipe",
"minor-pipe",
"inactive-pipe",
"junction",
"valve",
"reservoir",
"scada-flow",
"scada-pressure",
"impact-area"
]);
expect(MAP_LEGEND_ITEMS.find((item) => item.id === "valve")).toMatchObject({ imageSrc: "/map/valve.png" });
expect(MAP_LEGEND_ITEMS.find((item) => item.id === "reservoir")).toMatchObject({
label: "水库",
imageSrc: "/map/reservoir.png"
});
expect(MAP_LEGEND_ITEMS.find((item) => item.id === "scada-flow")).toMatchObject({ imageSrc: "/map/scada-flow.png" });
expect(MAP_LEGEND_ITEMS.find((item) => item.id === "scada-pressure")).toMatchObject({ imageSrc: "/map/scada-pressure.png" });
expect(MAP_LEGEND_ITEMS.find((item) => item.id === "inactive-pipe")).toMatchObject({
color: MAP_STYLE_TOKENS.state.inactive,
shape: "dashed-line"
});
});
it("finds every rendered layer that uses a source", () => {
const map = {
getLayer: (id: string) => waterNetworkLayers.some((layer) => layer.id === id),
getStyle: () => ({ version: 8, sources: {}, layers: waterNetworkLayers })
} as unknown as Pick<MapLibreMap, "getLayer" | "getStyle">;
expect(getWorkbenchLayerIds(map, "valves")).toEqual([
"valves-casing",
"valves",
"valves-symbol",
"valves-hover",
"valves-selected-outer",
"valves-selected",
"valves-hit"
]);
expect(getWorkbenchLayerIds(map, "reservoirs")).toEqual([
"reservoirs-halo",
"reservoirs",
"reservoirs-symbol",
"reservoirs-hover",
"reservoirs-selected-outer",
"reservoirs-selected",
"reservoirs-hit"
]);
});
});
@@ -0,0 +1,114 @@
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 { GEOSERVER_WORKSPACE } from "../../../lib/config";
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 { SUPPLY_ASSET_ICON_PATHS } from "./supply-icons";
export const WORKBENCH_LAYER_GROUPS: Record<string, string[]> = {
simulation: [
"simulation-impact-fill",
"simulation-impact-outline",
"simulation-burst-halo",
"simulation-burst-point",
"simulation-pipe-label",
"simulation-impact-label",
"simulation-district-label",
"simulation-burst-label"
]
};
export const INITIAL_LAYER_VISIBILITY: Record<string, boolean> = {
...Object.fromEntries(AVAILABLE_WATER_NETWORK_SOURCE_IDS.map((sourceId) => [sourceId, true])),
simulation: false
};
const SOURCE_CONTROL_LABELS = Object.fromEntries(
SUPPLY_LAYER_CATALOG
.filter((layer) => layer.available)
.map((layer) => [
layer.id,
{
label: layer.label,
description: `${GEOSERVER_WORKSPACE}:${layer.sourceLayer}`
}
])
) as Record<WaterNetworkSourceId, { label: string; description: string }>;
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: "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" }
];
export const BASE_LAYER_IDS = ["mapbox-light", "mapbox-satellite"] as const;
export const BASE_LAYER_OPTIONS: BaseLayerOption[] = [
{
id: "mapbox-light",
label: "浅色",
description: "道路底图",
swatchClassName: "bg-gradient-to-br from-slate-50 via-slate-100 to-blue-100"
},
{
id: "mapbox-satellite",
label: "影像",
description: "卫星街道",
swatchClassName: "bg-gradient-to-br from-emerald-950 via-emerald-700 to-yellow-200"
},
{
id: "none",
label: "无底图",
description: "仅业务图层",
swatchClassName: "bg-slate-100"
}
];
export function createLayerControlItems(layerVisibility: Record<string, boolean>): MapLayerControlItem[] {
return [
...AVAILABLE_WATER_NETWORK_SOURCE_IDS.map((sourceId) => ({
id: sourceId,
...SOURCE_CONTROL_LABELS[sourceId],
visible: layerVisibility[sourceId]
})),
{
id: "simulation",
label: "模拟结果",
description: "影响范围与事故标注",
visible: layerVisibility.simulation
}
];
}
export function getWorkbenchLayerIds(
map: Pick<MapLibreMap, "getLayer" | "getStyle">,
groupId: string
) {
if (isWaterNetworkSourceId(groupId)) {
return map
.getStyle()
.layers.filter((layer) => "source" in layer && layer.source === groupId)
.map((layer) => layer.id);
}
const layerIds = WORKBENCH_LAYER_GROUPS[groupId] ?? [];
return layerIds.filter((layerId) => map.getLayer(layerId));
}
function isWaterNetworkSourceId(value: string): value is WaterNetworkSourceId {
return (AVAILABLE_WATER_NETWORK_SOURCE_IDS as readonly string[]).includes(value);
}
@@ -0,0 +1,57 @@
import { describe, expect, it } from "vitest";
import {
MAX_CONDUIT_FEATURES,
MAP_FEATURE_ID_FIELDS,
createMapFeatureWfsUrl,
escapeCqlLiteral,
normalizeMapFeatureCollection,
parseMapFeatureQuery
} from "./map-feature-query";
describe("map feature WFS query", () => {
it("maps source identifiers to authoritative feature fields", () => {
expect(MAP_FEATURE_ID_FIELDS).toMatchObject({
pipes: "id",
junctions: "id",
valves: "id",
reservoirs: "id",
scada: "id",
pumps: "id",
tanks: "id"
});
expect(createMapFeatureWfsUrl({ sourceId: "junctions", featureIds: ["J-1"] }).searchParams.get("cql_filter"))
.toBe("id IN ('J-1')");
});
it("escapes CQL literals and uses materialized WFS layers", () => {
expect(escapeCqlLiteral("a'b")).toBe("'a''b'");
const url = createMapFeatureWfsUrl({ sourceId: "pipes", featureIds: ["a'b"] });
expect(url.searchParams.get("typeNames")).toContain("geo_pipes_mat");
expect(url.searchParams.get("srsName")).toBe("EPSG:4326");
expect(url.searchParams.get("cql_filter")).toBe("id IN ('a''b')");
});
it("uses the authoritative supply asset WFS layers", () => {
expect(createMapFeatureWfsUrl({ sourceId: "valves", featureIds: ["V-1"] }).searchParams.get("typeNames")).toContain("geo_valves");
expect(createMapFeatureWfsUrl({ sourceId: "reservoirs", featureIds: ["R-1"] }).searchParams.get("typeNames")).toContain("geo_reservoirs");
expect(createMapFeatureWfsUrl({ sourceId: "scada", featureIds: ["S-1"] }).searchParams.get("typeNames")).toContain("geo_scada");
});
it("limits ids and permits full-network requests only for pipes", () => {
expect(parseMapFeatureQuery({ sourceId: "pipes" })).toEqual({ ok: true, value: { sourceId: "pipes" } });
expect(createMapFeatureWfsUrl({ sourceId: "pipes" }).searchParams.get("count")).toBe(String(MAX_CONDUIT_FEATURES));
expect(parseMapFeatureQuery({ sourceId: "junctions" })).toMatchObject({ ok: false, status: 400 });
expect(parseMapFeatureQuery({ sourceId: "unknown", featureIds: ["1"] })).toMatchObject({ ok: false, status: 422 });
expect(parseMapFeatureQuery({ sourceId: "pumps", featureIds: ["P-1"] })).toMatchObject({ ok: false, status: 422 });
expect(parseMapFeatureQuery({ sourceId: "tanks", featureIds: ["T-1"] })).toMatchObject({ ok: false, status: 422 });
expect(parseMapFeatureQuery({ sourceId: "pipes", featureIds: Array.from({ length: 101 }, (_, index) => String(index)) }))
.toMatchObject({ ok: false, status: 400 });
expect(parseMapFeatureQuery({ sourceId: "pipes", featureIds: ["x".repeat(129)] }))
.toMatchObject({ ok: false, status: 400 });
});
it("preserves an empty result and rejects malformed upstream data", () => {
expect(normalizeMapFeatureCollection({ type: "FeatureCollection", features: [] }, 100)).toEqual({ type: "FeatureCollection", features: [] });
expect(normalizeMapFeatureCollection({ features: [] }, 100)).toBeNull();
});
});
@@ -0,0 +1,93 @@
import type { FeatureCollection } from "geojson";
import { GEOSERVER_WORKSPACE, MAP_URL } from "../../../lib/config";
import {
SOURCE_LAYERS,
isAvailableWaterNetworkSourceId,
type WaterNetworkSourceId
} from "./sources";
export const MAP_FEATURE_ID_FIELDS: Record<WaterNetworkSourceId, "id"> = {
pipes: "id",
junctions: "id",
valves: "id",
reservoirs: "id",
scada: "id",
pumps: "id",
tanks: "id"
};
export const MAX_MAP_FEATURE_IDS = 100;
export const MAX_MAP_FEATURE_ID_LENGTH = 128;
export const MAX_CONDUIT_FEATURES = 5000;
export type MapFeatureQuery = {
sourceId: WaterNetworkSourceId;
featureIds?: string[];
};
export type MapFeatureQueryValidation =
| { ok: true; value: MapFeatureQuery }
| { ok: false; code: "INVALID_REQUEST" | "UNSUPPORTED_SOURCE"; status: 400 | 422 };
export function parseMapFeatureQuery(body: unknown): MapFeatureQueryValidation {
if (!body || typeof body !== "object") {
return { ok: false, code: "INVALID_REQUEST", status: 400 };
}
const { sourceId, featureIds } = body as { sourceId?: unknown; featureIds?: unknown };
if (typeof sourceId !== "string" || !(sourceId in SOURCE_LAYERS) || !isAvailableWaterNetworkSourceId(sourceId)) {
return { ok: false, code: "UNSUPPORTED_SOURCE", status: 422 };
}
if (featureIds === undefined) {
return sourceId === "pipes"
? { ok: true, value: { sourceId } }
: { ok: false, code: "INVALID_REQUEST", status: 400 };
}
if (
!Array.isArray(featureIds) ||
featureIds.length < 1 ||
featureIds.length > MAX_MAP_FEATURE_IDS ||
!featureIds.every(
(id) => typeof id === "string" && id.trim().length > 0 && id.trim().length <= MAX_MAP_FEATURE_ID_LENGTH
)
) {
return { ok: false, code: "INVALID_REQUEST", status: 400 };
}
return {
ok: true,
value: { sourceId: sourceId as WaterNetworkSourceId, featureIds: featureIds.map((id) => id.trim()) }
};
}
export function escapeCqlLiteral(value: string) {
return `'${value.replaceAll("'", "''")}'`;
}
export function createMapFeatureWfsUrl(query: MapFeatureQuery) {
const idField = MAP_FEATURE_ID_FIELDS[query.sourceId];
const params = new URLSearchParams({
service: "WFS",
version: "2.0.0",
request: "GetFeature",
typeNames: `${GEOSERVER_WORKSPACE}:${SOURCE_LAYERS[query.sourceId]}`,
outputFormat: "application/json",
srsName: "EPSG:4326",
count: String(query.featureIds ? MAX_MAP_FEATURE_IDS : MAX_CONDUIT_FEATURES)
});
if (query.featureIds) {
params.set("cql_filter", `${idField} IN (${query.featureIds.map(escapeCqlLiteral).join(",")})`);
}
return new URL(`${MAP_URL}/${GEOSERVER_WORKSPACE}/ows?${params.toString()}`);
}
export function normalizeMapFeatureCollection(value: unknown, maxFeatures: number): FeatureCollection | null {
if (!value || typeof value !== "object") return null;
const collection = value as FeatureCollection;
if (collection.type !== "FeatureCollection" || !Array.isArray(collection.features)) return null;
return { type: "FeatureCollection", features: collection.features.slice(0, maxFeatures) };
}
@@ -0,0 +1,274 @@
import type { ExpressionSpecification, StyleSpecification } from "maplibre-gl";
type LayerSpecification = StyleSpecification["layers"][number];
type LineLayerSpecification = Extract<LayerSpecification, { type: "line" }>;
type CircleLayerSpecification = Extract<LayerSpecification, { type: "circle" }>;
type SymbolLayerSpecification = Extract<LayerSpecification, { type: "symbol" }>;
type LineWidth = NonNullable<NonNullable<LineLayerSpecification["paint"]>["line-width"]>;
type CircleRadius = NonNullable<NonNullable<CircleLayerSpecification["paint"]>["circle-radius"]>;
type IconSize = NonNullable<NonNullable<SymbolLayerSpecification["layout"]>["icon-size"]>;
export const MAP_MAX_ZOOM = 24;
const lineHitWidth = [
"interpolate",
["linear"],
["zoom"],
11,
16,
17,
18,
22,
22,
24,
24
] as LineWidth;
const pointHitRadius = [
"interpolate",
["linear"],
["zoom"],
9,
9,
12,
9,
16,
10,
20,
12,
24,
14
] as CircleRadius;
const pipeWidthAtZoom = (
minor: number,
medium: number,
major: number
) => [
"case",
[">=", ["to-number", ["coalesce", ["get", "diameter"], 0]], 600],
major,
[">=", ["to-number", ["coalesce", ["get", "diameter"], 0]], 300],
medium,
minor
] as ExpressionSpecification;
const supplyPipeWidth = [
"interpolate",
["linear"],
["zoom"],
9,
pipeWidthAtZoom(0.7, 1, 1.4),
11,
pipeWidthAtZoom(0.9, 1.3, 1.8),
14,
pipeWidthAtZoom(1.2, 1.8, 2.6),
17,
pipeWidthAtZoom(1.8, 2.8, 4),
20,
pipeWidthAtZoom(2.6, 4.2, 6),
24,
pipeWidthAtZoom(3.2, 5.2, 7)
] as unknown as LineWidth;
const supplyPipeCasingWidth = [
"interpolate",
["linear"],
["zoom"],
9,
pipeWidthAtZoom(2.1, 2.4, 2.8),
11,
pipeWidthAtZoom(2.3, 2.7, 3.2),
14,
pipeWidthAtZoom(2.7, 3.3, 4.1),
17,
pipeWidthAtZoom(3.6, 4.6, 5.8),
20,
pipeWidthAtZoom(4.5, 6.1, 7.9),
24,
pipeWidthAtZoom(5.2, 7.2, 9)
] as unknown as LineWidth;
const facilityRadius = [
"interpolate", ["linear"], ["zoom"],
8.5, 3,
10, 4.2,
12, 5.5,
16, 7,
20, 9,
24, 11
] as CircleRadius;
const facilityCasingRadius = [
"interpolate", ["linear"], ["zoom"],
8.5, 4,
10, 5.4,
12, 7,
16, 8.5,
20, 10.5,
24, 12.5
] as CircleRadius;
const facilityHoverRadius = [
"interpolate", ["linear"], ["zoom"],
8.5, 5,
10, 6.5,
12, 8,
16, 9.5,
20, 11.5,
24, 13.5
] as CircleRadius;
const facilitySelectedRadius = [
"interpolate", ["linear"], ["zoom"],
8.5, 5.5,
10, 7,
12, 8.5,
16, 10,
20, 12,
24, 14
] as CircleRadius;
const facilityIconSize = [
"interpolate", ["linear"], ["zoom"],
8.5, 0.18,
10, 0.23,
12, 0.29,
16, 0.36,
20, 0.46,
24, 0.56
] as IconSize;
export const DRAINAGE_LAYER_VISUALS = {
conduits: {
minzoom: 9,
casingWidth: [
"interpolate", ["linear"], ["zoom"],
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, 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,
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,
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, 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,
selectedRadius: ["interpolate", ["linear"], ["zoom"], 12, 5.5, 16, 7.5, 20, 10.5, 24, 13] as CircleRadius,
hitRadius: pointHitRadius
},
orifices: {
minzoom: 11,
casingWidth: ["interpolate", ["linear"], ["zoom"], 11, 3.2, 17, 7.5, 22, 9, 24, 10] as LineWidth,
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,
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: {
minzoom: 11,
casingWidth: ["interpolate", ["linear"], ["zoom"], 11, 4, 17, 9.5, 22, 11, 24, 12] as LineWidth,
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,
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: {
minzoom: 11,
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,
selectedRadius: ["interpolate", ["linear"], ["zoom"], 11, 7.5, 16, 9.5, 20, 12.5, 24, 15] as CircleRadius,
hitRadius: pointHitRadius
}
} as const;
export const SUPPLY_LAYER_VISUALS = {
pipes: {
minzoom: 8.5,
casingWidth: supplyPipeCasingWidth,
width: supplyPipeWidth,
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,
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: 15,
haloRadius: ["interpolate", ["linear"], ["zoom"], 13, 2.8, 17, 6] as CircleRadius,
haloOpacity: ["interpolate", ["linear"], ["zoom"], 15, 0, 17, 0, 19, 0.55, 24, 0.82] as const,
radius: ["interpolate", ["linear"], ["zoom"], 13, 1.5, 17, 3.8] as CircleRadius,
hoverRadius: ["interpolate", ["linear"], ["zoom"], 13, 5, 17, 10] as CircleRadius,
selectedRadius: ["interpolate", ["linear"], ["zoom"], 13, 5.5, 17, 10.5] as CircleRadius,
hitRadius: pointHitRadius
},
valves: {
minzoom: 8.5,
casingWidth: ["interpolate", ["linear"], ["zoom"], 11, 3.2, 17, 7.5, 22, 9, 24, 10] as LineWidth,
width: ["interpolate", ["linear"], ["zoom"], 11, 1.6, 17, 4.4, 22, 6, 24, 7] as LineWidth,
haloRadius: facilityCasingRadius,
radius: facilityRadius,
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,
hoverRadius: facilityHoverRadius,
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,
selectedRadius: facilitySelectedRadius,
hitWidth: lineHitWidth,
hitRadius: pointHitRadius,
iconSize: facilityIconSize
},
reservoirs: {
minzoom: 8.5,
haloRadius: ["interpolate", ["linear"], ["zoom"], 8.5, 7, 10, 8.5, 12, 9.5, 16, 11.5, 20, 13.5, 24, 15.5] as CircleRadius,
radius: ["interpolate", ["linear"], ["zoom"], 8.5, 5.5, 10, 7, 12, 8, 16, 10, 20, 12, 24, 14] as CircleRadius,
hoverRadius: ["interpolate", ["linear"], ["zoom"], 8.5, 8, 10, 9.5, 12, 10.5, 16, 12.5, 20, 14.5, 24, 16.5] as CircleRadius,
selectedRadius: ["interpolate", ["linear"], ["zoom"], 8.5, 8.5, 10, 10, 12, 11, 16, 13, 20, 15, 24, 17] as CircleRadius,
hitRadius: pointHitRadius,
iconSize: ["interpolate", ["linear"], ["zoom"], 8.5, 0.28, 10, 0.36, 12, 0.42, 16, 0.52, 20, 0.64, 24, 0.76] as IconSize
},
scada: {
minzoom: 8.5,
haloRadius: ["interpolate", ["linear"], ["zoom"], 8.5, 5.5, 10, 7, 12, 8.5, 16, 10, 20, 12, 24, 14] as CircleRadius,
radius: ["interpolate", ["linear"], ["zoom"], 8.5, 4, 10, 5.5, 12, 7, 16, 8.5, 20, 10.5, 24, 12.5] as CircleRadius,
hoverRadius: ["interpolate", ["linear"], ["zoom"], 8.5, 6.5, 10, 8, 12, 9.5, 16, 11, 20, 13, 24, 15] as CircleRadius,
selectedRadius: ["interpolate", ["linear"], ["zoom"], 8.5, 7, 10, 8.5, 12, 10, 16, 11.5, 20, 13.5, 24, 15.5] as CircleRadius,
hitRadius: pointHitRadius,
iconSize: ["interpolate", ["linear"], ["zoom"], 8.5, 0.25, 10, 0.32, 12, 0.4, 16, 0.48, 20, 0.6, 24, 0.72] as IconSize
},
pumps: {
minzoom: 11,
hoverRadius: 14,
selectedRadius: 12.5,
hitRadius: 16,
iconSize: ["interpolate", ["linear"], ["zoom"], 11, 0.44, 16, 0.66, 20, 0.82, 24, 0.96] as const
},
tanks: {
minzoom: 11,
hoverRadius: 14,
selectedRadius: 12.5,
hitRadius: 16,
iconSize: ["interpolate", ["linear"], ["zoom"], 11, 0.44, 16, 0.66, 20, 0.82, 24, 0.96] as const
}
} as const;
@@ -0,0 +1,155 @@
import type {
FilterSpecification,
GeoJSONSource,
GeoJSONSourceSpecification,
StyleSpecification
} 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";
export type MeasurementFeatureCollection = FeatureCollection<Geometry, { kind: "vertex" | "line" | "polygon" }>;
export const emptyMeasurementFeatureCollection: MeasurementFeatureCollection = {
type: "FeatureCollection",
features: []
};
export const measurementSource = {
type: "geojson",
data: emptyMeasurementFeatureCollection
} satisfies GeoJSONSourceSpecification;
export const measurementLayers: StyleSpecification["layers"] = [
{
id: MEASUREMENT_SELECTED_PIPES_LAYER_ID,
type: "line",
source: "pipes",
"source-layer": SOURCE_LAYERS.pipes,
minzoom: 9,
filter: ["in", ["get", "id"], ["literal", []]],
paint: {
"line-color": MAP_STYLE_TOKENS.state.selected,
"line-width": ["interpolate", ["linear"], ["zoom"], 9, 4, 13, 7, 17, 11],
"line-opacity": 0.68
}
},
{
id: "workbench-measurement-polygon-fill",
type: "fill",
source: MEASUREMENT_SOURCE_ID,
filter: ["==", ["get", "kind"], "polygon"],
paint: {
"fill-color": MAP_STYLE_TOKENS.tool.geometry,
"fill-opacity": 0.12
}
},
{
id: "workbench-measurement-line",
type: "line",
source: MEASUREMENT_SOURCE_ID,
filter: ["any", ["==", ["get", "kind"], "line"], ["==", ["get", "kind"], "polygon"]],
paint: {
"line-color": MAP_STYLE_TOKENS.tool.geometry,
"line-width": 2.5,
"line-dasharray": [2, 1.5],
"line-opacity": 0.9
}
},
{
id: "workbench-measurement-vertex",
type: "circle",
source: MEASUREMENT_SOURCE_ID,
filter: ["==", ["get", "kind"], "vertex"],
paint: {
"circle-color": MAP_STYLE_TOKENS.state.selected,
"circle-radius": 4,
"circle-stroke-color": MAP_STYLE_TOKENS.canvas.casing,
"circle-stroke-width": 1.5
}
}
];
export function ensureMeasurementLayers(map: {
getSource: (id: string) => unknown;
addSource: (id: string, source: GeoJSONSourceSpecification) => void;
getLayer: (id: string) => unknown;
addLayer: (layer: NonNullable<StyleSpecification["layers"]>[number], beforeId?: string) => void;
}) {
if (!map.getSource(MEASUREMENT_SOURCE_ID)) {
map.addSource(MEASUREMENT_SOURCE_ID, measurementSource);
}
measurementLayers.forEach((layer) => {
if (!map.getLayer(layer.id)) {
map.addLayer(layer, WORKBENCH_INTERACTION_BEFORE_ID);
}
});
}
export function setMeasurementSourceData(
map: { getSource: (id: string) => unknown },
data: MeasurementFeatureCollection
) {
const source = map.getSource(MEASUREMENT_SOURCE_ID) as GeoJSONSource | undefined;
source?.setData(data);
}
export function setSelectedMeasurementPipeIds(
map: { getLayer: (id: string) => unknown; setFilter: (id: string, filter?: FilterSpecification) => void },
pipeIds: string[]
) {
if (!map.getLayer(MEASUREMENT_SELECTED_PIPES_LAYER_ID)) {
return;
}
map.setFilter(MEASUREMENT_SELECTED_PIPES_LAYER_ID, ["in", ["get", "id"], ["literal", pipeIds]]);
}
export function createMeasurementFeatureCollection(
coordinates: [number, number][],
mode: "distance" | "area" | "segment"
): MeasurementFeatureCollection {
const features: MeasurementFeatureCollection["features"] = coordinates.map((coordinate, index) => ({
type: "Feature",
id: `measurement-vertex-${index}`,
properties: { kind: "vertex" },
geometry: {
type: "Point",
coordinates: coordinate
} satisfies Point
}));
if (coordinates.length >= 2) {
features.push({
type: "Feature",
id: "measurement-line",
properties: { kind: "line" },
geometry: {
type: "LineString",
coordinates
} satisfies LineString
});
}
if (mode === "area" && coordinates.length >= 3) {
features.push({
type: "Feature",
id: "measurement-polygon",
properties: { kind: "polygon" },
geometry: {
type: "Polygon",
coordinates: [[...coordinates, coordinates[0]]]
} satisfies Polygon
});
}
return {
type: "FeatureCollection",
features
};
}
@@ -0,0 +1,90 @@
import type { FeatureCollection } from "geojson";
import { describe, expect, it } from "vitest";
import {
SCADA_ANALYSIS_LAYER_IDS,
SCADA_ANALYSIS_LEVEL_COLORS,
createScadaAnalysisCollection,
parseScadaAnalysisItems,
scadaAnalysisLayers
} from "./scada-analysis";
describe("SCADA analysis overlay", () => {
it("uses distinct semantic rings, status indicators, and legible priority labels", () => {
const casing = scadaAnalysisLayers.find((layer) => layer.id === SCADA_ANALYSIS_LAYER_IDS.casing);
const level = scadaAnalysisLayers.find((layer) => layer.id === SCADA_ANALYSIS_LAYER_IDS.level);
const indicator = scadaAnalysisLayers.find((layer) => layer.id === SCADA_ANALYSIS_LAYER_IDS.indicator);
const label = scadaAnalysisLayers.find((layer) => layer.id === SCADA_ANALYSIS_LAYER_IDS.label);
expect(SCADA_ANALYSIS_LEVEL_COLORS).toEqual({
high: "#F43F5E",
medium: "#F59E0B",
low: "#10B981",
unrated: "#8B5CF6"
});
expect(casing).toMatchObject({
type: "circle",
paint: { "circle-radius": 20, "circle-stroke-color": "#FFFFFF", "circle-stroke-width": 8 }
});
expect(JSON.stringify(level)).toContain(SCADA_ANALYSIS_LEVEL_COLORS.high);
expect(JSON.stringify(level)).toContain(SCADA_ANALYSIS_LEVEL_COLORS.medium);
expect(JSON.stringify(level)).toContain(SCADA_ANALYSIS_LEVEL_COLORS.low);
expect(JSON.stringify(level)).toContain(SCADA_ANALYSIS_LEVEL_COLORS.unrated);
expect(level).toMatchObject({ paint: { "circle-radius": 20, "circle-stroke-width": 5 } });
expect(indicator).toMatchObject({
type: "circle",
paint: {
"circle-radius": 6,
"circle-translate": [14, -14],
"circle-stroke-color": "#FFFFFF",
"circle-stroke-width": 2
}
});
expect(label).toMatchObject({
type: "symbol",
layout: {
"symbol-sort-key": ["get", "sort_priority"],
"text-field": ["get", "analysis_label"],
"text-size": 18,
"text-font": ["Noto Sans Regular"],
"text-allow-overlap": false
},
paint: { "text-halo-color": "#FFFFFF", "text-halo-width": 4, "text-halo-blur": 0.5 }
});
expect(JSON.stringify(label)).toContain("#991B1B");
expect(JSON.stringify(label)).toContain("#92400E");
expect(JSON.stringify(label)).toContain("#166534");
});
it("validates one to one hundred unique IDs and fixed levels", () => {
expect(parseScadaAnalysisItems([{ sensor_id: " MP01 ", level: "high" }])).toEqual([
{ sensor_id: "MP01", level: "high" }
]);
expect(parseScadaAnalysisItems([])).toBeNull();
expect(parseScadaAnalysisItems([{ sensor_id: "MP01", level: "high" }, { sensor_id: "MP01", level: "low" }])).toBeNull();
expect(parseScadaAnalysisItems([{ sensor_id: "MP01", level: "critical" }])).toBeNull();
});
it("uses trusted WFS sensor IDs for localized labels and reports partial misses", () => {
const result = createScadaAnalysisCollection(scadaCollection, [
{ sensor_id: "MP01", level: "high" },
{ sensor_id: "MP02", level: "medium" },
{ sensor_id: "MP404", level: "low" }
]);
expect(result.result).toEqual({
rendered_ids: ["MP01", "MP02"],
missing_ids: ["MP404"],
level_counts: { high: 1, medium: 1, low: 0, unrated: 0 }
});
expect(result.collection.features.map((feature) => feature.properties)).toEqual([
expect.objectContaining({ sensor_id: "MP01", analysis_label: "MP01 · 高", sort_priority: 0 }),
expect.objectContaining({ sensor_id: "MP02", analysis_label: "MP02 · 中", sort_priority: 1 })
]);
});
});
const scadaCollection: FeatureCollection = {
type: "FeatureCollection",
features: [
{ type: "Feature", geometry: { type: "Point", coordinates: [120.7, 28] }, properties: { sensor_id: "MP01" } },
{ type: "Feature", geometry: { type: "Point", coordinates: [120.72, 28.02] }, properties: { sensor_id: "MP02" } }
]
};
@@ -0,0 +1,214 @@
import type { Feature, FeatureCollection, Point } from "geojson";
import type { ExpressionSpecification, Map as MapLibreMap, StyleSpecification } from "maplibre-gl";
export const SCADA_ANALYSIS_SOURCE_ID = "agent-scada-analysis";
export const SCADA_ANALYSIS_LAYER_IDS = {
casing: "agent-scada-analysis-casing",
level: "agent-scada-analysis-level",
indicator: "agent-scada-analysis-indicator",
label: "agent-scada-analysis-label"
} as const;
export const SCADA_ANALYSIS_LEVELS = ["high", "medium", "low", "unrated"] as const;
export type ScadaAnalysisLevel = (typeof SCADA_ANALYSIS_LEVELS)[number];
export type ScadaAnalysisItem = { sensor_id: string; level: ScadaAnalysisLevel };
export type ScadaAnalysisLevelCounts = Record<ScadaAnalysisLevel, number>;
export type ScadaAnalysisRenderResult = {
rendered_ids: string[];
missing_ids: string[];
level_counts: ScadaAnalysisLevelCounts;
fitted: true;
};
export const SCADA_ANALYSIS_LEVEL_COLORS: Record<ScadaAnalysisLevel, string> = {
high: "#F43F5E",
medium: "#F59E0B",
low: "#10B981",
unrated: "#8B5CF6"
};
const SCADA_ANALYSIS_LABEL_COLORS: Record<ScadaAnalysisLevel, string> = {
high: "#991B1B",
medium: "#92400E",
low: "#166534",
unrated: "#5B21B6"
};
const levelColorExpression = (
colors: Record<ScadaAnalysisLevel, string>
): ExpressionSpecification => [
"match",
["get", "analysis_level"],
"high", colors.high,
"medium", colors.medium,
"low", colors.low,
colors.unrated
];
export const SCADA_ANALYSIS_LEVEL_LABELS: Record<ScadaAnalysisLevel, string> = {
high: "高",
medium: "中",
low: "低",
unrated: "未分级"
};
const SCADA_ANALYSIS_SORT_PRIORITY: Record<ScadaAnalysisLevel, number> = {
high: 0,
medium: 1,
low: 2,
unrated: 3
};
type Layer = NonNullable<StyleSpecification["layers"]>[number];
export const scadaAnalysisLayers: Layer[] = [
{
id: SCADA_ANALYSIS_LAYER_IDS.casing,
type: "circle",
source: SCADA_ANALYSIS_SOURCE_ID,
paint: {
"circle-radius": 20,
"circle-color": "rgba(255,255,255,0)",
"circle-stroke-color": "#FFFFFF",
"circle-stroke-width": 8
}
},
{
id: SCADA_ANALYSIS_LAYER_IDS.level,
type: "circle",
source: SCADA_ANALYSIS_SOURCE_ID,
paint: {
"circle-radius": 20,
"circle-color": "rgba(255,255,255,0)",
"circle-stroke-color": levelColorExpression(SCADA_ANALYSIS_LEVEL_COLORS),
"circle-stroke-width": 5
}
},
{
id: SCADA_ANALYSIS_LAYER_IDS.indicator,
type: "circle",
source: SCADA_ANALYSIS_SOURCE_ID,
paint: {
"circle-radius": 6,
"circle-translate": [14, -14],
"circle-color": levelColorExpression(SCADA_ANALYSIS_LEVEL_COLORS),
"circle-stroke-color": "#FFFFFF",
"circle-stroke-width": 2
}
},
{
id: SCADA_ANALYSIS_LAYER_IDS.label,
type: "symbol",
source: SCADA_ANALYSIS_SOURCE_ID,
layout: {
"symbol-sort-key": ["get", "sort_priority"],
"text-field": ["get", "analysis_label"],
"text-size": 18,
"text-font": ["Noto Sans Regular"],
"text-letter-spacing": 0.015,
"text-offset": [0, -2.2],
"text-anchor": "bottom",
"text-allow-overlap": false,
"text-ignore-placement": false
},
paint: {
"text-color": levelColorExpression(SCADA_ANALYSIS_LABEL_COLORS),
"text-halo-color": "#FFFFFF",
"text-halo-width": 4,
"text-halo-blur": 0.5
}
}
];
export function ensureScadaAnalysisLayers(
map: Pick<MapLibreMap, "addLayer" | "getLayer">
): void {
scadaAnalysisLayers.forEach((layer, index) => {
if (map.getLayer(layer.id)) return;
const beforeId = scadaAnalysisLayers
.slice(index + 1)
.map((candidate) => candidate.id)
.find((candidateId) => Boolean(map.getLayer(candidateId)));
map.addLayer(layer, beforeId);
});
}
export function createEmptyScadaAnalysisCollection(): FeatureCollection<Point> {
return { type: "FeatureCollection", features: [] };
}
export function parseScadaAnalysisItems(value: unknown): ScadaAnalysisItem[] | null {
if (!Array.isArray(value) || value.length < 1 || value.length > 100) return null;
const items: ScadaAnalysisItem[] = [];
const seen = new Set<string>();
for (const entry of value) {
if (!entry || typeof entry !== "object" || Array.isArray(entry)) return null;
const keys = Object.keys(entry);
if (keys.length !== 2 || !keys.includes("sensor_id") || !keys.includes("level")) return null;
const { sensor_id: rawSensorId, level } = entry as Record<string, unknown>;
const sensorId = typeof rawSensorId === "string" ? rawSensorId.trim() : "";
if (
!sensorId ||
sensorId.length > 128 ||
!SCADA_ANALYSIS_LEVELS.includes(level as ScadaAnalysisLevel) ||
seen.has(sensorId)
) return null;
seen.add(sensorId);
items.push({ sensor_id: sensorId, level: level as ScadaAnalysisLevel });
}
return items;
}
export function createScadaAnalysisCollection(
collection: FeatureCollection,
items: ScadaAnalysisItem[]
): { collection: FeatureCollection<Point>; result: Omit<ScadaAnalysisRenderResult, "fitted"> } {
const featuresBySensorId = new Map<string, Feature<Point>>();
collection.features.forEach((feature) => {
const sensorId = typeof feature.properties?.sensor_id === "string"
? feature.properties.sensor_id.trim()
: "";
if (sensorId && feature.geometry?.type === "Point" && !featuresBySensorId.has(sensorId)) {
featuresBySensorId.set(sensorId, feature as Feature<Point>);
}
});
const levelCounts = createEmptyScadaAnalysisLevelCounts();
const renderedIds: string[] = [];
const missingIds: string[] = [];
const features: Array<Feature<Point>> = [];
items.forEach((item) => {
const feature = featuresBySensorId.get(item.sensor_id);
if (!feature) {
missingIds.push(item.sensor_id);
return;
}
const trustedSensorId = String(feature.properties?.sensor_id ?? "").trim();
if (!trustedSensorId) {
missingIds.push(item.sensor_id);
return;
}
renderedIds.push(trustedSensorId);
levelCounts[item.level] += 1;
features.push({
...feature,
properties: {
...feature.properties,
sensor_id: trustedSensorId,
analysis_level: item.level,
analysis_level_label: SCADA_ANALYSIS_LEVEL_LABELS[item.level],
analysis_label: `${trustedSensorId} · ${SCADA_ANALYSIS_LEVEL_LABELS[item.level]}`,
sort_priority: SCADA_ANALYSIS_SORT_PRIORITY[item.level]
}
});
});
return {
collection: { type: "FeatureCollection", features },
result: { rendered_ids: renderedIds, missing_ids: missingIds, level_counts: levelCounts }
};
}
export function createEmptyScadaAnalysisLevelCounts(): ScadaAnalysisLevelCounts {
return { high: 0, medium: 0, low: 0, unrated: 0 };
}
+125
View File
@@ -0,0 +1,125 @@
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";
import { waterNetworkLayers } from "./layers";
import { MAP_STYLE_TOKENS } from "./map-colors";
import { SUPPLY_LAYER_VISUALS } from "./map-layer-visuals";
describe("SCADA map presentation", () => {
it("maps the authoritative device types and falls back to unknown", () => {
expect(SCADA_ICON_EXPRESSION).toEqual([
"match", ["downcase", ["to-string", ["coalesce", ["get", "type"], ""]]],
"pipe_flow", SCADA_IMAGE_IDS.flow,
"pressure", SCADA_IMAGE_IDS.pressure,
SCADA_IMAGE_IDS.pressure
]);
});
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("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 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;
const junctionSelectedPaint = junctionSelectedLayer?.paint as Record<string, unknown> | undefined;
expect(symbolLayer).toMatchObject({
type: "symbol",
layout: {
"icon-image": SCADA_ICON_EXPRESSION,
"icon-size": SUPPLY_LAYER_VISUALS.scada.iconSize
}
});
expect(outerLayer).toMatchObject({
type: "circle",
paint: {
"circle-radius": SUPPLY_LAYER_VISUALS.scada.selectedRadius,
"circle-stroke-width": junctionOuterPaint?.["circle-stroke-width"]
}
});
expect(selectedLayer).toMatchObject({
type: "circle",
paint: {
"circle-stroke-width": junctionSelectedPaint?.["circle-stroke-width"]
}
});
expect(JSON.stringify((selectedLayer as { paint?: unknown })?.paint)).toContain("highlighted");
});
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);
expect(colorExpression).toContain("pipe_flow");
expect(colorExpression).toContain(MAP_STYLE_TOKENS.supply.scadaFlow);
expect(colorExpression).toContain(MAP_STYLE_TOKENS.supply.scadaPressure);
});
it("uses MapLibre-compatible PNG assets", () => {
expect(Object.values(SCADA_MAP_ICON_PATHS)).toHaveLength(2);
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(2);
expect(map.addImage).toHaveBeenCalledTimes(2);
expect(map.addImage).toHaveBeenCalledWith(expect.any(String), image, { pixelRatio: 2 });
});
it("falls back to the pressure image when flow fails", async () => {
const image = { width: 64, height: 64 };
const map = createImageMap((path) => path.includes("scada-flow")
? Promise.reject(new Error("decode failed"))
: Promise.resolve({ data: image }));
await expect(registerScadaImages(map)).resolves.toEqual({
status: "degraded",
failedImageIds: [SCADA_IMAGE_IDS.flow]
});
expect(map.addImage).toHaveBeenCalledWith(SCADA_IMAGE_IDS.flow, 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.map((layer) => layer.id).slice(0, 3)).toEqual([
"scada-casing",
"scada-fill",
"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>;
};
}
+173
View File
@@ -0,0 +1,173 @@
import type { ExpressionSpecification, Map as MapLibreMap, StyleSpecification } from "maplibre-gl";
import { MAP_STYLE_TOKENS } from "./map-colors";
import { SUPPLY_LAYER_VISUALS } from "./map-layer-visuals";
import {
createSupplyStateColor,
supplyFacilityOpacityExpression,
supplyIconOpacityExpression
} from "./supply-style-expressions";
export const SCADA_SOURCE_ID = "scada";
export const SCADA_SOURCE_LAYER = "geo_scada";
export const SCADA_HIT_LAYER_ID = "scada-hit";
export const SCADA_ICON_PATHS = {
flow: "/map/scada-flow.png",
pressure: "/map/scada-pressure.png"
} as const;
export const SCADA_MAP_ICON_PATHS = SCADA_ICON_PATHS;
export const SCADA_IMAGE_IDS = {
flow: "scada-flow",
pressure: "scada-pressure"
} as const;
export const SCADA_ICON_EXPRESSION: ExpressionSpecification = [
"match", ["downcase", ["to-string", ["coalesce", ["get", "type"], ""]]],
"pipe_flow", SCADA_IMAGE_IDS.flow,
"pressure", SCADA_IMAGE_IDS.pressure,
SCADA_IMAGE_IDS.pressure
];
const stateOpacity = (state: "hovered" | "selected"): ExpressionSpecification => [
"case",
state === "selected"
? ["any", ["boolean", ["feature-state", "selected"], false], ["boolean", ["feature-state", "highlighted"], false]]
: ["boolean", ["feature-state", state], false],
1,
0
];
const scadaColor: ExpressionSpecification = [
"match", ["downcase", ["to-string", ["coalesce", ["get", "type"], ""]]],
"pipe_flow", MAP_STYLE_TOKENS.supply.scadaFlow,
MAP_STYLE_TOKENS.supply.scadaPressure
];
type Layer = NonNullable<StyleSpecification["layers"]>[number];
export type ScadaImageRegistrationResult = {
status: "ready" | "degraded" | "unavailable";
failedImageIds: string[];
};
const scadaCasingLayer: Layer = {
id: "scada-casing", type: "circle", source: SCADA_SOURCE_ID, "source-layer": SCADA_SOURCE_LAYER, 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, "source-layer": SCADA_SOURCE_LAYER, 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, "source-layer": SCADA_SOURCE_LAYER, minzoom: SUPPLY_LAYER_VISUALS.scada.minzoom,
layout: { "icon-image": SCADA_ICON_EXPRESSION, "icon-size": SUPPLY_LAYER_VISUALS.scada.iconSize, "icon-allow-overlap": true, "icon-ignore-placement": true },
paint: { "icon-opacity": supplyIconOpacityExpression }
};
const scadaFallbackLayer: Layer = {
id: "scada-fallback", type: "circle", source: SCADA_SOURCE_ID, "source-layer": SCADA_SOURCE_LAYER, minzoom: SUPPLY_LAYER_VISUALS.scada.minzoom,
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, "source-layer": SCADA_SOURCE_LAYER, minzoom: SUPPLY_LAYER_VISUALS.scada.minzoom, paint: { "circle-radius": SUPPLY_LAYER_VISUALS.scada.hoverRadius, "circle-color": MAP_STYLE_TOKENS.canvas.transparent, "circle-stroke-color": scadaColor, "circle-stroke-width": 2, "circle-opacity": stateOpacity("hovered"), "circle-stroke-opacity": stateOpacity("hovered") } },
{ id: "scada-selected-outer", type: "circle", source: SCADA_SOURCE_ID, "source-layer": SCADA_SOURCE_LAYER, minzoom: SUPPLY_LAYER_VISUALS.scada.minzoom, paint: { "circle-radius": SUPPLY_LAYER_VISUALS.scada.selectedRadius, "circle-color": MAP_STYLE_TOKENS.canvas.transparent, "circle-stroke-color": MAP_STYLE_TOKENS.canvas.casing, "circle-stroke-width": 5, "circle-opacity": stateOpacity("selected"), "circle-stroke-opacity": stateOpacity("selected") } },
{ id: "scada-selected", type: "circle", source: SCADA_SOURCE_ID, "source-layer": SCADA_SOURCE_LAYER, minzoom: SUPPLY_LAYER_VISUALS.scada.minzoom, paint: { "circle-radius": SUPPLY_LAYER_VISUALS.scada.selectedRadius, "circle-color": MAP_STYLE_TOKENS.canvas.transparent, "circle-stroke-color": MAP_STYLE_TOKENS.state.selected, "circle-stroke-width": 2.5, "circle-opacity": stateOpacity("selected"), "circle-stroke-opacity": stateOpacity("selected") } }
];
const scadaHitLayer: Layer = {
id: SCADA_HIT_LAYER_ID, type: "circle", source: SCADA_SOURCE_ID, "source-layer": SCADA_SOURCE_LAYER, minzoom: SUPPLY_LAYER_VISUALS.scada.minzoom,
paint: { "circle-radius": SUPPLY_LAYER_VISUALS.scada.hitRadius, "circle-color": MAP_STYLE_TOKENS.canvas.transparent, "circle-opacity": 0 }
};
export const scadaBusinessLayers: Layer[] = [
scadaCasingLayer,
scadaFillLayer,
scadaSymbolLayer
];
export const scadaFallbackBusinessLayers: Layer[] = [
scadaCasingLayer,
scadaFillLayer,
scadaFallbackLayer
];
export const scadaHitLayers: Layer[] = [scadaHitLayer];
export const scadaLayers: Layer[] = [
...scadaBusinessLayers,
...scadaInteractionLayers,
scadaHitLayer
];
export const scadaFallbackLayers: Layer[] = [
...scadaFallbackBusinessLayers,
...scadaInteractionLayers,
scadaHitLayer
];
export async function registerScadaImages(
map: Pick<MapLibreMap, "loadImage" | "addImage" | "hasImage">
): Promise<ScadaImageRegistrationResult> {
let pressure: Awaited<ReturnType<typeof loadImage>>;
try {
pressure = await loadImage(map, SCADA_MAP_ICON_PATHS.pressure);
addImageIfMissing(map, SCADA_IMAGE_IDS.pressure, pressure);
} 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 === "pressure") continue;
const imageId = SCADA_IMAGE_IDS[key as Exclude<keyof typeof SCADA_IMAGE_IDS, "pressure">];
if (map.hasImage(imageId)) continue;
try {
const image = await loadImage(map, path);
addImageIfMissing(map, imageId, image);
} catch {
failedImageIds.push(imageId);
try {
addImageIfMissing(map, imageId, pressure);
} 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 });
}
@@ -0,0 +1,12 @@
import type { Map as MapLibreMap } from "maplibre-gl";
import { simulationLayerIds } from "./annotation-layers";
export function setSimulationLayersVisibility(map: MapLibreMap, visible: boolean) {
const visibility = visible ? "visible" : "none";
simulationLayerIds.forEach((layerId) => {
if (map.getLayer(layerId)) {
map.setLayoutProperty(layerId, "visibility", visibility);
}
});
}
@@ -0,0 +1,55 @@
import { describe, expect, it } from "vitest";
import {
AVAILABLE_WATER_NETWORK_SOURCE_IDS,
SUPPLY_LAYER_CATALOG,
createWaterNetworkSources,
SOURCE_LAYERS,
WATER_NETWORK_SOURCE_IDS
} from "./sources";
describe("createWaterNetworkSources", () => {
it("catalogs published and pre-registered supply system layers", () => {
expect(SOURCE_LAYERS).toEqual({
pipes: "geo_pipes_mat",
junctions: "geo_junctions_mat",
valves: "geo_valves",
reservoirs: "geo_reservoirs",
scada: "geo_scada",
pumps: "geo_pumps",
tanks: "geo_tanks"
});
expect(WATER_NETWORK_SOURCE_IDS).toEqual(["pipes", "junctions", "valves", "reservoirs", "scada", "pumps", "tanks"]);
expect(SUPPLY_LAYER_CATALOG.map((layer) => [layer.id, layer.available])).toEqual([
["pipes", true],
["junctions", true],
["valves", true],
["reservoirs", true],
["scada", true],
["pumps", false],
["tanks", false]
]);
expect(SUPPLY_LAYER_CATALOG.find((layer) => layer.id === "valves")).toMatchObject({
geometry: "point",
sourceLayer: "geo_valves"
});
});
it("loads only available supply system layers from GeoServer WMTS", () => {
expect(AVAILABLE_WATER_NETWORK_SOURCE_IDS).toEqual(["pipes", "junctions", "valves", "reservoirs", "scada"]);
expect(SUPPLY_LAYER_CATALOG.find((layer) => layer.id === "reservoirs")).toMatchObject({
label: "水库",
icon: "reservoir"
});
const sources = createWaterNetworkSources();
expect(Object.keys(sources)).toEqual(AVAILABLE_WATER_NETWORK_SOURCE_IDS);
AVAILABLE_WATER_NETWORK_SOURCE_IDS.forEach((sourceId) => {
expect(sources[sourceId].tiles).toEqual([
expect.stringContaining(`/tjwater:${SOURCE_LAYERS[sourceId]}/WebMercatorQuad/`)
]);
expect(sources[sourceId].promoteId).toBe("id");
});
expect("pumps" in sources).toBe(false);
expect("tanks" in sources).toBe(false);
});
});
+200
View File
@@ -0,0 +1,200 @@
import type { StyleSpecification, VectorSourceSpecification } from "maplibre-gl";
import { GEOSERVER_WORKSPACE, MAP_URL } from "../../../lib/config";
import { MAP_STYLE_TOKENS } from "./map-colors";
export const WATER_NETWORK_GLOBAL_VIEW = {
bbox3857: [
13508801.930066336,
3608163.3499832638,
13555650.638648884,
3633685.137885742
]
} as const;
export const SOURCE_LAYERS = {
pipes: "geo_pipes_mat",
junctions: "geo_junctions_mat",
valves: "geo_valves",
reservoirs: "geo_reservoirs",
scada: "geo_scada",
pumps: "geo_pumps",
tanks: "geo_tanks"
} as const;
export const WATER_NETWORK_SOURCE_IDS = [
"pipes",
"junctions",
"valves",
"reservoirs",
"scada",
"pumps",
"tanks"
] as const;
export type WaterNetworkSourceId = (typeof WATER_NETWORK_SOURCE_IDS)[number];
export type SupplyLayerCatalogItem = {
id: WaterNetworkSourceId;
sourceLayer: (typeof SOURCE_LAYERS)[WaterNetworkSourceId];
geometry: "line" | "point";
available: boolean;
label: string;
icon: string;
};
export const SUPPLY_LAYER_CATALOG = [
{
id: "pipes",
sourceLayer: SOURCE_LAYERS.pipes,
geometry: "line",
available: true,
label: "管线",
icon: "pipe"
},
{
id: "junctions",
sourceLayer: SOURCE_LAYERS.junctions,
geometry: "point",
available: true,
label: "节点",
icon: "junction"
},
{
id: "valves",
sourceLayer: SOURCE_LAYERS.valves,
geometry: "point",
available: true,
label: "阀门",
icon: "valve"
},
{
id: "reservoirs",
sourceLayer: SOURCE_LAYERS.reservoirs,
geometry: "point",
available: true,
label: "水库",
icon: "reservoir"
},
{
id: "scada",
sourceLayer: SOURCE_LAYERS.scada,
geometry: "point",
available: true,
label: "SCADA",
icon: "scada-pressure"
},
{
id: "pumps",
sourceLayer: SOURCE_LAYERS.pumps,
geometry: "point",
available: false,
label: "水泵",
icon: "pump"
},
{
id: "tanks",
sourceLayer: SOURCE_LAYERS.tanks,
geometry: "point",
available: false,
label: "水箱",
icon: "tank"
}
] as const satisfies readonly SupplyLayerCatalogItem[];
export const AVAILABLE_WATER_NETWORK_SOURCE_IDS = SUPPLY_LAYER_CATALOG
.filter((layer) => layer.available)
.map((layer) => layer.id) as Extract<WaterNetworkSourceId, "pipes" | "junctions" | "valves" | "reservoirs" | "scada">[];
export type AvailableWaterNetworkSourceId = (typeof AVAILABLE_WATER_NETWORK_SOURCE_IDS)[number];
export function isAvailableWaterNetworkSourceId(value: string): value is AvailableWaterNetworkSourceId {
return (AVAILABLE_WATER_NETWORK_SOURCE_IDS as readonly string[]).includes(value);
}
export function createWaterNetworkSources() {
return Object.fromEntries(
SUPPLY_LAYER_CATALOG
.filter((layer) => layer.available)
.map((layer) => [layer.id, createGeoServerVectorSource(layer.sourceLayer)])
) as Record<AvailableWaterNetworkSourceId, VectorSourceSpecification>;
}
function createGeoServerVectorSource(
sourceLayer: (typeof SOURCE_LAYERS)[keyof typeof SOURCE_LAYERS]
): VectorSourceSpecification {
return {
type: "vector",
promoteId: "id",
tiles: [
`${MAP_URL}/gwc/service/wmts/rest/${GEOSERVER_WORKSPACE}:${sourceLayer}/WebMercatorQuad/{z}/{y}/{x}?format=application/vnd.mapbox-vector-tile`
],
minzoom: 0,
maxzoom: 24
};
}
export function createBaseStyle(mapboxToken?: string): StyleSpecification {
const sources: StyleSpecification["sources"] = {};
const layers: StyleSpecification["layers"] = [
{
id: "background",
type: "background",
paint: {
"background-color": MAP_STYLE_TOKENS.canvas.primary
}
}
];
if (mapboxToken) {
sources["mapbox-light"] = {
type: "raster",
tiles: [
`https://api.mapbox.com/styles/v1/mapbox/light-v11/tiles/512/{z}/{x}/{y}@2x?access_token=${mapboxToken}`
],
tileSize: 512,
attribution:
'© <a href="https://www.mapbox.com/about/maps/">Mapbox</a> © <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
};
sources["mapbox-satellite"] = {
type: "raster",
tiles: [
`https://api.mapbox.com/styles/v1/mapbox/satellite-streets-v12/tiles/512/{z}/{x}/{y}@2x?access_token=${mapboxToken}`
],
tileSize: 512,
attribution:
'© <a href="https://www.mapbox.com/about/maps/">Mapbox</a> © <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
};
layers.push({
id: "mapbox-light",
type: "raster",
source: "mapbox-light",
paint: {
"raster-opacity": 0.82,
"raster-saturation": -0.22,
"raster-contrast": 0.04
}
});
layers.push({
id: "mapbox-satellite",
type: "raster",
source: "mapbox-satellite",
layout: {
visibility: "none"
},
paint: {
"raster-opacity": 0.86,
"raster-saturation": -0.16,
"raster-contrast": 0.02
}
});
}
return {
version: 8,
glyphs: "https://demotiles.maplibre.org/font/{fontstack}/{range}.pbf",
sources,
layers
};
}
@@ -0,0 +1,38 @@
import type { Map as MapLibreMap } from "maplibre-gl";
import { describe, expect, it, vi } from "vitest";
import {
SUPPLY_ASSET_ICON_PATHS,
SUPPLY_ASSET_IMAGE_IDS,
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(path).toMatch(/^\/map\/.+\.png$/);
});
});
it("registers each facility type at two-times pixel density", async () => {
const image = { width: 96, height: 96 };
const loadedImageIds = new Set<string>();
const map = {
loadImage: vi.fn(() => Promise.resolve({ data: image })),
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>;
};
await expect(registerSupplyAssetImages(map)).resolves.toEqual({
status: "ready",
failedImageIds: []
});
expect(map.loadImage).toHaveBeenCalledTimes(Object.keys(SUPPLY_ASSET_IMAGE_IDS).length);
expect(map.addImage).toHaveBeenCalledTimes(Object.keys(SUPPLY_ASSET_IMAGE_IDS).length);
expect(map.addImage).toHaveBeenCalledWith(expect.any(String), image, { pixelRatio: 2 });
});
});
@@ -0,0 +1,60 @@
import type { Map as MapLibreMap } from "maplibre-gl";
export const SUPPLY_ASSET_IMAGE_IDS = {
valve: "supply-valve",
reservoir: "supply-reservoir",
pump: "supply-pump",
tank: "supply-tank"
} as const;
export const SUPPLY_ASSET_ICON_PATHS = {
valve: "/map/valve.png",
reservoir: "/map/reservoir.png",
pump: "/map/pump.png",
tank: "/map/tank.png"
} as const;
export const SUPPLY_ASSET_MAP_ICON_PATHS = SUPPLY_ASSET_ICON_PATHS;
export type SupplyAssetImageRegistrationResult = {
status: "ready" | "degraded";
failedImageIds: string[];
};
const ACTIVE_SUPPLY_ASSET_ICON_KEYS = ["valve", "reservoir", "pump", "tank"] as const;
export async function registerSupplyAssetImages(
map: Pick<MapLibreMap, "loadImage" | "addImage" | "hasImage">
): Promise<SupplyAssetImageRegistrationResult> {
const failedImageIds: string[] = [];
for (const key of ACTIVE_SUPPLY_ASSET_ICON_KEYS) {
const imageId = SUPPLY_ASSET_IMAGE_IDS[key];
if (map.hasImage(imageId)) continue;
try {
const image = await loadImage(map, SUPPLY_ASSET_MAP_ICON_PATHS[key]);
addImageIfMissing(map, imageId, image);
} catch {
failedImageIds.push(imageId);
}
}
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 });
}
@@ -0,0 +1,66 @@
import type { ExpressionSpecification } from "maplibre-gl";
import { MAP_STYLE_TOKENS } from "./map-colors";
export const SUPPLY_INACTIVE_VALUES = [
"closed",
"off",
"inactive",
"关闭"
] as const;
export const supplyInactiveExpression: ExpressionSpecification = [
"in",
[
"downcase",
[
"to-string",
["coalesce", ["get", "status"], ["get", "setting"], ""]
]
],
["literal", SUPPLY_INACTIVE_VALUES]
];
export const supplyPipeColorExpression: ExpressionSpecification = [
"case",
supplyInactiveExpression,
MAP_STYLE_TOKENS.state.inactive,
[">=", ["to-number", ["coalesce", ["get", "diameter"], 0]], 600],
MAP_STYLE_TOKENS.supply.pipeMajor,
[">=", ["to-number", ["coalesce", ["get", "diameter"], 0]], 300],
MAP_STYLE_TOKENS.supply.pipeMedium,
MAP_STYLE_TOKENS.supply.pipeMinor
];
export const supplyPipeDashExpression = [1, 0] as [number, number];
export function createSupplyActiveOpacity(
activeOpacity: number
): ExpressionSpecification {
return ["case", supplyInactiveExpression, 0, activeOpacity];
}
export const supplyFacilityOpacityExpression: ExpressionSpecification = [
"case",
supplyInactiveExpression,
0.55,
0.94
];
export const supplyIconOpacityExpression: ExpressionSpecification = [
"case",
supplyInactiveExpression,
0.62,
0.98
];
export function createSupplyStateColor(
activeColor: string | ExpressionSpecification
): ExpressionSpecification {
return [
"case",
supplyInactiveExpression,
MAP_STYLE_TOKENS.state.inactive,
activeColor
];
}
@@ -0,0 +1,35 @@
import { BarChart3, Layers3, MapPinned, MoreHorizontal, Ruler } from "lucide-react";
import type { MapToolbarItem } from "@/features/map/core/components/toolbar";
export const waterNetworkToolbarItems: MapToolbarItem[] = [
{
id: "layers",
label: "图层",
description: "管理地图图层",
icon: Layers3
},
{
id: "measure",
label: "测量",
description: "距离与范围测量",
icon: Ruler
},
{
id: "analysis",
label: "分析",
description: "查看地图分析图例",
icon: BarChart3
},
{
id: "annotation",
label: "标注",
description: "管理地图标注",
icon: MapPinned
},
{
id: "more",
label: "更多",
description: "更多地图操作",
icon: MoreHorizontal
}
];
@@ -0,0 +1,24 @@
import { describe, expect, it } from "vitest";
import { createValueLabelCollection, formatFeatureValue, getNumericFeatureProperties } from "./value-label";
describe("workbench value labels", () => {
const feature = {
type: "Feature" as const,
geometry: { type: "Point" as const, coordinates: [120, 28] },
properties: { diameter: 600.125, name: "P-1", invalid: Number.NaN, depth: 2 }
};
it("lists finite numeric properties only", () => {
expect(getNumericFeatureProperties(feature.properties)).toEqual([["depth", 2], ["diameter", 600.125]]);
});
it("formats precision and unit with hard limits", () => {
expect(formatFeatureValue(12.3456, 2, "mm")).toBe("12.35 mm");
expect(formatFeatureValue(12.3456, 8, "")).toBe("12.346");
});
it("creates a single controlled label feature", () => {
expect(createValueLabelCollection(feature, "diameter", 1, "mm").features[0]?.properties?.label).toBe("600.1 mm");
expect(() => createValueLabelCollection(feature, "name", 1)).toThrow("INVALID_VALUE_PROPERTY");
});
});
+41
View File
@@ -0,0 +1,41 @@
import type { Feature, FeatureCollection, Geometry } from "geojson";
export const VALUE_LABEL_SOURCE_ID = "workbench-value-label";
export const VALUE_LABEL_LAYER_IDS = ["workbench-value-label-point", "workbench-value-label-line"] as const;
export function getNumericFeatureProperties(properties: Record<string, unknown> | null | undefined) {
return Object.entries(properties ?? {})
.filter((entry): entry is [string, number] => typeof entry[1] === "number" && Number.isFinite(entry[1]))
.sort(([a], [b]) => a.localeCompare(b));
}
export function formatFeatureValue(value: number, precision: number, unit = "") {
const safePrecision = Math.min(3, Math.max(0, Math.trunc(precision)));
const safeUnit = unit.trim().slice(0, 16);
return `${value.toFixed(safePrecision)}${safeUnit ? ` ${safeUnit}` : ""}`;
}
export function createValueLabelCollection(
feature: Feature,
property: string,
precision: number,
unit = ""
): FeatureCollection {
const value = feature.properties?.[property];
if (typeof value !== "number" || !Number.isFinite(value)) {
throw new Error("INVALID_VALUE_PROPERTY");
}
return {
type: "FeatureCollection",
features: [{
type: "Feature",
geometry: feature.geometry as Geometry,
properties: { label: formatFeatureValue(value, precision, unit) }
}]
};
}
export function createEmptyValueLabelCollection(): FeatureCollection {
return { type: "FeatureCollection", features: [] };
}
@@ -0,0 +1,68 @@
import type { FeatureCollection } from "geojson";
import type { Map as MapLibreMap } from "maplibre-gl";
import { describe, expect, it, vi } from "vitest";
import { WorkbenchMapController } from "./workbench-map-controller";
describe("WorkbenchMapController", () => {
it("locates points with zoom 18 and keeps selected state untouched", async () => {
const map = createMap();
const controller = createController(map, pointCollection);
await controller.locateAndHighlight({ sourceId: "junctions", featureId: "J-1" });
expect(map.easeTo).toHaveBeenCalledWith(expect.objectContaining({ center: [120.7, 28], zoom: 18 }));
expect(map.setFeatureState).toHaveBeenCalledWith(expect.objectContaining({ source: "junctions", id: "J-1" }), { highlighted: true });
controller.clearHighlight();
expect(map.removeFeatureState).toHaveBeenCalledWith(expect.anything(), "highlighted");
expect(map.removeFeatureState).not.toHaveBeenCalledWith(expect.anything(), "selected");
});
it("fits line bounds and uses responsive workbench padding", async () => {
const map = createMap();
const padding = { top: 50, right: 420, bottom: 50, left: 320 };
const controller = createController(map, lineCollection, padding);
await controller.locateAndHighlight({ sourceId: "pipes", featureId: "P-1" });
expect(map.fitBounds).toHaveBeenCalledWith([[120, 28], [121, 29]], expect.objectContaining({ padding, maxZoom: 18 }));
});
it("does not clear the last valid highlight when a target is missing", async () => {
const map = createMap();
let collection = pointCollection;
const controller = new WorkbenchMapController({
getMap: () => map as unknown as MapLibreMap,
isReady: () => true,
getPadding: () => ({ top: 0, right: 0, bottom: 0, left: 0 }),
fetchFeatures: async () => collection
});
await controller.highlight({ sourceId: "junctions", featureId: "J-1" });
collection = { type: "FeatureCollection", features: [] };
await controller.highlight({ sourceId: "junctions", featureId: "missing" });
expect(controller.getSnapshot().target).toEqual({ sourceId: "junctions", featureId: "J-1" });
expect(controller.getSnapshot().errorCode).toBe("FEATURE_NOT_FOUND");
expect(map.removeFeatureState).not.toHaveBeenCalled();
});
it("reports SCADA analysis as unavailable for the water-network data source", async () => {
const map = createMap();
const controller = createController(map, pointCollection);
await expect(controller.renderScadaAnalysis([{ sensor_id: "MP01", level: "high" }])).rejects.toThrow("SCADA_FEATURES_NOT_FOUND");
expect(controller.getSnapshot().errorCode).toBe("SCADA_FEATURES_NOT_FOUND");
});
});
const pointCollection: FeatureCollection = { type: "FeatureCollection", features: [{ type: "Feature", geometry: { type: "Point", coordinates: [120.7, 28] }, properties: { id: "J-1" } }] };
const lineCollection: FeatureCollection = { type: "FeatureCollection", features: [{ type: "Feature", geometry: { type: "LineString", coordinates: [[120, 28], [121, 29]] }, properties: { id: "P-1" } }] };
function createMap() {
return {
easeTo: vi.fn(), fitBounds: vi.fn(), setFeatureState: vi.fn(), removeFeatureState: vi.fn(),
getSource: vi.fn(), getLayer: vi.fn(), addLayer: vi.fn(), setPaintProperty: vi.fn(), setLayoutProperty: vi.fn(), removeControl: vi.fn()
};
}
function createController(map: ReturnType<typeof createMap>, collection: FeatureCollection, padding = { top: 48, right: 48, bottom: 48, left: 48 }) {
return new WorkbenchMapController({
getMap: () => map as unknown as MapLibreMap,
isReady: () => true,
getPadding: () => padding,
fetchFeatures: vi.fn(async () => collection)
});
}
@@ -0,0 +1,355 @@
import type { Feature, FeatureCollection, GeoJsonProperties, Geometry, Position } from "geojson";
import type { GeoJSONSource, Map as MapLibreMap, PaddingOptions } from "maplibre-gl";
import { SOURCE_LAYERS, type WaterNetworkSourceId } from "./sources";
import {
LAYER_GROUP_GEOMETRIES,
applyLayerGroupStyle as applyStyle,
resetLayerGroupStyle as resetStyle,
validateLayerGroupStylePatch,
type LayerGroupId,
type LayerGroupStylePatch
} from "./layer-group-style";
import { createFlowOverlayState, hideFlowOverlay, showFlowOverlay, type FlowOverlayState } from "./flow-overlay";
import {
VALUE_LABEL_SOURCE_ID,
createEmptyValueLabelCollection,
createValueLabelCollection
} from "./value-label";
import {
SCADA_ANALYSIS_SOURCE_ID,
createEmptyScadaAnalysisCollection,
createScadaAnalysisCollection,
ensureScadaAnalysisLayers,
type ScadaAnalysisItem,
type ScadaAnalysisRenderResult
} from "./scada-analysis";
export type FeatureTarget = { sourceId: WaterNetworkSourceId; featureId: string };
export type WorkbenchMapErrorCode =
| "MAP_NOT_READY"
| "FEATURE_NOT_FOUND"
| "WFS_UNAVAILABLE"
| "SCADA_FEATURES_NOT_FOUND"
| "ACTION_SUPERSEDED"
| "FLOW_UNAVAILABLE"
| "INVALID_STYLE_PATCH";
export type WorkbenchMapControllerState = {
target: FeatureTarget | null;
pending: boolean;
errorCode: WorkbenchMapErrorCode | null;
flowVisible: boolean;
styledGroups: LayerGroupId[];
scadaAnalysis: ScadaAnalysisRenderResult | null;
};
export type ValueLabelRequest = FeatureTarget & { property: string; precision: number; unit?: string };
export type WorkbenchMapCommands = {
locateAndHighlight: (target: FeatureTarget) => Promise<void>;
highlight: (target: FeatureTarget) => Promise<void>;
clearHighlight: () => void;
setFlowVisible: (visible: boolean) => Promise<void>;
showValueLabel: (request: { target: FeatureTarget; property: string; precision: number; unit?: string }) => Promise<void>;
clearValueLabel: () => void;
applyLayerGroupStyle: (groupId: LayerGroupId, patch: LayerGroupStylePatch) => void;
resetLayerGroupStyle: (groupId: LayerGroupId) => void;
renderScadaAnalysis: (items: ScadaAnalysisItem[], signal?: AbortSignal) => Promise<ScadaAnalysisRenderResult>;
clearScadaAnalysis: () => { cleared: true };
resetAll: () => void;
};
export type WorkbenchMapControllerOptions = {
getMap: () => MapLibreMap | null;
isReady: () => boolean;
getPadding: () => PaddingOptions;
fetchFeatures?: (sourceId: WaterNetworkSourceId, featureIds?: string[], signal?: AbortSignal) => Promise<FeatureCollection>;
reducedMotion?: () => boolean;
};
export class WorkbenchMapController implements WorkbenchMapCommands {
private state: WorkbenchMapControllerState = { target: null, pending: false, errorCode: null, flowVisible: false, styledGroups: [], scadaAnalysis: null };
private listeners = new Set<() => void>();
private highlightedTarget: FeatureTarget | null = null;
private cachedFeatures = new Map<string, Feature>();
private flowState: FlowOverlayState = createFlowOverlayState();
private scadaAnalysisRevision = 0;
constructor(private readonly options: WorkbenchMapControllerOptions) {}
subscribe = (listener: () => void) => {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
};
getSnapshot = () => this.state;
async locateAndHighlight(target: FeatureTarget) {
const feature = await this.resolveTarget(target);
const map = this.requireMap();
if (!feature || !map) return;
const coordinates = collectCoordinates(feature.geometry);
if (!coordinates.length) return this.setError("FEATURE_NOT_FOUND");
if (feature.geometry.type === "Point") {
map.easeTo({ center: coordinates[0] as [number, number], zoom: 18, padding: this.options.getPadding(), duration: 600 });
} else {
const bounds = coordinates.reduce(
(acc, coordinate) => [Math.min(acc[0], coordinate[0]), Math.min(acc[1], coordinate[1]), Math.max(acc[2], coordinate[0]), Math.max(acc[3], coordinate[1])],
[Infinity, Infinity, -Infinity, -Infinity]
);
map.fitBounds([[bounds[0], bounds[1]], [bounds[2], bounds[3]]], { padding: this.options.getPadding(), maxZoom: 18, duration: 600 });
}
this.setHighlight(target);
}
async highlight(target: FeatureTarget) {
const feature = await this.resolveTarget(target);
if (feature) this.setHighlight(target);
}
clearHighlight() {
const map = this.options.getMap();
if (map && this.highlightedTarget) {
map.removeFeatureState(toFeatureStateTarget(this.highlightedTarget), "highlighted");
}
this.highlightedTarget = null;
this.patchState({ target: null, errorCode: null });
}
async setFlowVisible(visible: boolean) {
const map = this.requireMap();
if (!map) return;
if (!visible) {
hideFlowOverlay(map, this.flowState);
this.patchState({ flowVisible: false, errorCode: null });
return;
}
this.patchState({ pending: true, errorCode: null });
try {
await showFlowOverlay(
map,
this.flowState,
() => this.fetchFeatures("pipes"),
this.options.reducedMotion?.()
);
this.patchState({ pending: false, flowVisible: true });
} catch {
hideFlowOverlay(map, this.flowState);
this.patchState({ pending: false, flowVisible: false, errorCode: "FLOW_UNAVAILABLE" });
}
}
async showValueLabel({ target, property, precision, unit = "" }: { target: FeatureTarget; property: string; precision: number; unit?: string }) {
if (!Number.isInteger(precision) || precision < 0 || precision > 3 || unit.length > 16) return this.setError("INVALID_STYLE_PATCH");
const feature = await this.resolveTarget(target);
const map = this.requireMap();
if (!feature || !map) return;
try {
(map.getSource(VALUE_LABEL_SOURCE_ID) as GeoJSONSource | undefined)?.setData(
createValueLabelCollection(feature, property, precision, unit)
);
this.patchState({ target, errorCode: null });
} catch {
this.setError("INVALID_STYLE_PATCH");
}
}
clearValueLabel() {
const map = this.options.getMap();
(map?.getSource(VALUE_LABEL_SOURCE_ID) as GeoJSONSource | undefined)?.setData(createEmptyValueLabelCollection());
}
applyLayerGroupStyle(groupId: LayerGroupId, patch: LayerGroupStylePatch) {
const map = this.requireMap();
if (!map) return;
if (!validateLayerGroupStylePatch(groupId, patch)) return this.setError("INVALID_STYLE_PATCH");
applyStyle(map, groupId, patch);
this.patchState({ styledGroups: [...new Set([...this.state.styledGroups, groupId])], errorCode: null });
}
resetLayerGroupStyle(groupId: LayerGroupId) {
const map = this.requireMap();
if (!map || !(groupId in LAYER_GROUP_GEOMETRIES)) return;
resetStyle(map, groupId);
this.patchState({ styledGroups: this.state.styledGroups.filter((item) => item !== groupId), errorCode: null });
}
async renderScadaAnalysis(items: ScadaAnalysisItem[], signal?: AbortSignal): Promise<ScadaAnalysisRenderResult> {
void items;
void signal;
this.setError("SCADA_FEATURES_NOT_FOUND");
throw new Error("SCADA_FEATURES_NOT_FOUND");
/*
const revision = ++this.scadaAnalysisRevision;
const map = this.options.getMap();
if (!map || !this.options.isReady()) {
this.setError("MAP_NOT_READY");
throw new Error("MAP_NOT_READY");
}
this.patchState({ pending: true, errorCode: null });
let sourceCollection: FeatureCollection;
try {
sourceCollection = await this.fetchFeatures("scada", items.map((item) => item.sensor_id), signal);
} catch {
if (revision === this.scadaAnalysisRevision) {
this.patchState({ pending: false, errorCode: "WFS_UNAVAILABLE" });
}
throw new Error(revision === this.scadaAnalysisRevision ? "WFS_UNAVAILABLE" : "ACTION_SUPERSEDED");
}
if (revision !== this.scadaAnalysisRevision) throw new Error("ACTION_SUPERSEDED");
const next = createScadaAnalysisCollection(sourceCollection, items);
if (next.collection.features.length === 0) {
this.patchState({ pending: false, errorCode: "SCADA_FEATURES_NOT_FOUND" });
throw new Error("SCADA_FEATURES_NOT_FOUND");
}
const source = map.getSource(SCADA_ANALYSIS_SOURCE_ID) as GeoJSONSource | undefined;
if (!source) {
this.patchState({ pending: false, errorCode: "MAP_NOT_READY" });
throw new Error("MAP_NOT_READY");
}
try {
ensureScadaAnalysisLayers(map);
source.setData(next.collection);
} catch {
this.patchState({ pending: false, errorCode: "MAP_NOT_READY" });
throw new Error("MAP_NOT_READY");
}
const result: ScadaAnalysisRenderResult = { ...next.result, fitted: true };
this.patchState({ pending: false, errorCode: null, scadaAnalysis: result });
const coordinates = next.collection.features.map((feature) => feature.geometry.coordinates);
if (coordinates.length === 1) {
map.easeTo({
center: [coordinates[0][0], coordinates[0][1]],
zoom: 18,
padding: this.options.getPadding(),
duration: this.options.reducedMotion?.() ? 0 : 600
});
} else {
const bounds = coordinates.reduce<[number, number, number, number]>(
(value, coordinate) => [
Math.min(value[0], coordinate[0]),
Math.min(value[1], coordinate[1]),
Math.max(value[2], coordinate[0]),
Math.max(value[3], coordinate[1])
],
[Infinity, Infinity, -Infinity, -Infinity]
);
map.fitBounds([[bounds[0], bounds[1]], [bounds[2], bounds[3]]], {
padding: this.options.getPadding(),
maxZoom: 18,
duration: this.options.reducedMotion?.() ? 0 : 600
});
}
return result;
*/
}
clearScadaAnalysis() {
this.scadaAnalysisRevision += 1;
const map = this.options.getMap();
(map?.getSource(SCADA_ANALYSIS_SOURCE_ID) as GeoJSONSource | undefined)?.setData(
createEmptyScadaAnalysisCollection()
);
this.patchState({ scadaAnalysis: null, pending: false, errorCode: null });
return { cleared: true as const };
}
resetAll() {
this.clearHighlight();
this.clearValueLabel();
this.clearScadaAnalysis();
const map = this.options.getMap();
if (map) {
hideFlowOverlay(map, this.flowState);
(Object.keys(LAYER_GROUP_GEOMETRIES) as LayerGroupId[]).forEach((groupId) => resetStyle(map, groupId));
}
this.patchState({ target: null, pending: false, errorCode: null, flowVisible: false, styledGroups: [], scadaAnalysis: null });
}
destroy() {
const map = this.options.getMap();
if (map) hideFlowOverlay(map, this.flowState);
this.listeners.clear();
}
private async resolveTarget(target: FeatureTarget) {
const map = this.requireMap();
if (!map) return null;
const key = `${target.sourceId}:${target.featureId}`;
const cached = this.cachedFeatures.get(key);
if (cached) return cached;
this.patchState({ pending: true, errorCode: null });
try {
const collection = await this.fetchFeatures(target.sourceId, [target.featureId]);
const feature = collection.features[0];
if (!feature) {
this.patchState({ pending: false, errorCode: "FEATURE_NOT_FOUND" });
return null;
}
this.cachedFeatures.set(key, feature);
this.patchState({ pending: false });
return feature;
} catch {
this.patchState({ pending: false, errorCode: "WFS_UNAVAILABLE" });
return null;
}
}
private async fetchFeatures(sourceId: WaterNetworkSourceId, featureIds?: string[], signal?: AbortSignal) {
if (this.options.fetchFeatures) return this.options.fetchFeatures(sourceId, featureIds, signal);
const response = await fetch("/api/map-features", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sourceId, featureIds }),
signal
});
const body = await response.json();
if (!response.ok) throw new Error(body.code ?? "WFS_UNAVAILABLE");
return body as FeatureCollection;
}
private setHighlight(target: FeatureTarget) {
const map = this.requireMap();
if (!map) return;
if (this.highlightedTarget) map.removeFeatureState(toFeatureStateTarget(this.highlightedTarget), "highlighted");
map.setFeatureState(toFeatureStateTarget(target), { highlighted: true });
this.highlightedTarget = target;
this.clearValueLabel();
this.patchState({ target, errorCode: null });
}
private requireMap() {
const map = this.options.getMap();
if (!map || !this.options.isReady()) {
this.setError("MAP_NOT_READY");
return null;
}
return map;
}
private setError(errorCode: WorkbenchMapErrorCode) {
this.patchState({ errorCode });
}
private patchState(patch: Partial<WorkbenchMapControllerState>) {
this.state = { ...this.state, ...patch };
this.listeners.forEach((listener) => listener());
}
}
function toFeatureStateTarget(target: FeatureTarget) {
return { source: target.sourceId, sourceLayer: SOURCE_LAYERS[target.sourceId], id: target.featureId };
}
function collectCoordinates(geometry: Geometry | null): Position[] {
if (!geometry || geometry.type === "GeometryCollection") return [];
const coordinates: Position[] = [];
const visit = (value: unknown) => {
if (Array.isArray(value) && value.length >= 2 && typeof value[0] === "number" && typeof value[1] === "number") coordinates.push(value as Position);
else if (Array.isArray(value)) value.forEach(visit);
};
visit(geometry.coordinates);
return coordinates;
}