feat(map): add five-source drainage styling
This commit is contained in:
@@ -19,21 +19,43 @@ export function FeatureInsightPanel({ feature, onClose }: FeatureInsightPanelPro
|
||||
return [];
|
||||
}
|
||||
|
||||
if (feature.layer === "pipes") {
|
||||
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("elevation", feature.properties.elevation)],
|
||||
["需水量", formatFeaturePropertyValue("demand", feature.properties.demand)],
|
||||
["对象类型", "管网节点"],
|
||||
["最大深度", 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)],
|
||||
["状态", "在线"]
|
||||
];
|
||||
}
|
||||
}, [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";
|
||||
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]);
|
||||
}
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
@@ -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"
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
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: [
|
||||
"https://geoserver.waternetwork.cn/geoserver/gwc/service/wmts/rest/tjwater:geo_pipes_mat/line/WebMercatorQuad/{z}/{y}/{x}?format=application/vnd.mapbox-vector-tile"
|
||||
`${GEOSERVER_WMTS_ROOT}/lingang:${sourceLayer}/${style}/WebMercatorQuad/{z}/{y}/{x}?format=application/vnd.mapbox-vector-tile`
|
||||
],
|
||||
bounds,
|
||||
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
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -42,7 +83,7 @@ export function createBaseStyle(mapboxToken?: string): StyleSpecification {
|
||||
id: "background",
|
||||
type: "background",
|
||||
paint: {
|
||||
"background-color": "#eef3f7"
|
||||
"background-color": MAP_BACKGROUND_COLORS.primary
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
@@ -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>;
|
||||
|
||||
Reference in New Issue
Block a user