From 513c403017dcd0f970f67a6e313998b581bfeb27 Mon Sep 17 00:00:00 2001 From: Huarch Date: Fri, 10 Jul 2026 18:45:43 +0800 Subject: [PATCH] feat(map): add five-source drainage styling --- .../components/feature-insight-panel.tsx | 50 +- .../workbench/hooks/use-map-interactions.ts | 94 +++- features/workbench/hooks/use-workbench-map.ts | 21 +- features/workbench/map-workbench-page.tsx | 14 +- features/workbench/map/camera.test.ts | 12 +- features/workbench/map/camera.ts | 11 +- features/workbench/map/color-contrast.test.ts | 79 +++ features/workbench/map/color-contrast.ts | 62 +++ .../workbench/map/feature-adapter.test.ts | 63 +++ features/workbench/map/feature-adapter.ts | 63 ++- features/workbench/map/layers.test.ts | 162 ++++++ features/workbench/map/layers.ts | 493 +++++++++++++++--- features/workbench/map/map-colors.ts | 31 ++ .../workbench/map/map-control-config.test.ts | 54 ++ features/workbench/map/map-control-config.ts | 74 ++- features/workbench/map/map-layer-visuals.ts | 106 ++++ features/workbench/map/measurement-layers.ts | 4 +- features/workbench/map/sources.ts | 89 +++- features/workbench/types.ts | 3 +- 19 files changed, 1303 insertions(+), 182 deletions(-) create mode 100644 features/workbench/map/color-contrast.test.ts create mode 100644 features/workbench/map/color-contrast.ts create mode 100644 features/workbench/map/feature-adapter.test.ts create mode 100644 features/workbench/map/layers.test.ts create mode 100644 features/workbench/map/map-colors.ts create mode 100644 features/workbench/map/map-control-config.test.ts create mode 100644 features/workbench/map/map-layer-visuals.ts diff --git a/features/workbench/components/feature-insight-panel.tsx b/features/workbench/components/feature-insight-panel.tsx index 8ee1720..bc28f75 100644 --- a/features/workbench/components/feature-insight-panel.tsx +++ b/features/workbench/components/feature-insight-panel.tsx @@ -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(() => { diff --git a/features/workbench/hooks/use-map-interactions.ts b/features/workbench/hooks/use-map-interactions.ts index f24e081..02496e8 100644 --- a/features/workbench/hooks/use-map-interactions.ts +++ b/features/workbench/hooks/use-map-interactions.ts @@ -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 = { + 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 = { + 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; 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]); } diff --git a/features/workbench/hooks/use-workbench-map.ts b/features/workbench/hooks/use-workbench-map.ts index 242f4b3..35523d2 100644 --- a/features/workbench/hooks/use-workbench-map.ts +++ b/features/workbench/hooks/use-workbench-map.ts @@ -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 = { const SOURCE_GROUP_BY_ID: Record = { "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(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") { diff --git a/features/workbench/map-workbench-page.tsx b/features/workbench/map-workbench-page.tsx index 4dcc2df..0a708a2 100644 --- a/features/workbench/map-workbench-page.tsx +++ b/features/workbench/map-workbench-page.tsx @@ -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"); } diff --git a/features/workbench/map/camera.test.ts b/features/workbench/map/camera.test.ts index e3a9988..6edbeec 100644 --- a/features/workbench/map/camera.test.ts +++ b/features/workbench/map/camera.test.ts @@ -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"); }); }); diff --git a/features/workbench/map/camera.ts b/features/workbench/map/camera.ts index b9d058c..e73bcac 100644 --- a/features/workbench/map/camera.ts +++ b/features/workbench/map/camera.ts @@ -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 + } ); } diff --git a/features/workbench/map/color-contrast.test.ts b/features/workbench/map/color-contrast.test.ts new file mode 100644 index 0000000..75fef7f --- /dev/null +++ b/features/workbench/map/color-contrast.test.ts @@ -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); + }); +}); diff --git a/features/workbench/map/color-contrast.ts b/features/workbench/map/color-contrast.ts new file mode 100644 index 0000000..474e373 --- /dev/null +++ b/features/workbench/map/color-contrast.ts @@ -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(); +} diff --git a/features/workbench/map/feature-adapter.test.ts b/features/workbench/map/feature-adapter.test.ts new file mode 100644 index 0000000..75e286b --- /dev/null +++ b/features/workbench/map/feature-adapter.test.ts @@ -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" + ); + }); +}); diff --git a/features/workbench/map/feature-adapter.ts b/features/workbench/map/feature-adapter.ts index 982add1..8807425 100644 --- a/features/workbench/map/feature-adapter.ts +++ b/features/workbench/map/feature-adapter.ts @@ -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 +) { + 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` + }; + } +} diff --git a/features/workbench/map/layers.test.ts b/features/workbench/map/layers.test.ts new file mode 100644 index 0000000..c9c5362 --- /dev/null +++ b/features/workbench/map/layers.test.ts @@ -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 | 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 | 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); + }); + }); +}); diff --git a/features/workbench/map/layers.ts b/features/workbench/map/layers.ts index 4a5cb06..903744b 100644 --- a/features/workbench/map/layers.ts +++ b/features/workbench/map/layers.ts @@ -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 + } } ]; diff --git a/features/workbench/map/map-colors.ts b/features/workbench/map/map-colors.ts new file mode 100644 index 0000000..541ecef --- /dev/null +++ b/features/workbench/map/map-colors.ts @@ -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"; diff --git a/features/workbench/map/map-control-config.test.ts b/features/workbench/map/map-control-config.test.ts new file mode 100644 index 0000000..02ac4c1 --- /dev/null +++ b/features/workbench/map/map-control-config.test.ts @@ -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; + + 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" + ]); + }); +}); diff --git a/features/workbench/map/map-control-config.ts b/features/workbench/map/map-control-config.ts index 228e32c..d696d7a 100644 --- a/features/workbench/map/map-control-config.ts +++ b/features/workbench/map/map-control-config.ts @@ -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 = { - 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 = { }; export const INITIAL_LAYER_VISIBILITY: Record = { - pipes: true, + conduits: true, junctions: true, + orifices: true, + outfalls: true, + pumps: true, simulation: false }; +const SOURCE_CONTROL_LABELS: Record = { + 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): 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, + 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); } diff --git a/features/workbench/map/map-layer-visuals.ts b/features/workbench/map/map-layer-visuals.ts new file mode 100644 index 0000000..cab86e8 --- /dev/null +++ b/features/workbench/map/map-layer-visuals.ts @@ -0,0 +1,106 @@ +import type { StyleSpecification } from "maplibre-gl"; + +type LayerSpecification = StyleSpecification["layers"][number]; +type LineLayerSpecification = Extract; +type CircleLayerSpecification = Extract; +type LineWidth = NonNullable["line-width"]; +type CircleRadius = NonNullable["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; diff --git a/features/workbench/map/measurement-layers.ts b/features/workbench/map/measurement-layers.ts index 2fad87b..dfa4f6f 100644 --- a/features/workbench/map/measurement-layers.ts +++ b/features/workbench/map/measurement-layers.ts @@ -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: { diff --git a/features/workbench/map/sources.ts b/features/workbench/map/sources.ts index e7c7420..c31a622 100644 --- a/features/workbench/map/sources.ts +++ b/features/workbench/map/sources.ts @@ -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 } } ]; diff --git a/features/workbench/types.ts b/features/workbench/types.ts index b6e5539..e461b73 100644 --- a/features/workbench/types.ts +++ b/features/workbench/types.ts @@ -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;