feat(map): add five-source drainage styling

This commit is contained in:
2026-07-10 18:45:43 +08:00
parent 2258177726
commit 513c403017
19 changed files with 1303 additions and 182 deletions
@@ -19,21 +19,43 @@ export function FeatureInsightPanel({ feature, onClose }: FeatureInsightPanelPro
return [];
}
if (feature.layer === "pipes") {
return [
["管径", formatFeaturePropertyValue("diameter", feature.properties.diameter)],
["长度", formatFeaturePropertyValue("length", feature.properties.length)],
["粗糙系数", formatFeaturePropertyValue("roughness", feature.properties.roughness)],
["状态", formatFeaturePropertyValue("status", feature.properties.status)]
];
switch (feature.layer) {
case "conduits":
return [
["管径", formatFeaturePropertyValue("diameter", feature.properties.diameter)],
["长度", formatFeaturePropertyValue("length", feature.properties.length)],
["粗糙系数", formatFeaturePropertyValue("roughness", feature.properties.roughness)],
["状态", formatFeaturePropertyValue("status", feature.properties.status)]
];
case "junctions":
return [
["最大深度", formatFeaturePropertyValue("max_depth", feature.properties.max_depth)],
["井底高程", formatFeaturePropertyValue("invert_elevation", feature.properties.invert_elevation)],
["对象类型", "检查井"],
["状态", "在线"]
];
case "orifices":
return [
["孔口类型", formatFeaturePropertyValue("orifice_type", feature.properties.orifice_type)],
["断面形状", formatFeaturePropertyValue("shape", feature.properties.shape)],
["流量系数", formatFeaturePropertyValue("discharge_coeff", feature.properties.discharge_coeff)],
["闸门", formatFeaturePropertyValue("gated", feature.properties.gated)]
];
case "pumps":
return [
["状态", formatFeaturePropertyValue("status", feature.properties.status)],
["泵曲线", formatFeaturePropertyValue("pump_curve", feature.properties.pump_curve)],
["启动水深", formatFeaturePropertyValue("startup_depth", feature.properties.startup_depth)],
["停泵水深", formatFeaturePropertyValue("shutoff_depth", feature.properties.shutoff_depth)]
];
case "outfalls":
return [
["排放类型", formatFeaturePropertyValue("outfall_type", feature.properties.outfall_type)],
["井底高程", formatFeaturePropertyValue("invert_elevation", feature.properties.invert_elevation)],
["阶段数据", formatFeaturePropertyValue("stage_data", feature.properties.stage_data)],
["状态", "在线"]
];
}
return [
["高程", formatFeaturePropertyValue("elevation", feature.properties.elevation)],
["需水量", formatFeaturePropertyValue("demand", feature.properties.demand)],
["对象类型", "管网节点"],
["状态", "在线"]
];
}, [feature]);
const propertyEntries = useMemo(() => {
@@ -4,21 +4,42 @@ import type { Map as MapLibreMap, MapLayerMouseEvent } from "maplibre-gl";
import type { RefObject } from "react";
import { useEffect } from "react";
import type { DetailFeature } from "../types";
import { getFeatureId, toDetailFeature } from "../map/feature-adapter";
import { SOURCE_LAYERS } from "../map/sources";
import {
getFeatureId,
getWaterNetworkSourceId,
toDetailFeature
} from "../map/feature-adapter";
import { INTERACTIVE_HIT_LAYER_IDS } from "../map/layers";
import type { WaterNetworkSourceId } from "../map/sources";
const INTERACTIVE_LAYER_IDS = ["pipes-flow", "junctions"];
const HOVER_LAYER_IDS: Record<WaterNetworkSourceId, readonly string[]> = {
conduits: ["pipes-hover-outline", "pipes-hover"],
junctions: ["junctions-hover"],
orifices: ["orifices-hover-outline", "orifices-hover"],
pumps: ["pumps-hover-outline", "pumps-hover"],
outfalls: ["outfalls-hover"]
};
const SELECTED_LAYER_IDS: Record<WaterNetworkSourceId, readonly string[]> = {
conduits: ["pipes-selected-halo", "pipes-selected-outline", "pipes-selected"],
junctions: ["junctions-selected-halo", "junctions-selected"],
orifices: ["orifices-selected-halo", "orifices-selected-outline", "orifices-selected"],
pumps: ["pumps-selected-halo", "pumps-selected-outline", "pumps-selected"],
outfalls: ["outfalls-selected-halo", "outfalls-selected"]
};
type UseMapInteractionsOptions = {
mapRef: RefObject<MapLibreMap | null>;
mapReady: boolean;
onSelectFeature: (feature: DetailFeature) => void;
selectedFeature: DetailFeature | null;
};
export function useMapInteractions({
mapRef,
mapReady,
onSelectFeature
onSelectFeature,
selectedFeature
}: UseMapInteractionsOptions) {
useEffect(() => {
const map = mapRef.current;
@@ -27,23 +48,47 @@ export function useMapInteractions({
}
const activeMap = map;
let hoveredFeature: { id: string; sourceId: WaterNetworkSourceId } | null = null;
function clearHoveredFeature() {
if (!hoveredFeature) {
return;
}
HOVER_LAYER_IDS[hoveredFeature.sourceId].forEach((layerId) => {
activeMap.setFilter(layerId, ["==", ["get", "id"], ""]);
});
hoveredFeature = null;
}
function handleMouseMove(event: MapLayerMouseEvent) {
activeMap.getCanvas().style.cursor = "pointer";
const feature = event.features?.[0];
if (!feature) {
return;
}
activeMap.getCanvas().style.cursor = "pointer";
const id = getFeatureId(feature);
const layerId = feature.sourceLayer === SOURCE_LAYERS.pipes ? "pipes-hover" : "junctions-hover";
activeMap.setFilter(layerId, ["==", ["get", "id"], id]);
const sourceId = getWaterNetworkSourceId(feature);
if (!id || !sourceId) {
clearHoveredFeature();
return;
}
if (hoveredFeature?.id === id && hoveredFeature.sourceId === sourceId) {
return;
}
clearHoveredFeature();
HOVER_LAYER_IDS[sourceId].forEach((layerId) => {
activeMap.setFilter(layerId, ["==", ["get", "id"], id]);
});
hoveredFeature = { id, sourceId };
}
function handleMouseLeave() {
activeMap.getCanvas().style.cursor = "";
activeMap.setFilter("pipes-hover", ["==", ["get", "id"], ""]);
activeMap.setFilter("junctions-hover", ["==", ["get", "id"], ""]);
clearHoveredFeature();
}
function handleClick(event: MapLayerMouseEvent) {
@@ -53,14 +98,33 @@ export function useMapInteractions({
}
}
activeMap.on("mousemove", INTERACTIVE_LAYER_IDS, handleMouseMove);
activeMap.on("mouseleave", INTERACTIVE_LAYER_IDS, handleMouseLeave);
activeMap.on("click", INTERACTIVE_LAYER_IDS, handleClick);
activeMap.on("mousemove", INTERACTIVE_HIT_LAYER_IDS, handleMouseMove);
activeMap.on("mouseleave", INTERACTIVE_HIT_LAYER_IDS, handleMouseLeave);
activeMap.on("click", INTERACTIVE_HIT_LAYER_IDS, handleClick);
return () => {
activeMap.off("mousemove", INTERACTIVE_LAYER_IDS, handleMouseMove);
activeMap.off("mouseleave", INTERACTIVE_LAYER_IDS, handleMouseLeave);
activeMap.off("click", INTERACTIVE_LAYER_IDS, handleClick);
activeMap.off("mousemove", INTERACTIVE_HIT_LAYER_IDS, handleMouseMove);
activeMap.off("mouseleave", INTERACTIVE_HIT_LAYER_IDS, handleMouseLeave);
activeMap.off("click", INTERACTIVE_HIT_LAYER_IDS, handleClick);
};
}, [mapRef, mapReady, onSelectFeature]);
useEffect(() => {
const map = mapRef.current;
if (!mapReady || !map) {
return;
}
Object.values(SELECTED_LAYER_IDS).flat().forEach((layerId) => {
map.setFilter(layerId, ["==", ["get", "id"], ""]);
});
if (!selectedFeature?.id) {
return;
}
SELECTED_LAYER_IDS[selectedFeature.layer].forEach((layerId) => {
map.setFilter(layerId, ["==", ["get", "id"], selectedFeature.id]);
});
}, [mapReady, mapRef, selectedFeature]);
}
+16 -5
View File
@@ -15,6 +15,7 @@ import {
getResponsiveWorkbenchPadding
} from "../map/camera";
import { waterNetworkLayers } from "../map/layers";
import { MAP_MAX_ZOOM } from "../map/map-layer-visuals";
import { setSimulationLayersVisibility } from "../map/simulation-layers";
import {
createBaseStyle,
@@ -36,6 +37,7 @@ type UseWorkbenchMapOptions = {
rightPanelOpen: boolean;
impactVisible: boolean;
onSelectFeature: (feature: DetailFeature) => void;
selectedFeature: DetailFeature | null;
};
type UseWorkbenchMapResult = {
@@ -64,8 +66,11 @@ const SOURCE_STATUS_LABELS: Record<string, string> = {
const SOURCE_GROUP_BY_ID: Record<string, string> = {
"mapbox-light": "mapbox-base",
"mapbox-satellite": "mapbox-base",
pipes: "geoserver-mvt",
conduits: "geoserver-mvt",
junctions: "geoserver-mvt",
orifices: "geoserver-mvt",
outfalls: "geoserver-mvt",
pumps: "geoserver-mvt",
[SIMULATION_SOURCE_IDS.impactArea]: "annotation-source",
[SIMULATION_SOURCE_IDS.annotations]: "annotation-source"
};
@@ -75,7 +80,8 @@ export function useWorkbenchMap({
leftPanelOpen,
rightPanelOpen,
impactVisible,
onSelectFeature
onSelectFeature,
selectedFeature
}: UseWorkbenchMapOptions): UseWorkbenchMapResult {
const mapRef = useRef<MapLibreMap | null>(null);
const impactVisibleRef = useRef(impactVisible);
@@ -104,6 +110,7 @@ export function useWorkbenchMap({
style: createBaseStyle(mapboxToken),
pitch: 0,
bearing: 0,
maxZoom: MAP_MAX_ZOOM,
preserveDrawingBuffer: true,
attributionControl: false
});
@@ -113,8 +120,11 @@ export function useWorkbenchMap({
map.on("load", () => {
map.resize();
const sources = createWaterNetworkSources();
map.addSource("pipes", sources.pipes);
map.addSource("conduits", sources.conduits);
map.addSource("junctions", sources.junctions);
map.addSource("orifices", sources.orifices);
map.addSource("outfalls", sources.outfalls);
map.addSource("pumps", sources.pumps);
map.addSource(SIMULATION_SOURCE_IDS.impactArea, simulationSources.impactArea);
map.addSource(SIMULATION_SOURCE_IDS.annotations, simulationSources.annotations);
waterNetworkLayers.forEach((layer) => map.addLayer(layer));
@@ -165,7 +175,8 @@ export function useWorkbenchMap({
useMapInteractions({
mapRef,
mapReady,
onSelectFeature
onSelectFeature,
selectedFeature
});
useEffect(() => {
@@ -278,7 +289,7 @@ function getSourceStatusMessage(sourceGroupId: string, status: WorkbenchSourceSt
}
if (sourceGroupId === "geoserver-mvt") {
return status === "online" ? "管线和节点矢量瓦片已加载。" : "管线或节点瓦片请求中断,当前视图可能不完整。";
return status === "online" ? "排水管网矢量瓦片已加载。" : "排水管网瓦片请求中断,当前视图可能不完整。";
}
if (sourceGroupId === "annotation-source") {
+8 -6
View File
@@ -47,7 +47,8 @@ import {
INITIAL_LAYER_VISIBILITY,
MAP_LEGEND_ITEMS,
WORKBENCH_LAYER_GROUPS,
createLayerControlItems
createLayerControlItems,
getWorkbenchLayerIds
} from "./map/map-control-config";
import { waterNetworkToolbarItems } from "./map/toolbar-config";
import type { DetailFeature, ScheduledConditionItem, ScheduledConditionRecord, WorkbenchAlert } from "./types";
@@ -178,7 +179,8 @@ export function MapWorkbenchPage() {
leftPanelOpen,
rightPanelOpen,
impactVisible,
onSelectFeature: handleSelectFeature
onSelectFeature: handleSelectFeature,
selectedFeature: detailFeature
});
const agent = useWorkbenchAgent({
@@ -286,11 +288,11 @@ export function MapWorkbenchPage() {
);
const layerControlItems = useMemo(() => createLayerControlItems(layerVisibility), [layerVisibility]);
function handleToggleLayer(layerGroupId: string, visible: boolean) {
function handleToggleLayer(layerControlId: string, visible: boolean) {
const map = mapRef.current;
setLayerVisibility((current) => ({ ...current, [layerGroupId]: visible }));
if (layerGroupId === "simulation") {
setLayerVisibility((current) => ({ ...current, [layerControlId]: visible }));
if (layerControlId === "simulation") {
setImpactVisible(visible);
}
@@ -298,7 +300,7 @@ export function MapWorkbenchPage() {
return;
}
WORKBENCH_LAYER_GROUPS[layerGroupId]?.forEach((layerId) => {
getWorkbenchLayerIds(map, layerControlId).forEach((layerId) => {
if (map.getLayer(layerId)) {
map.setLayoutProperty(layerId, "visibility", visible ? "visible" : "none");
}
+6 -6
View File
@@ -21,15 +21,15 @@ describe("fitNetworkBounds", () => {
expect(fitBounds).toHaveBeenCalledTimes(1);
const [bounds, options] = fitBounds.mock.calls[0] ?? [];
expect(bounds[0][0]).toBeCloseTo(121.351633, 6);
expect(bounds[0][1]).toBeCloseTo(30.810505, 6);
expect(bounds[1][0]).toBeCloseTo(121.772485, 6);
expect(bounds[1][1]).toBeCloseTo(31.007214, 6);
expect(bounds[0][0]).toBeCloseTo(121.735034, 6);
expect(bounds[0][1]).toBeCloseTo(30.846365, 6);
expect(bounds[1][0]).toBeCloseTo(121.970518, 6);
expect(bounds[1][1]).toBeCloseTo(30.994738, 6);
expect(options).toMatchObject({
duration: 600,
padding: { top: 0, right: 0, bottom: 0, left: 0 }
maxZoom: 12,
padding: { top: 48, right: 48, bottom: 48, left: 48 }
});
expect(options).not.toHaveProperty("zoom");
expect(options).not.toHaveProperty("maxZoom");
});
});
+8 -3
View File
@@ -9,7 +9,8 @@ export type NetworkViewParams = {
};
const WEB_MERCATOR_RADIUS = 6378137;
const NO_PADDING: PaddingOptions = { top: 0, right: 0, bottom: 0, left: 0 };
const GLOBAL_VIEW_MAX_ZOOM = 12;
const GLOBAL_VIEW_PADDING: PaddingOptions = { top: 48, right: 48, bottom: 48, left: 48 };
export function getWorkbenchPadding(leftPanelOpen: boolean, rightPanelOpen: boolean): PaddingOptions {
return {
@@ -31,11 +32,15 @@ export function getResponsiveWorkbenchPadding(
export function fitNetworkBounds(
map: MapLibreMap,
viewParams: NetworkViewParams = WATER_NETWORK_GLOBAL_VIEW,
padding: PaddingOptions = NO_PADDING
padding: PaddingOptions = GLOBAL_VIEW_PADDING
) {
map.fitBounds(
getLngLatBoundsFromBbox3857(viewParams.bbox3857),
{ padding: getResponsivePadding(map, padding), duration: 600 }
{
padding: getResponsivePadding(map, padding),
maxZoom: GLOBAL_VIEW_MAX_ZOOM,
duration: 600
}
);
}
@@ -0,0 +1,79 @@
import { describe, expect, it } from "vitest";
import {
compositeHexColor,
createCoolorsContrastUrl,
getContrastRatio
} from "./color-contrast";
import {
CLOSED_CONDUIT_COLOR,
DRAINAGE_SELECTED_OUTLINE_COLORS,
DRAINAGE_SOURCE_COLORS,
DRAINAGE_SOURCE_OPACITIES,
MAP_BACKGROUND_COLORS
} from "./map-colors";
import { createBaseStyle } from "./sources";
type DrainageColorId = keyof typeof DRAINAGE_SOURCE_COLORS;
const drainageColorIds = Object.keys(DRAINAGE_SOURCE_COLORS) as DrainageColorId[];
const mapBackgrounds = Object.values(MAP_BACKGROUND_COLORS);
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", () => {
drainageColorIds.forEach((sourceId) => {
mapBackgrounds.forEach((background) => {
const renderedColor = compositeHexColor(
DRAINAGE_SOURCE_COLORS[sourceId],
background,
DRAINAGE_SOURCE_OPACITIES[sourceId]
);
expect(
getContrastRatio(renderedColor, background),
createCoolorsContrastUrl(renderedColor, background)
).toBeGreaterThanOrEqual(3);
});
});
});
it("keeps selected outlines above 3:1 on every map background", () => {
drainageColorIds.forEach((sourceId) => {
mapBackgrounds.forEach((background) => {
const selectedColor = DRAINAGE_SELECTED_OUTLINE_COLORS[sourceId];
expect(
getContrastRatio(selectedColor, background),
createCoolorsContrastUrl(selectedColor, background)
).toBeGreaterThanOrEqual(3);
});
});
});
it("keeps closed conduits above 3:1 and applies the primary map background", () => {
mapBackgrounds.forEach((background) => {
const renderedColor = compositeHexColor(
CLOSED_CONDUIT_COLOR,
background,
DRAINAGE_SOURCE_OPACITIES.conduits
);
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_BACKGROUND_COLORS.primary);
});
});
+62
View File
@@ -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,63 @@
import type { MapGeoJSONFeature } from "maplibre-gl";
import { describe, expect, it } from "vitest";
import { toDetailFeature } from "./feature-adapter";
import { SOURCE_LAYERS } from "./sources";
describe("drainage feature adapter", () => {
it.each([
{
layer: "conduits",
sourceLayer: SOURCE_LAYERS.conduits,
properties: { id: "C-1", diameter: 600, length: 42.5 },
title: "管渠 C-1",
subtitle: "DN600 · 42.50 m"
},
{
layer: "junctions",
sourceLayer: SOURCE_LAYERS.junctions,
properties: { id: "J-1", max_depth: 4.2, invert_elevation: -1.8 },
title: "检查井 J-1",
subtitle: "最大深度 4.20 m · 井底高程 -1.80 m"
},
{
layer: "orifices",
sourceLayer: SOURCE_LAYERS.orifices,
properties: { id: "O-1", orifice_type: "SIDE", shape: "RECT_CLOSED" },
title: "孔口 O-1",
subtitle: "类型 SIDE · 断面 RECT_CLOSED"
},
{
layer: "pumps",
sourceLayer: SOURCE_LAYERS.pumps,
properties: { id: "P-1", status: "ON", pump_curve: "CURVE-1" },
title: "泵 P-1",
subtitle: "状态 ON · 曲线 CURVE-1"
},
{
layer: "outfalls",
sourceLayer: SOURCE_LAYERS.outfalls,
properties: { id: "OF-1", outfall_type: "FREE", invert_elevation: -5.67 },
title: "排放口 OF-1",
subtitle: "类型 FREE · 井底高程 -5.67 m"
}
] as const)("adapts $layer features", ({ layer, sourceLayer, properties, title, subtitle }) => {
const detail = toDetailFeature({
sourceLayer,
properties
} as unknown as MapGeoJSONFeature);
expect(detail).toMatchObject({
id: properties.id,
layer,
title,
subtitle,
properties
});
});
it("rejects features outside the drainage network sources", () => {
expect(() => toDetailFeature({ sourceLayer: "other" } as unknown as MapGeoJSONFeature)).toThrow(
"Unsupported water network source layer: other"
);
});
});
+52 -11
View File
@@ -1,27 +1,68 @@
import type { MapGeoJSONFeature } from "maplibre-gl";
import type { DetailFeature } from "../types";
import { formatValue } from "../utils/format-value";
import { SOURCE_LAYERS } from "./sources";
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 {
const entry = Object.entries(SOURCE_LAYERS).find(([, sourceLayer]) => sourceLayer === feature.sourceLayer);
return (entry?.[0] as WaterNetworkSourceId | undefined) ?? null;
}
export function toDetailFeature(feature: MapGeoJSONFeature): DetailFeature {
const isPipe = feature.sourceLayer === SOURCE_LAYERS.pipes;
const layer = getWaterNetworkSourceId(feature);
if (!layer) {
throw new Error(`Unsupported water network source layer: ${feature.sourceLayer ?? "unknown"}`);
}
const id = getFeatureId(feature);
const diameter = feature.properties?.diameter;
const length = feature.properties?.length;
const demand = feature.properties?.demand;
const properties = feature.properties ?? {};
const presentation = getFeaturePresentation(layer, id, properties);
return {
id,
layer: isPipe ? "pipes" : "junctions",
title: isPipe ? `管线 ${id || "未命名"}` : `节点 ${id || "未命名"}`,
subtitle: isPipe
? `DN${formatValue(diameter)} · ${formatValue(length)} m`
: `需水量 ${formatValue(demand)} · 高程 ${formatValue(feature.properties?.elevation)} m`,
properties: feature.properties ?? {}
layer,
...presentation,
properties
};
}
function getFeaturePresentation(
layer: WaterNetworkSourceId,
id: string,
properties: Record<string, unknown>
) {
const displayId = id || "未命名";
switch (layer) {
case "conduits":
return {
title: `管渠 ${displayId}`,
subtitle: `DN${formatValue(properties.diameter)} · ${formatValue(properties.length)} m`
};
case "junctions":
return {
title: `检查井 ${displayId}`,
subtitle: `最大深度 ${formatValue(properties.max_depth)} m · 井底高程 ${formatValue(properties.invert_elevation)} m`
};
case "orifices":
return {
title: `孔口 ${displayId}`,
subtitle: `类型 ${formatValue(properties.orifice_type)} · 断面 ${formatValue(properties.shape)}`
};
case "pumps":
return {
title: `${displayId}`,
subtitle: `状态 ${formatValue(properties.status)} · 曲线 ${formatValue(properties.pump_curve)}`
};
case "outfalls":
return {
title: `排放口 ${displayId}`,
subtitle: `类型 ${formatValue(properties.outfall_type)} · 井底高程 ${formatValue(properties.invert_elevation)} m`
};
}
}
+162
View File
@@ -0,0 +1,162 @@
import { describe, expect, it } from "vitest";
import {
INTERACTIVE_HIT_LAYER_IDS,
waterNetworkLayers
} from "./layers";
import {
DRAINAGE_SELECTED_OUTLINE_COLORS,
DRAINAGE_SOURCE_COLORS
} from "./map-colors";
import { DRAINAGE_LAYER_VISUALS, MAP_MAX_ZOOM } from "./map-layer-visuals";
describe("drainage network interaction layers", () => {
it("adds one invisible hit layer for every interactive source", () => {
expect(INTERACTIVE_HIT_LAYER_IDS).toEqual([
"conduits-hit",
"junctions-hit",
"orifices-hit",
"pumps-hit",
"outfalls-hit"
]);
const hitLayers = INTERACTIVE_HIT_LAYER_IDS.map((layerId) => {
const layer = waterNetworkLayers.find((candidate) => candidate.id === layerId);
expect(layer).toBeDefined();
return layer!;
});
expect(hitLayers.map((layer) => "source" in layer ? layer.source : null)).toEqual([
"conduits",
"junctions",
"orifices",
"pumps",
"outfalls"
]);
hitLayers.forEach((layer) => {
if (layer.type === "line") {
expect(layer.paint?.["line-width"]).toBe(DRAINAGE_LAYER_VISUALS.conduits.hitWidth);
expect(layer.paint?.["line-opacity"]).toBe(0);
} else if (layer.type === "circle") {
expect(layer.paint?.["circle-radius"]).toBe(DRAINAGE_LAYER_VISUALS.junctions.hitRadius);
expect(layer.paint?.["circle-opacity"]).toBe(0);
} else {
throw new Error(`Unexpected hit layer type: ${layer.type}`);
}
});
});
it("orders smaller facilities above conduits for overlapping hits", () => {
const layerIds = waterNetworkLayers.map((layer) => layer.id);
const hitLayerIndexes = INTERACTIVE_HIT_LAYER_IDS.map((layerId) => layerIds.indexOf(layerId));
expect(hitLayerIndexes).toEqual([...hitLayerIndexes].sort((first, second) => first - second));
expect(hitLayerIndexes.every((index) => index >= 0)).toBe(true);
});
it("provides persistent selected layers for every source", () => {
const selectedLayerIds = [
"pipes-selected-halo",
"pipes-selected-outline",
"pipes-selected",
"junctions-selected-halo",
"junctions-selected",
"orifices-selected-halo",
"orifices-selected-outline",
"orifices-selected",
"pumps-selected-halo",
"pumps-selected-outline",
"pumps-selected",
"outfalls-selected-halo",
"outfalls-selected"
];
selectedLayerIds.forEach((layerId) => {
const layer = waterNetworkLayers.find((candidate) => candidate.id === layerId);
expect(layer && "filter" in layer ? layer.filter : undefined).toEqual(["==", ["get", "id"], ""]);
expect(layer && "minzoom" in layer ? layer.minzoom : undefined).toBeUndefined();
});
});
it("renders selected features as a halo, complementary outline, and source-color core", () => {
const expectedSelectedColors = [
["pipes-selected-outline", "line-color", DRAINAGE_SELECTED_OUTLINE_COLORS.conduits],
["pipes-selected", "line-color", DRAINAGE_SOURCE_COLORS.conduits],
["junctions-selected", "circle-stroke-color", DRAINAGE_SELECTED_OUTLINE_COLORS.junctions],
["junctions-selected", "circle-color", DRAINAGE_SOURCE_COLORS.junctions],
["orifices-selected-outline", "line-color", DRAINAGE_SELECTED_OUTLINE_COLORS.orifices],
["orifices-selected", "line-color", DRAINAGE_SOURCE_COLORS.orifices],
["pumps-selected-outline", "line-color", DRAINAGE_SELECTED_OUTLINE_COLORS.pumps],
["pumps-selected", "line-color", DRAINAGE_SOURCE_COLORS.pumps],
["outfalls-selected", "circle-stroke-color", DRAINAGE_SELECTED_OUTLINE_COLORS.outfalls],
["outfalls-selected", "circle-color", DRAINAGE_SOURCE_COLORS.outfalls]
] as const;
expectedSelectedColors.forEach(([layerId, paintProperty, color]) => {
const layer = waterNetworkLayers.find((candidate) => candidate.id === layerId);
const paint = layer?.paint as Record<string, unknown> | undefined;
expect(paint?.[paintProperty]).toBe(color);
});
const layerIds = waterNetworkLayers.map((layer) => layer.id);
["pipes", "orifices", "pumps"].forEach((prefix) => {
expect(layerIds.indexOf(`${prefix}-selected-halo`)).toBeLessThan(
layerIds.indexOf(`${prefix}-selected-outline`)
);
expect(layerIds.indexOf(`${prefix}-selected-outline`)).toBeLessThan(
layerIds.indexOf(`${prefix}-selected`)
);
});
["junctions", "outfalls"].forEach((prefix) => {
expect(layerIds.indexOf(`${prefix}-selected-halo`)).toBeLessThan(
layerIds.indexOf(`${prefix}-selected`)
);
});
});
it("keeps source geometry readable through zoom level 24", () => {
expect(MAP_MAX_ZOOM).toBe(24);
expect(DRAINAGE_LAYER_VISUALS.junctions.minzoom).toBe(12);
expect(DRAINAGE_LAYER_VISUALS.junctions.radius).toEqual([
"interpolate", ["linear"], ["zoom"], 12, 1, 16, 3, 20, 5.5, 24, 8
]);
expect(DRAINAGE_LAYER_VISUALS.outfalls.radius).toEqual([
"interpolate", ["linear"], ["zoom"], 11, 3, 16, 5, 20, 7, 24, 9
]);
expect(DRAINAGE_LAYER_VISUALS.orifices.width).toEqual([
"interpolate", ["linear"], ["zoom"], 11, 1.6, 17, 4.5, 22, 6, 24, 7
]);
expect(DRAINAGE_LAYER_VISUALS.pumps.width).toEqual([
"interpolate", ["linear"], ["zoom"], 11, 2.4, 17, 6.2, 22, 8, 24, 9
]);
expect((DRAINAGE_LAYER_VISUALS.conduits.width as unknown[]).at(-2)).toBe(24);
expect((DRAINAGE_LAYER_VISUALS.conduits.hitWidth as unknown[]).at(-1)).toBe(24);
expect((DRAINAGE_LAYER_VISUALS.junctions.hitRadius as unknown[]).at(-1)).toBe(14);
});
it("uses the centralized geometry tokens in rendered layers", () => {
const expectedGeometry = [
["pipes-flow", "line-width", DRAINAGE_LAYER_VISUALS.conduits.width],
["pipes-selected-halo", "line-width", DRAINAGE_LAYER_VISUALS.conduits.selectedHaloWidth],
["junctions", "circle-radius", DRAINAGE_LAYER_VISUALS.junctions.radius],
["junctions-selected-halo", "circle-radius", DRAINAGE_LAYER_VISUALS.junctions.selectedHaloRadius],
["orifices", "line-width", DRAINAGE_LAYER_VISUALS.orifices.width],
["orifices-selected", "line-width", DRAINAGE_LAYER_VISUALS.orifices.selectedWidth],
["pumps", "line-width", DRAINAGE_LAYER_VISUALS.pumps.width],
["pumps-selected", "line-width", DRAINAGE_LAYER_VISUALS.pumps.selectedWidth],
["outfalls", "circle-radius", DRAINAGE_LAYER_VISUALS.outfalls.radius],
["outfalls-selected-halo", "circle-radius", DRAINAGE_LAYER_VISUALS.outfalls.selectedHaloRadius]
] as const;
expectedGeometry.forEach(([layerId, paintProperty, visualToken]) => {
const layer = waterNetworkLayers.find((candidate) => candidate.id === layerId);
const paint = layer?.paint as Record<string, unknown> | undefined;
expect(paint?.[paintProperty]).toBe(visualToken);
});
["junctions-halo", "junctions", "junctions-hover", "junctions-hit"].forEach((layerId) => {
const layer = waterNetworkLayers.find((candidate) => candidate.id === layerId);
expect(layer && "minzoom" in layer ? layer.minzoom : undefined).toBe(12);
});
});
});
+425 -68
View File
@@ -1,85 +1,127 @@
import type { StyleSpecification } from "maplibre-gl";
import {
CLOSED_CONDUIT_COLOR,
DRAINAGE_SELECTED_OUTLINE_COLORS,
DRAINAGE_SOURCE_COLORS,
DRAINAGE_SOURCE_OPACITIES
} from "./map-colors";
import { DRAINAGE_LAYER_VISUALS } from "./map-layer-visuals";
import { SOURCE_LAYERS } from "./sources";
export { DRAINAGE_SOURCE_COLORS } from "./map-colors";
export const INTERACTIVE_HIT_LAYER_IDS = [
"conduits-hit",
"junctions-hit",
"orifices-hit",
"pumps-hit",
"outfalls-hit"
];
export const waterNetworkLayers: StyleSpecification["layers"] = [
{
id: "pipes-casing",
type: "line",
source: "pipes",
"source-layer": SOURCE_LAYERS.pipes,
minzoom: 9,
source: "conduits",
"source-layer": SOURCE_LAYERS.conduits,
minzoom: DRAINAGE_LAYER_VISUALS.conduits.minzoom,
paint: {
"line-color": "rgba(255,255,255,0.86)",
"line-width": [
"interpolate",
["linear"],
["zoom"],
9,
1,
13,
2.4,
17,
["+", 5, ["/", ["coalesce", ["get", "diameter"], 100], 280]]
],
"line-width": DRAINAGE_LAYER_VISUALS.conduits.casingWidth,
"line-opacity": 0.9
}
},
{
id: "pipes-flow",
type: "line",
source: "pipes",
"source-layer": SOURCE_LAYERS.pipes,
minzoom: 9,
source: "conduits",
"source-layer": SOURCE_LAYERS.conduits,
minzoom: DRAINAGE_LAYER_VISUALS.conduits.minzoom,
paint: {
"line-color": [
"case",
["==", ["get", "status"], "Closed"],
"#9ca3af",
[">=", ["coalesce", ["get", "diameter"], 0], 600],
"#0477bf",
[">=", ["coalesce", ["get", "diameter"], 0], 300],
"#0aa6a6",
"#3dbf7f"
CLOSED_CONDUIT_COLOR,
DRAINAGE_SOURCE_COLORS.conduits
],
"line-width": [
"interpolate",
["linear"],
["zoom"],
9,
0.8,
13,
1.8,
17,
["+", 2.5, ["/", ["coalesce", ["get", "diameter"], 100], 360]]
],
"line-opacity": 0.82
"line-width": DRAINAGE_LAYER_VISUALS.conduits.width,
"line-opacity": DRAINAGE_SOURCE_OPACITIES.conduits
}
},
{
id: "pipes-risk-glow",
type: "line",
source: "pipes",
"source-layer": SOURCE_LAYERS.pipes,
source: "conduits",
"source-layer": SOURCE_LAYERS.conduits,
minzoom: 12,
filter: [">=", ["coalesce", ["get", "diameter"], 0], 600],
paint: {
"line-color": "#ff7a45",
"line-width": ["interpolate", ["linear"], ["zoom"], 12, 2.6, 17, 7.5],
"line-color": DRAINAGE_SOURCE_COLORS.conduits,
"line-width": DRAINAGE_LAYER_VISUALS.conduits.riskGlowWidth,
"line-blur": 2.5,
"line-opacity": 0.22
}
},
{
id: "pipes-hover",
id: "pipes-hover-outline",
type: "line",
source: "pipes",
"source-layer": SOURCE_LAYERS.pipes,
minzoom: 9,
source: "conduits",
"source-layer": SOURCE_LAYERS.conduits,
minzoom: DRAINAGE_LAYER_VISUALS.conduits.minzoom,
filter: ["==", ["get", "id"], ""],
paint: {
"line-color": "#111827",
"line-width": 7,
"line-opacity": 0.35
"line-color": "#ffffff",
"line-width": DRAINAGE_LAYER_VISUALS.conduits.hoverOutlineWidth,
"line-opacity": 0.92
}
},
{
id: "pipes-hover",
type: "line",
source: "conduits",
"source-layer": SOURCE_LAYERS.conduits,
minzoom: DRAINAGE_LAYER_VISUALS.conduits.minzoom,
filter: ["==", ["get", "id"], ""],
paint: {
"line-color": DRAINAGE_SOURCE_COLORS.conduits,
"line-width": DRAINAGE_LAYER_VISUALS.conduits.hoverWidth,
"line-opacity": 1
}
},
{
id: "pipes-selected-halo",
type: "line",
source: "conduits",
"source-layer": SOURCE_LAYERS.conduits,
filter: ["==", ["get", "id"], ""],
paint: {
"line-color": "#ffffff",
"line-width": DRAINAGE_LAYER_VISUALS.conduits.selectedHaloWidth,
"line-opacity": 1
}
},
{
id: "pipes-selected-outline",
type: "line",
source: "conduits",
"source-layer": SOURCE_LAYERS.conduits,
filter: ["==", ["get", "id"], ""],
paint: {
"line-color": DRAINAGE_SELECTED_OUTLINE_COLORS.conduits,
"line-width": DRAINAGE_LAYER_VISUALS.conduits.selectedOutlineWidth,
"line-opacity": 1
}
},
{
id: "pipes-selected",
type: "line",
source: "conduits",
"source-layer": SOURCE_LAYERS.conduits,
filter: ["==", ["get", "id"], ""],
paint: {
"line-color": DRAINAGE_SOURCE_COLORS.conduits,
"line-width": DRAINAGE_LAYER_VISUALS.conduits.selectedWidth,
"line-opacity": 1
}
},
{
@@ -87,11 +129,11 @@ export const waterNetworkLayers: StyleSpecification["layers"] = [
type: "circle",
source: "junctions",
"source-layer": SOURCE_LAYERS.junctions,
minzoom: 13,
minzoom: DRAINAGE_LAYER_VISUALS.junctions.minzoom,
paint: {
"circle-color": "#ffffff",
"circle-radius": ["interpolate", ["linear"], ["zoom"], 13, 2.8, 17, 6],
"circle-opacity": 0.82,
"circle-radius": DRAINAGE_LAYER_VISUALS.junctions.haloRadius,
"circle-opacity": 0.76,
"circle-stroke-color": "rgba(15,23,42,0.22)",
"circle-stroke-width": 1
}
@@ -101,21 +143,11 @@ export const waterNetworkLayers: StyleSpecification["layers"] = [
type: "circle",
source: "junctions",
"source-layer": SOURCE_LAYERS.junctions,
minzoom: 13,
minzoom: DRAINAGE_LAYER_VISUALS.junctions.minzoom,
paint: {
"circle-color": [
"interpolate",
["linear"],
["coalesce", ["get", "demand"], 0],
0,
"#70c1b3",
20,
"#247ba0",
60,
"#f25f5c"
],
"circle-radius": ["interpolate", ["linear"], ["zoom"], 13, 1.5, 17, 3.8],
"circle-opacity": 0.86
"circle-color": DRAINAGE_SOURCE_COLORS.junctions,
"circle-radius": DRAINAGE_LAYER_VISUALS.junctions.radius,
"circle-opacity": DRAINAGE_SOURCE_OPACITIES.junctions
}
},
{
@@ -123,14 +155,339 @@ export const waterNetworkLayers: StyleSpecification["layers"] = [
type: "circle",
source: "junctions",
"source-layer": SOURCE_LAYERS.junctions,
minzoom: 13,
minzoom: DRAINAGE_LAYER_VISUALS.junctions.minzoom,
filter: ["==", ["get", "id"], ""],
paint: {
"circle-color": "#111827",
"circle-radius": ["interpolate", ["linear"], ["zoom"], 13, 5, 17, 10],
"circle-opacity": 0.2,
"circle-stroke-color": "#111827",
"circle-color": DRAINAGE_SOURCE_COLORS.junctions,
"circle-radius": DRAINAGE_LAYER_VISUALS.junctions.hoverRadius,
"circle-opacity": 0.24,
"circle-stroke-color": DRAINAGE_SOURCE_COLORS.junctions,
"circle-stroke-width": 2
}
},
{
id: "junctions-selected-halo",
type: "circle",
source: "junctions",
"source-layer": SOURCE_LAYERS.junctions,
filter: ["==", ["get", "id"], ""],
paint: {
"circle-color": "#ffffff",
"circle-radius": DRAINAGE_LAYER_VISUALS.junctions.selectedHaloRadius,
"circle-opacity": 0.92
}
},
{
id: "junctions-selected",
type: "circle",
source: "junctions",
"source-layer": SOURCE_LAYERS.junctions,
filter: ["==", ["get", "id"], ""],
paint: {
"circle-color": DRAINAGE_SOURCE_COLORS.junctions,
"circle-radius": DRAINAGE_LAYER_VISUALS.junctions.selectedRadius,
"circle-opacity": 1,
"circle-stroke-color": DRAINAGE_SELECTED_OUTLINE_COLORS.junctions,
"circle-stroke-width": 3
}
},
{
id: "orifices-casing",
type: "line",
source: "orifices",
"source-layer": SOURCE_LAYERS.orifices,
minzoom: DRAINAGE_LAYER_VISUALS.orifices.minzoom,
paint: {
"line-color": "#ffffff",
"line-width": DRAINAGE_LAYER_VISUALS.orifices.casingWidth,
"line-opacity": 0.9
}
},
{
id: "orifices",
type: "line",
source: "orifices",
"source-layer": SOURCE_LAYERS.orifices,
minzoom: DRAINAGE_LAYER_VISUALS.orifices.minzoom,
paint: {
"line-color": DRAINAGE_SOURCE_COLORS.orifices,
"line-width": DRAINAGE_LAYER_VISUALS.orifices.width,
"line-opacity": DRAINAGE_SOURCE_OPACITIES.orifices
}
},
{
id: "orifices-hover-outline",
type: "line",
source: "orifices",
"source-layer": SOURCE_LAYERS.orifices,
minzoom: DRAINAGE_LAYER_VISUALS.orifices.minzoom,
filter: ["==", ["get", "id"], ""],
paint: {
"line-color": "#ffffff",
"line-width": DRAINAGE_LAYER_VISUALS.orifices.hoverOutlineWidth,
"line-opacity": 0.92
}
},
{
id: "orifices-hover",
type: "line",
source: "orifices",
"source-layer": SOURCE_LAYERS.orifices,
minzoom: DRAINAGE_LAYER_VISUALS.orifices.minzoom,
filter: ["==", ["get", "id"], ""],
paint: {
"line-color": DRAINAGE_SOURCE_COLORS.orifices,
"line-width": DRAINAGE_LAYER_VISUALS.orifices.hoverWidth,
"line-opacity": 1
}
},
{
id: "orifices-selected-halo",
type: "line",
source: "orifices",
"source-layer": SOURCE_LAYERS.orifices,
filter: ["==", ["get", "id"], ""],
paint: {
"line-color": "#ffffff",
"line-width": DRAINAGE_LAYER_VISUALS.orifices.selectedHaloWidth,
"line-opacity": 1
}
},
{
id: "orifices-selected-outline",
type: "line",
source: "orifices",
"source-layer": SOURCE_LAYERS.orifices,
filter: ["==", ["get", "id"], ""],
paint: {
"line-color": DRAINAGE_SELECTED_OUTLINE_COLORS.orifices,
"line-width": DRAINAGE_LAYER_VISUALS.orifices.selectedOutlineWidth,
"line-opacity": 1
}
},
{
id: "orifices-selected",
type: "line",
source: "orifices",
"source-layer": SOURCE_LAYERS.orifices,
filter: ["==", ["get", "id"], ""],
paint: {
"line-color": DRAINAGE_SOURCE_COLORS.orifices,
"line-width": DRAINAGE_LAYER_VISUALS.orifices.selectedWidth,
"line-opacity": 1
}
},
{
id: "pumps-casing",
type: "line",
source: "pumps",
"source-layer": SOURCE_LAYERS.pumps,
minzoom: DRAINAGE_LAYER_VISUALS.pumps.minzoom,
paint: {
"line-color": "#ffffff",
"line-width": DRAINAGE_LAYER_VISUALS.pumps.casingWidth,
"line-opacity": 0.9
}
},
{
id: "pumps",
type: "line",
source: "pumps",
"source-layer": SOURCE_LAYERS.pumps,
minzoom: DRAINAGE_LAYER_VISUALS.pumps.minzoom,
paint: {
"line-color": DRAINAGE_SOURCE_COLORS.pumps,
"line-width": DRAINAGE_LAYER_VISUALS.pumps.width,
"line-opacity": DRAINAGE_SOURCE_OPACITIES.pumps
}
},
{
id: "pumps-hover-outline",
type: "line",
source: "pumps",
"source-layer": SOURCE_LAYERS.pumps,
minzoom: DRAINAGE_LAYER_VISUALS.pumps.minzoom,
filter: ["==", ["get", "id"], ""],
paint: {
"line-color": "#ffffff",
"line-width": DRAINAGE_LAYER_VISUALS.pumps.hoverOutlineWidth,
"line-opacity": 0.92
}
},
{
id: "pumps-hover",
type: "line",
source: "pumps",
"source-layer": SOURCE_LAYERS.pumps,
minzoom: DRAINAGE_LAYER_VISUALS.pumps.minzoom,
filter: ["==", ["get", "id"], ""],
paint: {
"line-color": DRAINAGE_SOURCE_COLORS.pumps,
"line-width": DRAINAGE_LAYER_VISUALS.pumps.hoverWidth,
"line-opacity": 1
}
},
{
id: "pumps-selected-halo",
type: "line",
source: "pumps",
"source-layer": SOURCE_LAYERS.pumps,
filter: ["==", ["get", "id"], ""],
paint: {
"line-color": "#ffffff",
"line-width": DRAINAGE_LAYER_VISUALS.pumps.selectedHaloWidth,
"line-opacity": 1
}
},
{
id: "pumps-selected-outline",
type: "line",
source: "pumps",
"source-layer": SOURCE_LAYERS.pumps,
filter: ["==", ["get", "id"], ""],
paint: {
"line-color": DRAINAGE_SELECTED_OUTLINE_COLORS.pumps,
"line-width": DRAINAGE_LAYER_VISUALS.pumps.selectedOutlineWidth,
"line-opacity": 1
}
},
{
id: "pumps-selected",
type: "line",
source: "pumps",
"source-layer": SOURCE_LAYERS.pumps,
filter: ["==", ["get", "id"], ""],
paint: {
"line-color": DRAINAGE_SOURCE_COLORS.pumps,
"line-width": DRAINAGE_LAYER_VISUALS.pumps.selectedWidth,
"line-opacity": 1
}
},
{
id: "outfalls-halo",
type: "circle",
source: "outfalls",
"source-layer": SOURCE_LAYERS.outfalls,
minzoom: DRAINAGE_LAYER_VISUALS.outfalls.minzoom,
paint: {
"circle-color": "#ffffff",
"circle-radius": DRAINAGE_LAYER_VISUALS.outfalls.haloRadius,
"circle-opacity": 0.9
}
},
{
id: "outfalls",
type: "circle",
source: "outfalls",
"source-layer": SOURCE_LAYERS.outfalls,
minzoom: DRAINAGE_LAYER_VISUALS.outfalls.minzoom,
paint: {
"circle-color": DRAINAGE_SOURCE_COLORS.outfalls,
"circle-radius": DRAINAGE_LAYER_VISUALS.outfalls.radius,
"circle-stroke-color": "#7F1D2D",
"circle-stroke-width": 1,
"circle-opacity": DRAINAGE_SOURCE_OPACITIES.outfalls
}
},
{
id: "outfalls-hover",
type: "circle",
source: "outfalls",
"source-layer": SOURCE_LAYERS.outfalls,
minzoom: DRAINAGE_LAYER_VISUALS.outfalls.minzoom,
filter: ["==", ["get", "id"], ""],
paint: {
"circle-color": DRAINAGE_SOURCE_COLORS.outfalls,
"circle-radius": DRAINAGE_LAYER_VISUALS.outfalls.hoverRadius,
"circle-opacity": 0.24,
"circle-stroke-color": DRAINAGE_SOURCE_COLORS.outfalls,
"circle-stroke-width": 2
}
},
{
id: "outfalls-selected-halo",
type: "circle",
source: "outfalls",
"source-layer": SOURCE_LAYERS.outfalls,
filter: ["==", ["get", "id"], ""],
paint: {
"circle-color": "#ffffff",
"circle-radius": DRAINAGE_LAYER_VISUALS.outfalls.selectedHaloRadius,
"circle-opacity": 0.92
}
},
{
id: "outfalls-selected",
type: "circle",
source: "outfalls",
"source-layer": SOURCE_LAYERS.outfalls,
filter: ["==", ["get", "id"], ""],
paint: {
"circle-color": DRAINAGE_SOURCE_COLORS.outfalls,
"circle-radius": DRAINAGE_LAYER_VISUALS.outfalls.selectedRadius,
"circle-opacity": 1,
"circle-stroke-color": DRAINAGE_SELECTED_OUTLINE_COLORS.outfalls,
"circle-stroke-width": 3
}
},
{
id: "conduits-hit",
type: "line",
source: "conduits",
"source-layer": SOURCE_LAYERS.conduits,
minzoom: DRAINAGE_LAYER_VISUALS.conduits.minzoom,
paint: {
"line-color": "#000000",
"line-width": DRAINAGE_LAYER_VISUALS.conduits.hitWidth,
"line-opacity": 0
}
},
{
id: "junctions-hit",
type: "circle",
source: "junctions",
"source-layer": SOURCE_LAYERS.junctions,
minzoom: DRAINAGE_LAYER_VISUALS.junctions.minzoom,
paint: {
"circle-color": "#000000",
"circle-radius": DRAINAGE_LAYER_VISUALS.junctions.hitRadius,
"circle-opacity": 0
}
},
{
id: "orifices-hit",
type: "line",
source: "orifices",
"source-layer": SOURCE_LAYERS.orifices,
minzoom: DRAINAGE_LAYER_VISUALS.orifices.minzoom,
paint: {
"line-color": "#000000",
"line-width": DRAINAGE_LAYER_VISUALS.orifices.hitWidth,
"line-opacity": 0
}
},
{
id: "pumps-hit",
type: "line",
source: "pumps",
"source-layer": SOURCE_LAYERS.pumps,
minzoom: DRAINAGE_LAYER_VISUALS.pumps.minzoom,
paint: {
"line-color": "#000000",
"line-width": DRAINAGE_LAYER_VISUALS.pumps.hitWidth,
"line-opacity": 0
}
},
{
id: "outfalls-hit",
type: "circle",
source: "outfalls",
"source-layer": SOURCE_LAYERS.outfalls,
minzoom: DRAINAGE_LAYER_VISUALS.outfalls.minzoom,
paint: {
"circle-color": "#000000",
"circle-radius": DRAINAGE_LAYER_VISUALS.outfalls.hitRadius,
"circle-opacity": 0
}
}
];
+31
View File
@@ -0,0 +1,31 @@
export const MAP_BACKGROUND_COLORS = {
primary: "#F9FAFB",
secondary: "#F0F1F3",
secondaryAlt: "#FAFBFC"
} as const;
export const DRAINAGE_SOURCE_COLORS = {
conduits: "#0F766E",
junctions: "#B45309",
orifices: "#6D28D9",
pumps: "#1D4ED8",
outfalls: "#B91C1C"
} as const;
export const DRAINAGE_SOURCE_OPACITIES = {
conduits: 0.88,
junctions: 0.9,
orifices: 0.92,
pumps: 0.92,
outfalls: 0.95
} as const;
export const DRAINAGE_SELECTED_OUTLINE_COLORS = {
conduits: "#BE123C",
junctions: "#1D4ED8",
orifices: "#3F6212",
pumps: "#C2410C",
outfalls: "#0E7490"
} as const;
export const CLOSED_CONDUIT_COLOR = "#64748B";
@@ -0,0 +1,54 @@
import type { Map as MapLibreMap } from "maplibre-gl";
import { describe, expect, it } from "vitest";
import { waterNetworkLayers } from "./layers";
import {
INITIAL_LAYER_VISIBILITY,
createLayerControlItems,
getWorkbenchLayerIds
} from "./map-control-config";
describe("workbench source layer controls", () => {
it("creates one business control for each Lingang source", () => {
const items = createLayerControlItems(INITIAL_LAYER_VISIBILITY);
expect(items.map((item) => item.id)).toEqual([
"conduits",
"junctions",
"orifices",
"outfalls",
"pumps"
]);
expect(items.slice(0, 5).map((item) => item.description)).toEqual([
"lingang:geo_conduits_mat",
"lingang:geo_junctions_mat",
"lingang:geo_orifices_mat",
"lingang:geo_outfalls_mat",
"lingang:geo_pumps_mat"
]);
});
it("finds every rendered layer that uses a source", () => {
const map = {
getStyle: () => ({ version: 8, sources: {}, layers: waterNetworkLayers })
} as Pick<MapLibreMap, "getStyle">;
expect(getWorkbenchLayerIds(map, "orifices")).toEqual([
"orifices-casing",
"orifices",
"orifices-hover-outline",
"orifices-hover",
"orifices-selected-halo",
"orifices-selected-outline",
"orifices-selected",
"orifices-hit"
]);
expect(getWorkbenchLayerIds(map, "outfalls")).toEqual([
"outfalls-halo",
"outfalls",
"outfalls-hover",
"outfalls-selected-halo",
"outfalls-selected",
"outfalls-hit"
]);
});
});
+47 -27
View File
@@ -1,10 +1,15 @@
import type { Map as MapLibreMap } from "maplibre-gl";
import type { BaseLayerOption } from "@/features/map/core/components/base-layers-control";
import type { MapLayerControlItem } from "@/features/map/core/components/layer-control";
import type { MapLegendItem } from "@/features/map/core/components/legend";
import { DRAINAGE_SOURCE_COLORS } from "./map-colors";
import {
SOURCE_LAYERS,
WATER_NETWORK_SOURCE_IDS,
type WaterNetworkSourceId
} from "./sources";
export const WORKBENCH_LAYER_GROUPS: Record<string, string[]> = {
pipes: ["pipes-casing", "pipes-flow", "pipes-risk-glow", "pipes-hover"],
junctions: ["junctions-halo", "junctions", "junctions-hover"],
simulation: [
"simulation-impact-fill",
"simulation-impact-outline",
@@ -18,16 +23,28 @@ export const WORKBENCH_LAYER_GROUPS: Record<string, string[]> = {
};
export const INITIAL_LAYER_VISIBILITY: Record<string, boolean> = {
pipes: true,
conduits: true,
junctions: true,
orifices: true,
outfalls: true,
pumps: true,
simulation: false
};
const SOURCE_CONTROL_LABELS: Record<WaterNetworkSourceId, { label: string; description: string }> = {
conduits: { label: "管渠", description: `lingang:${SOURCE_LAYERS.conduits}` },
junctions: { label: "检查井", description: `lingang:${SOURCE_LAYERS.junctions}` },
orifices: { label: "孔口", description: `lingang:${SOURCE_LAYERS.orifices}` },
outfalls: { label: "排放口", description: `lingang:${SOURCE_LAYERS.outfalls}` },
pumps: { label: "泵", description: `lingang:${SOURCE_LAYERS.pumps}` }
};
export const MAP_LEGEND_ITEMS: MapLegendItem[] = [
{ id: "major-pipe", label: "DN600 及以上管线", color: "#0477bf", shape: "line" },
{ id: "medium-pipe", label: "DN300-DN600 管线", color: "#0aa6a6", shape: "line" },
{ id: "minor-pipe", label: "DN300 以下管线", color: "#3dbf7f", shape: "line" },
{ id: "junction-demand", label: "节点需水强度", color: "#f25f5c", shape: "dot" },
{ id: "conduit", label: "管渠(线宽表示口径)", color: DRAINAGE_SOURCE_COLORS.conduits, shape: "line" },
{ id: "junction", label: "检查井", color: DRAINAGE_SOURCE_COLORS.junctions, shape: "dot" },
{ id: "orifice", label: "孔口", color: DRAINAGE_SOURCE_COLORS.orifices, shape: "line" },
{ id: "pump", label: "", color: DRAINAGE_SOURCE_COLORS.pumps, shape: "line" },
{ id: "outfall", label: "排放口", color: DRAINAGE_SOURCE_COLORS.outfalls, shape: "dot" },
{ id: "impact-area", label: "影响范围", color: "#ef4444", shape: "square" }
];
@@ -55,24 +72,27 @@ export const BASE_LAYER_OPTIONS: BaseLayerOption[] = [
];
export function createLayerControlItems(layerVisibility: Record<string, boolean>): MapLayerControlItem[] {
return [
{
id: "pipes",
label: "管线",
description: "主干、支线与风险高亮",
visible: layerVisibility.pipes
},
{
id: "junctions",
label: "节点",
description: "用水节点与悬停反馈",
visible: layerVisibility.junctions
},
{
id: "simulation",
label: "模拟结果",
description: "影响范围与事故标注",
visible: layerVisibility.simulation
}
];
return WATER_NETWORK_SOURCE_IDS.map((sourceId) => ({
id: sourceId,
...SOURCE_CONTROL_LABELS[sourceId],
visible: layerVisibility[sourceId]
}));
}
export function getWorkbenchLayerIds(
map: Pick<MapLibreMap, "getStyle">,
layerControlId: string
) {
if (isWaterNetworkSourceId(layerControlId)) {
return map
.getStyle()
.layers.filter((layer) => "source" in layer && layer.source === layerControlId)
.map((layer) => layer.id);
}
return WORKBENCH_LAYER_GROUPS[layerControlId] ?? [];
}
function isWaterNetworkSourceId(value: string): value is WaterNetworkSourceId {
return (WATER_NETWORK_SOURCE_IDS as readonly string[]).includes(value);
}
+106
View File
@@ -0,0 +1,106 @@
import type { StyleSpecification } from "maplibre-gl";
type LayerSpecification = StyleSpecification["layers"][number];
type LineLayerSpecification = Extract<LayerSpecification, { type: "line" }>;
type CircleLayerSpecification = Extract<LayerSpecification, { type: "circle" }>;
type LineWidth = NonNullable<LineLayerSpecification["paint"]>["line-width"];
type CircleRadius = NonNullable<CircleLayerSpecification["paint"]>["circle-radius"];
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"],
12,
9,
16,
10,
20,
12,
24,
14
] as CircleRadius;
export const DRAINAGE_LAYER_VISUALS = {
conduits: {
minzoom: 9,
casingWidth: [
"interpolate", ["linear"], ["zoom"],
9, 1.8,
13, 3,
17, ["+", 4.5, ["/", ["coalesce", ["get", "diameter"], 100], 300]],
22, ["+", 6, ["/", ["coalesce", ["get", "diameter"], 100], 260]],
24, ["+", 7, ["/", ["coalesce", ["get", "diameter"], 100], 240]]
] as LineWidth,
width: [
"interpolate", ["linear"], ["zoom"],
9, 0.9,
13, 1.8,
17, ["+", 2.5, ["/", ["coalesce", ["get", "diameter"], 100], 360]],
22, ["+", 3.5, ["/", ["coalesce", ["get", "diameter"], 100], 300]],
24, ["+", 4, ["/", ["coalesce", ["get", "diameter"], 100], 280]]
] as LineWidth,
riskGlowWidth: ["interpolate", ["linear"], ["zoom"], 12, 2.6, 17, 7.5, 22, 9.5, 24, 11] as LineWidth,
hoverOutlineWidth: ["interpolate", ["linear"], ["zoom"], 9, 5.5, 13, 8, 17, 12.5, 22, 15, 24, 17] as LineWidth,
hoverWidth: ["interpolate", ["linear"], ["zoom"], 9, 3, 13, 5.2, 17, 9, 22, 11.5, 24, 13] as LineWidth,
selectedHaloWidth: ["interpolate", ["linear"], ["zoom"], 9, 7, 13, 10, 17, 14.5, 22, 17.5, 24, 20] as LineWidth,
selectedOutlineWidth: ["interpolate", ["linear"], ["zoom"], 9, 5.5, 13, 8.2, 17, 12.5, 22, 15.5, 24, 18] as LineWidth,
selectedWidth: ["interpolate", ["linear"], ["zoom"], 9, 4, 13, 6.5, 17, 10.5, 22, 13.5, 24, 16] as LineWidth,
hitWidth: lineHitWidth
},
junctions: {
minzoom: 12,
haloRadius: ["interpolate", ["linear"], ["zoom"], 12, 2.5, 16, 4.5, 20, 7, 24, 9.5] as CircleRadius,
radius: ["interpolate", ["linear"], ["zoom"], 12, 1, 16, 3, 20, 5.5, 24, 8] as CircleRadius,
hoverRadius: ["interpolate", ["linear"], ["zoom"], 12, 5, 16, 7, 20, 10, 24, 12] as CircleRadius,
selectedHaloRadius: ["interpolate", ["linear"], ["zoom"], 12, 10.5, 16, 12.5, 20, 15.5, 24, 18] as CircleRadius,
selectedRadius: ["interpolate", ["linear"], ["zoom"], 12, 5.5, 16, 7.5, 20, 10.5, 24, 13] as CircleRadius,
hitRadius: pointHitRadius
},
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,
selectedHaloWidth: ["interpolate", ["linear"], ["zoom"], 11, 8, 17, 13, 22, 16, 24, 18] as LineWidth,
selectedOutlineWidth: ["interpolate", ["linear"], ["zoom"], 11, 6.75, 17, 11, 22, 14, 24, 16] as LineWidth,
selectedWidth: ["interpolate", ["linear"], ["zoom"], 11, 5.5, 17, 9, 22, 12, 24, 14] as LineWidth,
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,
selectedHaloWidth: ["interpolate", ["linear"], ["zoom"], 11, 9, 17, 14, 22, 17, 24, 19] as LineWidth,
selectedOutlineWidth: ["interpolate", ["linear"], ["zoom"], 11, 7.5, 17, 12, 22, 15, 24, 17] as LineWidth,
selectedWidth: ["interpolate", ["linear"], ["zoom"], 11, 6, 17, 10, 22, 13, 24, 15] as LineWidth,
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,
selectedHaloRadius: ["interpolate", ["linear"], ["zoom"], 11, 12.5, 16, 14.5, 20, 17.5, 24, 20] as CircleRadius,
selectedRadius: ["interpolate", ["linear"], ["zoom"], 11, 7.5, 16, 9.5, 20, 12.5, 24, 15] as CircleRadius,
hitRadius: pointHitRadius
}
} as const;
+2 -2
View File
@@ -26,8 +26,8 @@ export const measurementLayers: StyleSpecification["layers"] = [
{
id: MEASUREMENT_SELECTED_PIPES_LAYER_ID,
type: "line",
source: "pipes",
"source-layer": SOURCE_LAYERS.pipes,
source: "conduits",
"source-layer": SOURCE_LAYERS.conduits,
minzoom: 9,
filter: ["in", ["get", "id"], ["literal", []]],
paint: {
+65 -24
View File
@@ -1,37 +1,78 @@
import type { StyleSpecification } from "maplibre-gl";
import type { VectorSourceSpecification, StyleSpecification } from "maplibre-gl";
import { MAP_BACKGROUND_COLORS } from "./map-colors";
export const WATER_NETWORK_GLOBAL_VIEW = {
bbox3857: [
13508802,
3608164,
13555651,
3633686
13551482,
3612812.75,
13577696,
3632065.75
]
} as const;
export const SOURCE_LAYERS = {
pipes: "geo_pipes_mat",
junctions: "geo_junctions_mat"
conduits: "geo_conduits_mat",
junctions: "geo_junctions_mat",
orifices: "geo_orifices_mat",
outfalls: "geo_outfalls_mat",
pumps: "geo_pumps_mat"
} as const;
export const WATER_NETWORK_SOURCE_IDS = [
"conduits",
"junctions",
"orifices",
"outfalls",
"pumps"
] as const;
export type WaterNetworkSourceId = (typeof WATER_NETWORK_SOURCE_IDS)[number];
const GEOSERVER_WMTS_ROOT = "https://geoserver.waternetwork.cn/geoserver/gwc/service/wmts/rest";
export function createWaterNetworkSources() {
return {
pipes: {
type: "vector" as const,
tiles: [
"https://geoserver.waternetwork.cn/geoserver/gwc/service/wmts/rest/tjwater:geo_pipes_mat/line/WebMercatorQuad/{z}/{y}/{x}?format=application/vnd.mapbox-vector-tile"
],
minzoom: 0,
maxzoom: 24
},
junctions: {
type: "vector" as const,
tiles: [
"https://geoserver.waternetwork.cn/geoserver/gwc/service/wmts/rest/tjwater:geo_junctions_mat/generic/WebMercatorQuad/{z}/{y}/{x}?format=application/vnd.mapbox-vector-tile"
],
minzoom: 0,
maxzoom: 24
}
conduits: createLingangVectorSource(
SOURCE_LAYERS.conduits,
"line",
[121.7350340307058, 30.84636502815, 121.97051839928491, 30.994737681416165]
),
junctions: createLingangVectorSource(
SOURCE_LAYERS.junctions,
"point",
[121.7350340307058, 30.84636502815, 121.97051839928491, 30.994737681416165]
),
orifices: createLingangVectorSource(
SOURCE_LAYERS.orifices,
"line",
[121.88611269518903, 30.919491577239235, 121.9347115520599, 30.952564332104696]
),
outfalls: createLingangVectorSource(
SOURCE_LAYERS.outfalls,
"point",
[121.77154156385242, 30.85723317314842, 121.77231411499677, 30.859452151585806]
),
pumps: createLingangVectorSource(
SOURCE_LAYERS.pumps,
"line",
[121.73585149761436, 30.861759757577705, 121.9498481645973, 30.983213202338085]
)
};
}
function createLingangVectorSource(
sourceLayer: (typeof SOURCE_LAYERS)[keyof typeof SOURCE_LAYERS],
style: "line" | "point",
bounds: [number, number, number, number]
): VectorSourceSpecification {
return {
type: "vector",
tiles: [
`${GEOSERVER_WMTS_ROOT}/lingang:${sourceLayer}/${style}/WebMercatorQuad/{z}/{y}/{x}?format=application/vnd.mapbox-vector-tile`
],
bounds,
minzoom: 0,
maxzoom: 24
};
}
@@ -42,7 +83,7 @@ export function createBaseStyle(mapboxToken?: string): StyleSpecification {
id: "background",
type: "background",
paint: {
"background-color": "#eef3f7"
"background-color": MAP_BACKGROUND_COLORS.primary
}
}
];
+2 -1
View File
@@ -1,8 +1,9 @@
import type { Map as MapLibreMap } from "maplibre-gl";
import type { WaterNetworkSourceId } from "./map/sources";
export type DetailFeature = {
id: string;
layer: "pipes" | "junctions";
layer: WaterNetworkSourceId;
title: string;
subtitle: string;
properties: Record<string, unknown>;