feat: initialize TJWater WebGIS frontend
This commit is contained in:
@@ -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);
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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) }
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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>;
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user