diff --git a/.env.example b/.env.example index 2d2aabc..d127786 100644 --- a/.env.example +++ b/.env.example @@ -1,2 +1,3 @@ NEXT_PUBLIC_MAPBOX_ACCESS_TOKEN=your_mapbox_access_token_here NEXT_PUBLIC_AGENT_API_BASE_URL=http://127.0.0.1:8787 +NEXT_PUBLIC_MAP_URL=https://geoserver.waternetwork.cn/geoserver diff --git a/MAP_STYLE_GUIDE.md b/MAP_STYLE_GUIDE.md new file mode 100644 index 0000000..eefbf9c --- /dev/null +++ b/MAP_STYLE_GUIDE.md @@ -0,0 +1,63 @@ +# 排水 WebGIS 地图设计规范 + +## 设计原则 + +地图采用克制的浅色工程制图语言,阅读顺序是资产身份、业务状态、交互状态。颜色不单独承担分类,线型、点型、描边和填充共同构成几何签名。 + +低缩放优先呈现连续网络拓扑,放大后逐步恢复设施细节。默认浅色底图是完整质量基准,影像底图依靠白色 casing 保证基本可读,不维护第二套主题。 + +## 颜色系统 + +运行时唯一来源是 `features/workbench/map/map-colors.ts` 的 `MAP_STYLE_TOKENS`。业务图层、模拟、工具、图例和测试不得建立平行颜色常量。 + +| Token | HEX | OKLCH | 用途 | +| --- | --- | --- | --- | +| `asset.conduit` | `#0F766E` | `oklch(51.1% 0.086 186.4)` | 管渠 | +| `asset.junction` | `#0369A1` | `oklch(50.0% 0.119 242.7)` | 检查井 | +| `asset.orifice` | `#475569` | `oklch(44.6% 0.037 257.3)` | 孔口 | +| `asset.pump` | `#155E75` | `oklch(45.0% 0.077 224.3)` | 泵 | +| `asset.outfall` | `#334155` | `oklch(37.2% 0.039 257.3)` | 排放口 | +| `state.selected` | `#2563EB` | `oklch(54.6% 0.215 262.9)` | 所有选中态 | +| `state.inactive` | `#64748B` | `oklch(55.4% 0.041 257.4)` | Closed、Off、Inactive | +| `state.risk` | `#C2410C` | `oklch(55.3% 0.174 38.4)` | 风险与预警 | +| `state.incident` | `#B91C1C` | `oklch(50.5% 0.190 27.5)` | 事故与爆管 | +| `state.success` | `#15803D` | `oklch(52.7% 0.137 150.1)` | 改善与成功 | +| `state.agent` | `#6D28D9` | `oklch(49.1% 0.241 292.6)` | Agent 推断 | +| `tool.geometry` | `#334155` | `oklch(37.2% 0.039 257.3)` | 绘制与测量 | + +红色只表示事故,橙色只表示风险,绿色只表示成功,紫色只表示 Agent 推断。普通 core opacity 为 `0.9`,关键轮廓和 selected 为 `1`,面填充限定在 `0.10` 至 `0.18`。 + +## 资产与缩放配方 + +| 资产 | 几何签名 | 缩放行为 | +| --- | --- | --- | +| 管渠 | 白色 casing、青绿色实体 core,管径驱动高缩放线宽 | z9–z13 core `1.1→2px`,casing `1.7→2.8px`;z17 起叠加管径 | +| 检查井 | 蓝色实心圆、白色 halo | z12–z14 仅 `0.6→1px` 弱化小点;halo opacity 为 0;z14–z15 渐显;z15 后恢复完整点型 | +| 孔口 | 白色 casing、板岩色虚线 | z11 起显示,dash 为 `[4, 2]`,避免低缩放退化为点阵 | +| 泵 | 白色 casing、深青色点划线 | z11 起显示,dash 为 `[6, 2, 1.5, 2]` | +| 排放口 | 白色中心、深板岩色圆环 | z11 起显示,随缩放增大 | + +Closed、Off、Inactive 只把 core 改为 inactive 灰,不改变几何身份。禁止用 glow 表示口径、流量或其他连续量。 + +## 状态与图层顺序 + +状态优先级是 `selected > hover > status > source`。五个业务 vector source 使用 `promoteId: "id"`,hover 与 selected 通过 feature-state 控制,opacity 使用 120ms 过渡且不脉冲。 + +- hover:白色增宽 casing 加 source/status core,selected 时 opacity 为 0。 +- selected:保留普通 core,外侧使用白色分隔、操作蓝外框和窄白外缘。 +- 模拟:风险范围使用 risk 橙 14% 填充和虚线边界,爆管使用 incident 红。 +- 工具:绘制与测量主体使用中性 tool 色,活动顶点和选中几何使用操作蓝。 +- Agent:推断标注只使用 agent 紫。 + +绘制顺序固定为底图、模拟填充、业务源、模拟事故和边界、绘制与测量、hover、selected、标签、hit layers。动态工具图层插入 `WORKBENCH_INTERACTION_BEFORE_ID` 之前。 + +## 禁用规则与验收 + +- 禁止在图层或图例中散落业务 HEX,禁止建立 token 的 source/selected/opacity 镜像常量。 +- 禁止多层半透明同色叠加模拟 selected,禁止用白色 halo 切断低缩放管网。 +- 关键图形在三个浅色背景上合成后至少达到 3:1,selected 蓝至少达到 4.5:1。 +- z11.7、z13、z15、z20 检查连续性和设施渐显;Light、Satellite 和无底图均需辨认资产。 +- 检查线与点的 hover、selected、交叉遮挡、状态清理、模拟、绘制、测量、图例和导出 PNG。 +- 在 1440×900 与 375px 检查地图可读性和控件遮挡,并用 protanopia、deuteranopia 模拟确认几何签名仍可区分 source。 + +设计依据:[MapLibre Style Specification](https://maplibre.org/maplibre-style-spec/)、[MapLibre feature-state expressions](https://maplibre.org/maplibre-style-spec/expressions/)、[ArcGIS Color Schemes](https://pro.arcgis.com/en/pro-app/3.6/help/mapping/layer-properties/work-with-color-schemes.htm)、[ColorBrewer](https://colorbrewer2.org/)、[CARTOColors](https://carto.com/carto-colors/index.html) 与 [WCAG 1.4.11](https://www.w3.org/WAI/WCAG21/understanding/non-text-contrast.html)。 diff --git a/app/api/agent-locate/route.ts b/app/api/agent-locate/route.ts index e09a87f..43b3ce1 100644 --- a/app/api/agent-locate/route.ts +++ b/app/api/agent-locate/route.ts @@ -1,11 +1,12 @@ import { NextResponse } from "next/server"; +import { MAP_URL } from "@/lib/config"; const TYPES = { junction: "drainage:geo_junctions", conduit: "drainage:geo_conduits", pipe: "drainage:geo_conduits", orifice: "drainage:geo_orifices", outfall: "drainage:geo_outfalls", pump: "drainage:geo_pumps" } as const; export async function POST(request: Request) { const body = await request.json().catch(() => null) as { ids?: unknown; featureType?: unknown } | null; if (!body || !Array.isArray(body.ids) || body.ids.length < 1 || body.ids.length > 100 || !body.ids.every((id) => typeof id === "string" && id.trim())) return NextResponse.json({ code: "INVALID_REQUEST" }, { status: 400 }); if (typeof body.featureType !== "string" || !(body.featureType in TYPES)) return NextResponse.json({ code: "UNSUPPORTED_FEATURE_TYPE" }, { status: 422 }); const ids = body.ids.map((id) => String(id).trim()); const cql = `id IN (${ids.map((id) => `'${id.replaceAll("'", "''")}'`).join(",")})`; - const url = new URL("https://geoserver.waternetwork.cn/geoserver/drainage/ows"); url.search = new URLSearchParams({ service: "WFS", version: "2.0.0", request: "GetFeature", typeNames: TYPES[body.featureType as keyof typeof TYPES], outputFormat: "application/json", srsName: "EPSG:4326", count: "100", cql_filter: cql }).toString(); + const url = new URL(`${MAP_URL}/drainage/ows`); url.search = new URLSearchParams({ service: "WFS", version: "2.0.0", request: "GetFeature", typeNames: TYPES[body.featureType as keyof typeof TYPES], outputFormat: "application/json", srsName: "EPSG:4326", count: "100", cql_filter: cql }).toString(); try { const response = await fetch(url, { signal: AbortSignal.timeout(8_000), cache: "no-store" }); const data = await response.json(); if (!response.ok || !data || data.type !== "FeatureCollection" || !Array.isArray(data.features)) throw new Error(); return NextResponse.json({ type: "FeatureCollection", features: data.features.slice(0, 100) }); } catch { return NextResponse.json({ code: "WFS_UNAVAILABLE" }, { status: 502 }); } } diff --git a/features/map/core/components/legend.tsx b/features/map/core/components/legend.tsx index 00d38cb..ca74850 100644 --- a/features/map/core/components/legend.tsx +++ b/features/map/core/components/legend.tsx @@ -1,4 +1,5 @@ import type { LucideIcon } from "lucide-react"; +import Image from "next/image"; import { cn } from "@/lib/utils"; import { MAP_ICON_CELL_RADIUS_CLASS_NAME, @@ -14,7 +15,8 @@ export type MapLegendItem = { description?: string; color: string; icon?: LucideIcon; - shape?: "line" | "dot" | "square"; + imageSrc?: string; + shape?: "line" | "dashed-line" | "dash-dot-line" | "dot" | "ring" | "square"; }; type MapLegendProps = { @@ -59,7 +61,7 @@ export function MapLegendList({ items, className = "", showStatusDot = false }: className={cn("flex min-h-10 items-center gap-2.5 px-2.5 py-2 text-sm text-slate-700", MAP_READABLE_RADIUS_CLASS_NAME, MAP_READABLE_SURFACE_CLASS_NAME)} > {item.label} @@ -78,9 +80,21 @@ export function MapLegendSwatch({ color, shape = "dot" }: Pick; } + if (shape === "dashed-line" || shape === "dash-dot-line") { + return ( + + ); + } + if (shape === "square") { return ; } + if (shape === "ring") { + return ; + } + return ; } diff --git a/features/workbench/components/feature-insight-panel.tsx b/features/workbench/components/feature-insight-panel.tsx index bc28f75..89286be 100644 --- a/features/workbench/components/feature-insight-panel.tsx +++ b/features/workbench/components/feature-insight-panel.tsx @@ -1,7 +1,10 @@ "use client"; import { Target, TriangleAlert, X } from "lucide-react"; +import Image from "next/image"; import { useMemo } from "react"; +import { cn } from "@/lib/utils"; +import { SCADA_ICON_PATHS } from "../map/scada"; import type { DetailFeature } from "../types"; import { formatFeaturePropertyValue, @@ -55,6 +58,13 @@ export function FeatureInsightPanel({ feature, onClose }: FeatureInsightPanelPro ["阶段数据", formatFeaturePropertyValue("stage_data", feature.properties.stage_data)], ["状态", "在线"] ]; + case "scada": + return [ + ["设备类型", formatFeaturePropertyValue("device_type_name", feature.properties.device_type_name)], + ["运行状态", formatFeaturePropertyValue("active", feature.properties.active)], + ["关联检查井", formatFeaturePropertyValue("junction_id", feature.properties.junction_id)], + ["同步时间", formatFeaturePropertyValue("synced_at", feature.properties.synced_at)] + ]; } }, [feature]); @@ -103,6 +113,19 @@ export function FeatureInsightPanel({ feature, onClose }: FeatureInsightPanelPro ))} + {feature.layer === "scada" ? ( +
+
+ +
+

{formatFeaturePropertyValue("point_name", feature.properties.point_name)}

+

{formatFeaturePropertyValue("device_external_id", feature.properties.device_external_id)}

+
+
+

当前点位由管网密度聚类生成,仅用于空间展示。图层暂未提供实时测值或历史曲线。

+
+ ) : null} +
{copied ? "编号已复制" : ""} @@ -210,3 +195,101 @@ export function FeaturePopover({ feature, onClose }: FeaturePopoverProps) { ); } + +type ScadaFeaturePopoverProps = { + feature: DetailFeature; + copied: boolean; + canCopy: boolean; + onCopy: () => void; + onClose: () => void; +}; + +function ScadaFeaturePopover({ feature, copied, canCopy, onCopy, onClose }: ScadaFeaturePopoverProps) { + const properties = feature.properties; + const presentation = getScadaPresentation(properties.device_type_id); + const active = properties.active === true || String(properties.active).toLowerCase() === "true"; + const pointId = formatFeaturePropertyValue("point_external_id", properties.point_external_id ?? feature.id); + const attributes = [ + ["设备编号", formatFeaturePropertyValue("device_external_id", properties.device_external_id)], + ["关联检查井", formatFeaturePropertyValue("junction_id", properties.junction_id)], + ["同步时间", formatFeaturePropertyValue("synced_at", properties.synced_at)] + ]; + + return ( + + ); +} + +function getScadaPresentation(rawTypeId: unknown) { + const typeId = Number(rawTypeId); + if (typeId === 72) return { iconPath: SCADA_ICON_PATHS.waterQuality, headerClassName: "bg-teal-50", labelClassName: "text-teal-700" }; + if (typeId === 58) return { iconPath: SCADA_ICON_PATHS.radarLevel, headerClassName: "bg-blue-50", labelClassName: "text-blue-700" }; + if (typeId === 45) return { iconPath: SCADA_ICON_PATHS.ultrasonicFlow, headerClassName: "bg-orange-50", labelClassName: "text-orange-700" }; + return { iconPath: SCADA_ICON_PATHS.unknown, headerClassName: "bg-slate-100", labelClassName: "text-slate-700" }; +} diff --git a/features/workbench/hooks/use-map-interactions.test.ts b/features/workbench/hooks/use-map-interactions.test.ts new file mode 100644 index 0000000..8900565 --- /dev/null +++ b/features/workbench/hooks/use-map-interactions.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it, vi } from "vitest"; +import { + clearMapFeatureInteractionState, + setMapFeatureInteractionState, + toMapFeatureReference +} from "./use-map-interactions"; + +describe("map feature interaction state", () => { + const feature = { source: "junctions", sourceLayer: "geo_junctions_mat", id: "junction-7" } as const; + + it("maps selected business features to promoted vector feature references", () => { + expect(toMapFeatureReference({ id: "junction-7", layer: "junctions" })).toEqual(feature); + expect(toMapFeatureReference(null)).toBeNull(); + }); + + it("sets hover and selected through feature-state", () => { + const map = { setFeatureState: vi.fn(), removeFeatureState: vi.fn() }; + setMapFeatureInteractionState(map, feature, { selected: true, hovered: false }); + expect(map.setFeatureState).toHaveBeenCalledWith( + { source: "junctions", sourceLayer: "geo_junctions_mat", id: "junction-7" }, + { selected: true, hovered: false } + ); + }); + + it("clears only the requested state key", () => { + const map = { setFeatureState: vi.fn(), removeFeatureState: vi.fn() }; + clearMapFeatureInteractionState(map, feature, "selected"); + expect(map.removeFeatureState).toHaveBeenCalledWith( + { source: "junctions", sourceLayer: "geo_junctions_mat", id: "junction-7" }, + "selected" + ); + }); +}); diff --git a/features/workbench/hooks/use-map-interactions.ts b/features/workbench/hooks/use-map-interactions.ts index 02496e8..8b5baec 100644 --- a/features/workbench/hooks/use-map-interactions.ts +++ b/features/workbench/hooks/use-map-interactions.ts @@ -10,23 +10,48 @@ import { toDetailFeature } from "../map/feature-adapter"; import { INTERACTIVE_HIT_LAYER_IDS } from "../map/layers"; -import type { WaterNetworkSourceId } from "../map/sources"; +import { SOURCE_LAYERS, type WaterNetworkSourceId } from "../map/sources"; +import { SCADA_HIT_LAYER_ID } from "../map/scada"; -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"] +export type MapFeatureInteractionState = { + source: WaterNetworkSourceId; + sourceLayer: string; + id: string; + hovered: boolean; + selected: boolean; }; -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 FeatureStateMap = Pick; +type FeatureReference = Pick; + +export function toMapFeatureReference( + feature: Pick | null +): FeatureReference | null { + if (!feature?.id) return null; + return { source: feature.layer, sourceLayer: SOURCE_LAYERS[feature.layer], id: feature.id }; +} + +export function setMapFeatureInteractionState( + map: FeatureStateMap, + feature: FeatureReference, + state: Partial> +) { + map.setFeatureState( + { source: feature.source, sourceLayer: feature.sourceLayer, id: feature.id }, + state + ); +} + +export function clearMapFeatureInteractionState( + map: FeatureStateMap, + feature: FeatureReference, + key?: "hovered" | "selected" +) { + map.removeFeatureState( + { source: feature.source, sourceLayer: feature.sourceLayer, id: feature.id }, + key + ); +} type UseMapInteractionsOptions = { mapRef: RefObject; @@ -43,88 +68,65 @@ export function useMapInteractions({ }: UseMapInteractionsOptions) { useEffect(() => { const map = mapRef.current; - if (!mapReady || !map) { - return; - } + if (!mapReady || !map) return; - const activeMap = map; - let hoveredFeature: { id: string; sourceId: WaterNetworkSourceId } | null = null; + let hoveredFeature: FeatureReference | null = null; - function clearHoveredFeature() { - if (!hoveredFeature) { - return; - } - - HOVER_LAYER_IDS[hoveredFeature.sourceId].forEach((layerId) => { - activeMap.setFilter(layerId, ["==", ["get", "id"], ""]); - }); + const clearHoveredFeature = () => { + if (!hoveredFeature) return; + clearMapFeatureInteractionState(map, hoveredFeature, "hovered"); hoveredFeature = null; - } + }; - function handleMouseMove(event: MapLayerMouseEvent) { + const handleMouseMove = (event: MapLayerMouseEvent) => { const feature = event.features?.[0]; - if (!feature) { - return; - } + const source = feature && getWaterNetworkSourceId(feature); + const id = feature && getFeatureId(feature); + if (!source || !id) return; - activeMap.getCanvas().style.cursor = "pointer"; - const id = getFeatureId(feature); - const sourceId = getWaterNetworkSourceId(feature); - if (!id || !sourceId) { + const next = { source, sourceLayer: SOURCE_LAYERS[source], id }; + if (selectedFeature?.id === id && selectedFeature.layer === source) { clearHoveredFeature(); return; } - - if (hoveredFeature?.id === id && hoveredFeature.sourceId === sourceId) { - return; - } + if (hoveredFeature?.id === id && hoveredFeature.source === source) return; clearHoveredFeature(); - HOVER_LAYER_IDS[sourceId].forEach((layerId) => { - activeMap.setFilter(layerId, ["==", ["get", "id"], id]); - }); - hoveredFeature = { id, sourceId }; - } + setMapFeatureInteractionState(map, next, { hovered: true }); + hoveredFeature = next; + map.getCanvas().style.cursor = "pointer"; + }; - function handleMouseLeave() { - activeMap.getCanvas().style.cursor = ""; + const handleMouseLeave = () => { clearHoveredFeature(); - } + map.getCanvas().style.cursor = ""; + }; - function handleClick(event: MapLayerMouseEvent) { + const handleClick = (event: MapLayerMouseEvent) => { const feature = event.features?.[0]; - if (feature) { - onSelectFeature(toDetailFeature(feature)); - } - } + if (feature) onSelectFeature(toDetailFeature(feature)); + }; - activeMap.on("mousemove", INTERACTIVE_HIT_LAYER_IDS, handleMouseMove); - activeMap.on("mouseleave", INTERACTIVE_HIT_LAYER_IDS, handleMouseLeave); - activeMap.on("click", INTERACTIVE_HIT_LAYER_IDS, handleClick); + const hitLayerIds = [SCADA_HIT_LAYER_ID, ...INTERACTIVE_HIT_LAYER_IDS]; + map.on("mousemove", hitLayerIds, handleMouseMove); + map.on("mouseleave", hitLayerIds, handleMouseLeave); + map.on("click", hitLayerIds, handleClick); return () => { - activeMap.off("mousemove", INTERACTIVE_HIT_LAYER_IDS, handleMouseMove); - activeMap.off("mouseleave", INTERACTIVE_HIT_LAYER_IDS, handleMouseLeave); - activeMap.off("click", INTERACTIVE_HIT_LAYER_IDS, handleClick); + clearHoveredFeature(); + map.off("mousemove", hitLayerIds, handleMouseMove); + map.off("mouseleave", hitLayerIds, handleMouseLeave); + map.off("click", hitLayerIds, handleClick); }; - }, [mapRef, mapReady, onSelectFeature]); + }, [mapRef, mapReady, onSelectFeature, selectedFeature]); 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]); - }); + if (!mapReady || !map) return; + const next = toMapFeatureReference(selectedFeature); + if (next) setMapFeatureInteractionState(map, next, { selected: true, hovered: false }); + return () => { + if (next) clearMapFeatureInteractionState(map, next, "selected"); + }; }, [mapReady, mapRef, selectedFeature]); } diff --git a/features/workbench/hooks/use-workbench-map.ts b/features/workbench/hooks/use-workbench-map.ts index 35523d2..b703b5b 100644 --- a/features/workbench/hooks/use-workbench-map.ts +++ b/features/workbench/hooks/use-workbench-map.ts @@ -14,9 +14,19 @@ import { fitNetworkBounds, getResponsiveWorkbenchPadding } from "../map/camera"; -import { waterNetworkLayers } from "../map/layers"; +import { + waterNetworkBusinessLayers, + waterNetworkHitLayers, + waterNetworkInteractionLayers +} from "../map/layers"; import { MAP_MAX_ZOOM } from "../map/map-layer-visuals"; import { setSimulationLayersVisibility } from "../map/simulation-layers"; +import { + registerScadaImages, + scadaFallbackLayers, + scadaLayers, + type ScadaImageRegistrationResult +} from "../map/scada"; import { createBaseStyle, createWaterNetworkSources, @@ -60,6 +70,7 @@ export type WorkbenchSourceStatus = { const SOURCE_STATUS_LABELS: Record = { "mapbox-base": "Mapbox 底图", "geoserver-mvt": "GeoServer MVT", + "scada-icons": "SCADA 图标", "annotation-source": "业务标注源" }; @@ -71,6 +82,7 @@ const SOURCE_GROUP_BY_ID: Record = { orifices: "geoserver-mvt", outfalls: "geoserver-mvt", pumps: "geoserver-mvt", + scada: "geoserver-mvt", [SIMULATION_SOURCE_IDS.impactArea]: "annotation-source", [SIMULATION_SOURCE_IDS.annotations]: "annotation-source" }; @@ -117,7 +129,7 @@ export function useWorkbenchMap({ window.__waterNetworkMap = map; - map.on("load", () => { + map.on("load", async () => { map.resize(); const sources = createWaterNetworkSources(); map.addSource("conduits", sources.conduits); @@ -125,15 +137,47 @@ export function useWorkbenchMap({ map.addSource("orifices", sources.orifices); map.addSource("outfalls", sources.outfalls); map.addSource("pumps", sources.pumps); + map.addSource("scada", sources.scada); map.addSource(SIMULATION_SOURCE_IDS.impactArea, simulationSources.impactArea); map.addSource(SIMULATION_SOURCE_IDS.annotations, simulationSources.annotations); - waterNetworkLayers.forEach((layer) => map.addLayer(layer)); - simulationAnnotationLayers.forEach((layer) => map.addLayer(layer)); - setSimulationLayersVisibility(map, impactVisibleRef.current); + simulationAnnotationLayers.filter((layer) => layer.id === "simulation-impact-fill").forEach((layer) => map.addLayer(layer)); + waterNetworkBusinessLayers.forEach((layer) => map.addLayer(layer)); + simulationAnnotationLayers.filter((layer) => layer.type !== "symbol" && layer.id !== "simulation-impact-fill").forEach((layer) => map.addLayer(layer)); + waterNetworkInteractionLayers.forEach((layer) => map.addLayer(layer)); + let scadaRegistration: ScadaImageRegistrationResult = { + status: "unavailable", + failedImageIds: [] + }; - setMapReady(true); - updateSourceStatus(setSourceStatuses, "annotation-source", "online", "业务标注源已就绪。"); - fitNetworkBounds(map, WATER_NETWORK_GLOBAL_VIEW); + try { + scadaRegistration = await registerScadaImages(map); + } finally { + try { + const presentationLayers = scadaRegistration.status === "unavailable" + ? scadaFallbackLayers + : scadaLayers; + presentationLayers.slice(0, -1).forEach((layer) => map.addLayer(layer)); + simulationAnnotationLayers.filter((layer) => layer.type === "symbol").forEach((layer) => map.addLayer(layer)); + waterNetworkHitLayers.forEach((layer) => map.addLayer(layer)); + map.addLayer(presentationLayers[presentationLayers.length - 1]); + } finally { + setSimulationLayersVisibility(map, impactVisibleRef.current); + setMapReady(true); + updateSourceStatus(setSourceStatuses, "annotation-source", "online", "业务标注源已就绪。"); + fitNetworkBounds(map, WATER_NETWORK_GLOBAL_VIEW); + } + } + + if (scadaRegistration.status !== "ready") { + updateSourceStatus( + setSourceStatuses, + "scada-icons", + "degraded", + scadaRegistration.status === "degraded" + ? "部分 SCADA 分类图标加载失败,已使用通用图标。" + : "SCADA 图标加载失败,已使用通用点位显示。" + ); + } }); const resizeMap = () => map.resize(); diff --git a/features/workbench/map/annotation-layers.ts b/features/workbench/map/annotation-layers.ts index 6bf83d0..7e7d5fc 100644 --- a/features/workbench/map/annotation-layers.ts +++ b/features/workbench/map/annotation-layers.ts @@ -1,5 +1,6 @@ import type { GeoJSONSourceSpecification, StyleSpecification } from "maplibre-gl"; import { annotationPoints, impactAreaPolygon } from "../data/workbench-simulation"; +import { MAP_STYLE_TOKENS } from "./map-colors"; export const SIMULATION_SOURCE_IDS = { impactArea: "simulation-impact-area", @@ -23,8 +24,8 @@ export const simulationAnnotationLayers: StyleSpecification["layers"] = [ type: "fill", source: SIMULATION_SOURCE_IDS.impactArea, paint: { - "fill-color": "#ef4444", - "fill-opacity": 0.18 + "fill-color": MAP_STYLE_TOKENS.state.risk, + "fill-opacity": MAP_STYLE_TOKENS.opacity.area } }, { @@ -32,7 +33,7 @@ export const simulationAnnotationLayers: StyleSpecification["layers"] = [ type: "line", source: SIMULATION_SOURCE_IDS.impactArea, paint: { - "line-color": "#ef4444", + "line-color": MAP_STYLE_TOKENS.state.risk, "line-width": 1.5, "line-dasharray": [2, 2], "line-opacity": 0.72 @@ -44,9 +45,9 @@ export const simulationAnnotationLayers: StyleSpecification["layers"] = [ source: SIMULATION_SOURCE_IDS.annotations, filter: ["==", ["get", "kind"], "burst"], paint: { - "circle-color": "#fef2f2", + "circle-color": MAP_STYLE_TOKENS.canvas.casing, "circle-radius": 18, - "circle-stroke-color": "#ef4444", + "circle-stroke-color": MAP_STYLE_TOKENS.state.incident, "circle-stroke-width": 3, "circle-opacity": 0.88 } @@ -57,9 +58,9 @@ export const simulationAnnotationLayers: StyleSpecification["layers"] = [ source: SIMULATION_SOURCE_IDS.annotations, filter: ["==", ["get", "kind"], "burst"], paint: { - "circle-color": "#ef4444", + "circle-color": MAP_STYLE_TOKENS.state.incident, "circle-radius": 7, - "circle-stroke-color": "#ffffff", + "circle-stroke-color": MAP_STYLE_TOKENS.canvas.casing, "circle-stroke-width": 3 } }, @@ -75,8 +76,8 @@ export const simulationAnnotationLayers: StyleSpecification["layers"] = [ "text-allow-overlap": true }, paint: { - "text-color": "#ffffff", - "text-halo-color": "#2563eb", + "text-color": MAP_STYLE_TOKENS.label.inverse, + "text-halo-color": MAP_STYLE_TOKENS.state.agent, "text-halo-width": 9 } }, @@ -93,8 +94,8 @@ export const simulationAnnotationLayers: StyleSpecification["layers"] = [ "text-allow-overlap": true }, paint: { - "text-color": "#dc2626", - "text-halo-color": "#ffffff", + "text-color": MAP_STYLE_TOKENS.state.risk, + "text-halo-color": MAP_STYLE_TOKENS.canvas.casing, "text-halo-width": 4 } }, @@ -110,8 +111,8 @@ export const simulationAnnotationLayers: StyleSpecification["layers"] = [ "text-allow-overlap": true }, paint: { - "text-color": "#334155", - "text-halo-color": "#ffffff", + "text-color": MAP_STYLE_TOKENS.label.secondary, + "text-halo-color": MAP_STYLE_TOKENS.canvas.casing, "text-halo-width": 3 } }, @@ -129,8 +130,8 @@ export const simulationAnnotationLayers: StyleSpecification["layers"] = [ "text-allow-overlap": true }, paint: { - "text-color": "#0f172a", - "text-halo-color": "#ffffff", + "text-color": MAP_STYLE_TOKENS.label.primary, + "text-halo-color": MAP_STYLE_TOKENS.canvas.casing, "text-halo-width": 5 } } diff --git a/features/workbench/map/color-contrast.test.ts b/features/workbench/map/color-contrast.test.ts index 75fef7f..6fcf931 100644 --- a/features/workbench/map/color-contrast.test.ts +++ b/features/workbench/map/color-contrast.test.ts @@ -5,18 +5,16 @@ import { getContrastRatio } from "./color-contrast"; import { - CLOSED_CONDUIT_COLOR, - DRAINAGE_SELECTED_OUTLINE_COLORS, - DRAINAGE_SOURCE_COLORS, - DRAINAGE_SOURCE_OPACITIES, - MAP_BACKGROUND_COLORS + MAP_STYLE_TOKENS } 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); +const sourceColors = Object.values(MAP_STYLE_TOKENS.asset); +const mapBackgrounds = [ + MAP_STYLE_TOKENS.canvas.primary, + MAP_STYLE_TOKENS.canvas.secondary, + MAP_STYLE_TOKENS.canvas.secondaryAlt +]; describe("map color contrast", () => { it("calculates WCAG contrast and alpha composites", () => { @@ -32,12 +30,12 @@ describe("map color contrast", () => { }); it("keeps rendered source colors above 3:1 on every map background", () => { - drainageColorIds.forEach((sourceId) => { + sourceColors.forEach((sourceColor) => { mapBackgrounds.forEach((background) => { const renderedColor = compositeHexColor( - DRAINAGE_SOURCE_COLORS[sourceId], + sourceColor, background, - DRAINAGE_SOURCE_OPACITIES[sourceId] + MAP_STYLE_TOKENS.opacity.core ); expect( @@ -48,14 +46,14 @@ describe("map color contrast", () => { }); }); - it("keeps selected outlines above 3:1 on every map background", () => { - drainageColorIds.forEach((sourceId) => { + it("keeps selected outlines above 4.5:1 on every map background", () => { + sourceColors.forEach(() => { mapBackgrounds.forEach((background) => { - const selectedColor = DRAINAGE_SELECTED_OUTLINE_COLORS[sourceId]; + const selectedColor = MAP_STYLE_TOKENS.state.selected; expect( getContrastRatio(selectedColor, background), createCoolorsContrastUrl(selectedColor, background) - ).toBeGreaterThanOrEqual(3); + ).toBeGreaterThanOrEqual(4.5); }); }); }); @@ -63,9 +61,9 @@ describe("map color contrast", () => { it("keeps closed conduits above 3:1 and applies the primary map background", () => { mapBackgrounds.forEach((background) => { const renderedColor = compositeHexColor( - CLOSED_CONDUIT_COLOR, + MAP_STYLE_TOKENS.state.inactive, background, - DRAINAGE_SOURCE_OPACITIES.conduits + MAP_STYLE_TOKENS.opacity.core ); expect(getContrastRatio(renderedColor, background)).toBeGreaterThanOrEqual(3); }); @@ -74,6 +72,6 @@ describe("map color contrast", () => { 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); + expect(backgroundLayer.paint?.["background-color"]).toBe(MAP_STYLE_TOKENS.canvas.primary); }); }); diff --git a/features/workbench/map/drawing-layers.ts b/features/workbench/map/drawing-layers.ts index 5f7a27b..9ca935e 100644 --- a/features/workbench/map/drawing-layers.ts +++ b/features/workbench/map/drawing-layers.ts @@ -6,6 +6,8 @@ import type { } from "maplibre-gl"; import type { FeatureCollection, Geometry, LineString, Point, Polygon } from "geojson"; import { createCircleCoordinates } from "./geo"; +import { MAP_STYLE_TOKENS } from "./map-colors"; +import { WORKBENCH_INTERACTION_BEFORE_ID } from "./layers"; export const DRAWING_SOURCE_IDS = { features: "workbench-drawing-features", @@ -54,7 +56,7 @@ export const drawingLayers: StyleSpecification["layers"] = [ source: DRAWING_SOURCE_IDS.features, filter: ["==", ["geometry-type"], "Polygon"], paint: { - "fill-color": "#2563eb", + "fill-color": MAP_STYLE_TOKENS.tool.geometry, "fill-opacity": 0.16 } }, @@ -64,7 +66,7 @@ export const drawingLayers: StyleSpecification["layers"] = [ source: DRAWING_SOURCE_IDS.features, filter: ["==", ["geometry-type"], "Polygon"], paint: { - "line-color": "#2563eb", + "line-color": MAP_STYLE_TOKENS.tool.geometry, "line-width": 2, "line-opacity": 0.82 } @@ -75,7 +77,7 @@ export const drawingLayers: StyleSpecification["layers"] = [ source: DRAWING_SOURCE_IDS.features, filter: ["==", ["geometry-type"], "LineString"], paint: { - "line-color": "#2563eb", + "line-color": MAP_STYLE_TOKENS.tool.geometry, "line-width": 3, "line-opacity": 0.88 } @@ -86,9 +88,9 @@ export const drawingLayers: StyleSpecification["layers"] = [ source: DRAWING_SOURCE_IDS.features, filter: ["==", ["geometry-type"], "Point"], paint: { - "circle-color": "#2563eb", + "circle-color": MAP_STYLE_TOKENS.tool.geometry, "circle-radius": 6, - "circle-stroke-color": "#ffffff", + "circle-stroke-color": MAP_STYLE_TOKENS.canvas.casing, "circle-stroke-width": 2 } }, @@ -98,7 +100,7 @@ export const drawingLayers: StyleSpecification["layers"] = [ source: DRAWING_SOURCE_IDS.features, filter: ["all", ["==", ["geometry-type"], "Polygon"], ["==", ["get", "id"], ""]], paint: { - "line-color": "#ff7a45", + "line-color": MAP_STYLE_TOKENS.state.selected, "line-width": 4, "line-opacity": 0.86 } @@ -109,7 +111,7 @@ export const drawingLayers: StyleSpecification["layers"] = [ source: DRAWING_SOURCE_IDS.features, filter: ["all", ["==", ["geometry-type"], "LineString"], ["==", ["get", "id"], ""]], paint: { - "line-color": "#ff7a45", + "line-color": MAP_STYLE_TOKENS.state.selected, "line-width": 6, "line-opacity": 0.72 } @@ -120,10 +122,10 @@ export const drawingLayers: StyleSpecification["layers"] = [ source: DRAWING_SOURCE_IDS.features, filter: ["all", ["==", ["geometry-type"], "Point"], ["==", ["get", "id"], ""]], paint: { - "circle-color": "#ff7a45", + "circle-color": MAP_STYLE_TOKENS.state.selected, "circle-radius": 10, "circle-opacity": 0.2, - "circle-stroke-color": "#ff7a45", + "circle-stroke-color": MAP_STYLE_TOKENS.state.selected, "circle-stroke-width": 2 } }, @@ -133,7 +135,7 @@ export const drawingLayers: StyleSpecification["layers"] = [ source: DRAWING_SOURCE_IDS.draft, filter: ["==", ["geometry-type"], "Polygon"], paint: { - "fill-color": "#ff7a45", + "fill-color": MAP_STYLE_TOKENS.tool.geometry, "fill-opacity": 0.12 } }, @@ -143,7 +145,7 @@ export const drawingLayers: StyleSpecification["layers"] = [ source: DRAWING_SOURCE_IDS.draft, filter: ["any", ["==", ["geometry-type"], "LineString"], ["==", ["geometry-type"], "Polygon"]], paint: { - "line-color": "#ff7a45", + "line-color": MAP_STYLE_TOKENS.tool.geometry, "line-width": 2, "line-dasharray": [2, 2], "line-opacity": 0.9 @@ -155,9 +157,9 @@ export const drawingLayers: StyleSpecification["layers"] = [ source: DRAWING_SOURCE_IDS.draft, filter: ["==", ["geometry-type"], "Point"], paint: { - "circle-color": "#ff7a45", + "circle-color": MAP_STYLE_TOKENS.state.selected, "circle-radius": 4, - "circle-stroke-color": "#ffffff", + "circle-stroke-color": MAP_STYLE_TOKENS.canvas.casing, "circle-stroke-width": 1.5 } } @@ -167,7 +169,7 @@ export function ensureDrawingLayers(map: { getSource: (id: string) => unknown; addSource: (id: string, source: GeoJSONSourceSpecification) => void; getLayer: (id: string) => unknown; - addLayer: (layer: NonNullable[number]) => void; + addLayer: (layer: NonNullable[number], beforeId?: string) => void; }) { if (!map.getSource(DRAWING_SOURCE_IDS.features)) { map.addSource(DRAWING_SOURCE_IDS.features, drawingSources.features); @@ -179,7 +181,7 @@ export function ensureDrawingLayers(map: { drawingLayers.forEach((layer) => { if (!map.getLayer(layer.id)) { - map.addLayer(layer); + map.addLayer(layer, WORKBENCH_INTERACTION_BEFORE_ID); } }); } diff --git a/features/workbench/map/export-view.ts b/features/workbench/map/export-view.ts index aad745d..a4c1fbb 100644 --- a/features/workbench/map/export-view.ts +++ b/features/workbench/map/export-view.ts @@ -1,4 +1,5 @@ import maplibregl, { type Map as MapLibreMap, type StyleSpecification } from "maplibre-gl"; +import type { MapFeatureInteractionState } from "../hooks/use-map-interactions"; const MAX_EXPORT_DIMENSION = 4096; const EXPORT_IDLE_TIMEOUT_MS = 6000; @@ -7,9 +8,10 @@ export type ExportMapViewOptions = { scale?: number; targetLongEdge?: number; filename?: string; + selectedFeature?: Pick | null; }; -export async function exportMapViewImage(map: MapLibreMap, { scale, targetLongEdge, filename }: ExportMapViewOptions = {}) { +export async function exportMapViewImage(map: MapLibreMap, { scale, targetLongEdge, filename, selectedFeature }: ExportMapViewOptions = {}) { const canvas = map.getCanvas(); const sourceWidth = canvas.clientWidth || canvas.width; const sourceHeight = canvas.clientHeight || canvas.height; @@ -47,6 +49,12 @@ export async function exportMapViewImage(map: MapLibreMap, { scale, targetLongEd padding: 0, duration: 0 }); + if (selectedFeature) { + exportMap.setFeatureState( + { source: selectedFeature.source, sourceLayer: selectedFeature.sourceLayer, id: selectedFeature.id }, + { selected: true, hovered: false } + ); + } await waitForMapIdle(exportMap); const link = document.createElement("a"); diff --git a/features/workbench/map/feature-adapter.ts b/features/workbench/map/feature-adapter.ts index 8807425..e930dd4 100644 --- a/features/workbench/map/feature-adapter.ts +++ b/features/workbench/map/feature-adapter.ts @@ -64,5 +64,10 @@ function getFeaturePresentation( title: `排放口 ${displayId}`, subtitle: `类型 ${formatValue(properties.outfall_type)} · 井底高程 ${formatValue(properties.invert_elevation)} m` }; + case "scada": + return { + title: String(properties.point_name || `SCADA 测点 ${displayId}`), + subtitle: `${formatValue(properties.device_type_name)} · 设备 ${formatValue(properties.device_external_id)}` + }; } } diff --git a/features/workbench/map/layers.test.ts b/features/workbench/map/layers.test.ts index c9c5362..fe896db 100644 --- a/features/workbench/map/layers.test.ts +++ b/features/workbench/map/layers.test.ts @@ -3,10 +3,7 @@ import { INTERACTIVE_HIT_LAYER_IDS, waterNetworkLayers } from "./layers"; -import { - DRAINAGE_SELECTED_OUTLINE_COLORS, - DRAINAGE_SOURCE_COLORS -} from "./map-colors"; +import { MAP_STYLE_TOKENS } from "./map-colors"; import { DRAINAGE_LAYER_VISUALS, MAP_MAX_ZOOM } from "./map-layer-visuals"; describe("drainage network interaction layers", () => { @@ -56,61 +53,49 @@ describe("drainage network interaction layers", () => { 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(); + expect(layer && "filter" in layer ? layer.filter : undefined).toBeUndefined(); + const paint = layer?.paint as Record | undefined; + expect(JSON.stringify(paint)).toContain("feature-state"); + expect(JSON.stringify(paint)).toContain("selected"); }); }); - 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] + it("renders selected features as opaque hollow outlines", () => { + const expectedLineOutlines = [ + ["pipes-selected-outline", DRAINAGE_LAYER_VISUALS.conduits], + ["orifices-selected-outline", DRAINAGE_LAYER_VISUALS.orifices], + ["pumps-selected-outline", DRAINAGE_LAYER_VISUALS.pumps] ] as const; - expectedSelectedColors.forEach(([layerId, paintProperty, color]) => { + expectedLineOutlines.forEach(([layerId, visuals]) => { const layer = waterNetworkLayers.find((candidate) => candidate.id === layerId); const paint = layer?.paint as Record | undefined; - expect(paint?.[paintProperty]).toBe(color); + expect(paint?.["line-color"]).toBe(MAP_STYLE_TOKENS.state.selected); + expect(paint?.["line-gap-width"]).toBe(visuals.selectedWidth); + expect(paint?.["line-width"]).toBe(visuals.selectedBorderWidth); + expect(paint?.["line-opacity"]).toEqual(["case", ["boolean", ["feature-state", "selected"], false], 1, 0]); }); - 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`) - ); + const expectedPointOutlines = [ + "junctions-selected", + "outfalls-selected" + ] as const; + expectedPointOutlines.forEach((layerId) => { + const layer = waterNetworkLayers.find((candidate) => candidate.id === layerId); + const paint = layer?.paint as Record | undefined; + expect(paint?.["circle-color"]).toBe(MAP_STYLE_TOKENS.canvas.transparent); + expect(JSON.stringify(paint?.["circle-opacity"])).toContain("feature-state"); + expect(paint?.["circle-stroke-color"]).toBe(MAP_STYLE_TOKENS.state.selected); + expect(JSON.stringify(paint?.["circle-stroke-opacity"])).toContain("selected"); }); }); @@ -118,7 +103,7 @@ describe("drainage network interaction layers", () => { 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 + "interpolate", ["linear"], ["zoom"], 12, 0.6, 14, 1, 15, 1.5, 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 @@ -134,18 +119,39 @@ describe("drainage network interaction layers", () => { expect((DRAINAGE_LAYER_VISUALS.junctions.hitRadius as unknown[]).at(-1)).toBe(14); }); + it("prioritizes continuous conduits before full junction detail at low zoom", () => { + expect(DRAINAGE_LAYER_VISUALS.conduits.width).toEqual([ + "interpolate", ["linear"], ["zoom"], + 9, 1.1, + 13, 2, + 17, ["+", 2.5, ["/", ["coalesce", ["get", "diameter"], 100], 360]], + 22, ["+", 3.5, ["/", ["coalesce", ["get", "diameter"], 100], 300]], + 24, ["+", 4, ["/", ["coalesce", ["get", "diameter"], 100], 280]] + ]); + expect(DRAINAGE_LAYER_VISUALS.junctions.haloOpacity).toEqual([ + "interpolate", ["linear"], ["zoom"], 12, 0, 14, 0, 15, 1 + ]); + + const junctionHalo = waterNetworkLayers.find((layer) => layer.id === "junctions-halo"); + const orifices = waterNetworkLayers.find((layer) => layer.id === "orifices"); + const pumps = waterNetworkLayers.find((layer) => layer.id === "pumps"); + expect((junctionHalo?.paint as Record)?.["circle-opacity"]).toBe(DRAINAGE_LAYER_VISUALS.junctions.haloOpacity); + expect((orifices?.paint as Record)?.["line-dasharray"]).toEqual([4, 2]); + expect((pumps?.paint as Record)?.["line-dasharray"]).toEqual([6, 2, 1.5, 2]); + }); + 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], + ["pipes-selected-outline", "line-width", DRAINAGE_LAYER_VISUALS.conduits.selectedBorderWidth], ["junctions", "circle-radius", DRAINAGE_LAYER_VISUALS.junctions.radius], - ["junctions-selected-halo", "circle-radius", DRAINAGE_LAYER_VISUALS.junctions.selectedHaloRadius], + ["junctions-selected", "circle-radius", DRAINAGE_LAYER_VISUALS.junctions.selectedRadius], ["orifices", "line-width", DRAINAGE_LAYER_VISUALS.orifices.width], - ["orifices-selected", "line-width", DRAINAGE_LAYER_VISUALS.orifices.selectedWidth], + ["orifices-selected-outline", "line-width", DRAINAGE_LAYER_VISUALS.orifices.selectedBorderWidth], ["pumps", "line-width", DRAINAGE_LAYER_VISUALS.pumps.width], - ["pumps-selected", "line-width", DRAINAGE_LAYER_VISUALS.pumps.selectedWidth], + ["pumps-selected-outline", "line-width", DRAINAGE_LAYER_VISUALS.pumps.selectedBorderWidth], ["outfalls", "circle-radius", DRAINAGE_LAYER_VISUALS.outfalls.radius], - ["outfalls-selected-halo", "circle-radius", DRAINAGE_LAYER_VISUALS.outfalls.selectedHaloRadius] + ["outfalls-selected", "circle-radius", DRAINAGE_LAYER_VISUALS.outfalls.selectedRadius] ] as const; expectedGeometry.forEach(([layerId, paintProperty, visualToken]) => { diff --git a/features/workbench/map/layers.ts b/features/workbench/map/layers.ts index 903744b..c4475cd 100644 --- a/features/workbench/map/layers.ts +++ b/features/workbench/map/layers.ts @@ -1,493 +1,90 @@ -import type { StyleSpecification } from "maplibre-gl"; -import { - CLOSED_CONDUIT_COLOR, - DRAINAGE_SELECTED_OUTLINE_COLORS, - DRAINAGE_SOURCE_COLORS, - DRAINAGE_SOURCE_OPACITIES -} from "./map-colors"; +import type { ExpressionSpecification, StyleSpecification } from "maplibre-gl"; import { DRAINAGE_LAYER_VISUALS } from "./map-layer-visuals"; +import { MAP_STYLE_TOKENS } from "./map-colors"; 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" -]; + "conduits-hit", "junctions-hit", "orifices-hit", "pumps-hit", "outfalls-hit" +] as const; -export const waterNetworkLayers: StyleSpecification["layers"] = [ - { - id: "pipes-casing", - type: "line", - source: "conduits", - "source-layer": SOURCE_LAYERS.conduits, - minzoom: DRAINAGE_LAYER_VISUALS.conduits.minzoom, - paint: { - "line-color": "rgba(255,255,255,0.86)", - "line-width": DRAINAGE_LAYER_VISUALS.conduits.casingWidth, - "line-opacity": 0.9 - } - }, - { - id: "pipes-flow", - type: "line", - source: "conduits", - "source-layer": SOURCE_LAYERS.conduits, - minzoom: DRAINAGE_LAYER_VISUALS.conduits.minzoom, - paint: { - "line-color": [ - "case", - ["==", ["get", "status"], "Closed"], - CLOSED_CONDUIT_COLOR, - DRAINAGE_SOURCE_COLORS.conduits - ], - "line-width": DRAINAGE_LAYER_VISUALS.conduits.width, - "line-opacity": DRAINAGE_SOURCE_OPACITIES.conduits - } - }, - { - id: "pipes-risk-glow", - type: "line", - source: "conduits", - "source-layer": SOURCE_LAYERS.conduits, - minzoom: 12, - filter: [">=", ["coalesce", ["get", "diameter"], 0], 600], - paint: { - "line-color": DRAINAGE_SOURCE_COLORS.conduits, - "line-width": DRAINAGE_LAYER_VISUALS.conduits.riskGlowWidth, - "line-blur": 2.5, - "line-opacity": 0.22 - } - }, - { - id: "pipes-hover-outline", - type: "line", - source: "conduits", - "source-layer": SOURCE_LAYERS.conduits, - minzoom: DRAINAGE_LAYER_VISUALS.conduits.minzoom, - filter: ["==", ["get", "id"], ""], - paint: { - "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 - } - }, - { - id: "junctions-halo", - type: "circle", - source: "junctions", - "source-layer": SOURCE_LAYERS.junctions, - minzoom: DRAINAGE_LAYER_VISUALS.junctions.minzoom, - paint: { - "circle-color": "#ffffff", - "circle-radius": DRAINAGE_LAYER_VISUALS.junctions.haloRadius, - "circle-opacity": 0.76, - "circle-stroke-color": "rgba(15,23,42,0.22)", - "circle-stroke-width": 1 - } - }, - { - id: "junctions", - type: "circle", - source: "junctions", - "source-layer": SOURCE_LAYERS.junctions, - minzoom: DRAINAGE_LAYER_VISUALS.junctions.minzoom, - paint: { - "circle-color": DRAINAGE_SOURCE_COLORS.junctions, - "circle-radius": DRAINAGE_LAYER_VISUALS.junctions.radius, - "circle-opacity": DRAINAGE_SOURCE_OPACITIES.junctions - } - }, - { - id: "junctions-hover", - type: "circle", - source: "junctions", - "source-layer": SOURCE_LAYERS.junctions, - minzoom: DRAINAGE_LAYER_VISUALS.junctions.minzoom, - filter: ["==", ["get", "id"], ""], - paint: { - "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 - } - } +const hoveredOpacity: ExpressionSpecification = [ + "case", + ["all", ["boolean", ["feature-state", "hovered"], false], ["!", ["boolean", ["feature-state", "selected"], false]]], + 1, + 0 ]; +const selectedOpacity: ExpressionSpecification = [ + "case", ["boolean", ["feature-state", "selected"], false], 1, 0 +]; +const stateColor = (sourceColor: string): ExpressionSpecification => [ + "case", + ["in", ["downcase", ["to-string", ["coalesce", ["get", "status"], ""]]], ["literal", ["closed", "off", "inactive"]]], + MAP_STYLE_TOKENS.state.inactive, + sourceColor +]; +const opacityTransition = { duration: 120, delay: 0 } as const; + +type Layer = NonNullable[number]; +type AuthoredLayer = { id: string; type: string; [key: string]: unknown }; +const layers: AuthoredLayer[] = []; + +// Business sources: white casing, then category-colored core. +layers.push( + { id: "pipes-casing", type: "line", source: "conduits", "source-layer": SOURCE_LAYERS.conduits, minzoom: DRAINAGE_LAYER_VISUALS.conduits.minzoom, paint: { "line-color": MAP_STYLE_TOKENS.canvas.casing, "line-width": DRAINAGE_LAYER_VISUALS.conduits.casingWidth, "line-opacity": MAP_STYLE_TOKENS.opacity.casing } }, + { id: "pipes-flow", type: "line", source: "conduits", "source-layer": SOURCE_LAYERS.conduits, minzoom: DRAINAGE_LAYER_VISUALS.conduits.minzoom, paint: { "line-color": stateColor(MAP_STYLE_TOKENS.asset.conduit), "line-width": DRAINAGE_LAYER_VISUALS.conduits.width, "line-opacity": MAP_STYLE_TOKENS.opacity.core } }, + { id: "junctions-halo", type: "circle", source: "junctions", "source-layer": SOURCE_LAYERS.junctions, minzoom: DRAINAGE_LAYER_VISUALS.junctions.minzoom, paint: { "circle-color": MAP_STYLE_TOKENS.canvas.casing, "circle-radius": DRAINAGE_LAYER_VISUALS.junctions.haloRadius, "circle-opacity": DRAINAGE_LAYER_VISUALS.junctions.haloOpacity } }, + { id: "junctions", type: "circle", source: "junctions", "source-layer": SOURCE_LAYERS.junctions, minzoom: DRAINAGE_LAYER_VISUALS.junctions.minzoom, paint: { "circle-color": stateColor(MAP_STYLE_TOKENS.asset.junction), "circle-radius": DRAINAGE_LAYER_VISUALS.junctions.radius, "circle-opacity": MAP_STYLE_TOKENS.opacity.core } }, + { id: "orifices-casing", type: "line", source: "orifices", "source-layer": SOURCE_LAYERS.orifices, minzoom: DRAINAGE_LAYER_VISUALS.orifices.minzoom, paint: { "line-color": MAP_STYLE_TOKENS.canvas.casing, "line-width": DRAINAGE_LAYER_VISUALS.orifices.casingWidth, "line-opacity": 1 } }, + { id: "orifices", type: "line", source: "orifices", "source-layer": SOURCE_LAYERS.orifices, minzoom: DRAINAGE_LAYER_VISUALS.orifices.minzoom, paint: { "line-color": stateColor(MAP_STYLE_TOKENS.asset.orifice), "line-width": DRAINAGE_LAYER_VISUALS.orifices.width, "line-dasharray": [4, 2], "line-opacity": MAP_STYLE_TOKENS.opacity.core } }, + { id: "pumps-casing", type: "line", source: "pumps", "source-layer": SOURCE_LAYERS.pumps, minzoom: DRAINAGE_LAYER_VISUALS.pumps.minzoom, paint: { "line-color": MAP_STYLE_TOKENS.canvas.casing, "line-width": DRAINAGE_LAYER_VISUALS.pumps.casingWidth, "line-opacity": 1 } }, + { id: "pumps", type: "line", source: "pumps", "source-layer": SOURCE_LAYERS.pumps, minzoom: DRAINAGE_LAYER_VISUALS.pumps.minzoom, paint: { "line-color": stateColor(MAP_STYLE_TOKENS.asset.pump), "line-width": DRAINAGE_LAYER_VISUALS.pumps.width, "line-dasharray": [6, 2, 1.5, 2], "line-opacity": MAP_STYLE_TOKENS.opacity.core } }, + { id: "outfalls-halo", type: "circle", source: "outfalls", "source-layer": SOURCE_LAYERS.outfalls, minzoom: DRAINAGE_LAYER_VISUALS.outfalls.minzoom, paint: { "circle-color": MAP_STYLE_TOKENS.canvas.casing, "circle-radius": DRAINAGE_LAYER_VISUALS.outfalls.haloRadius, "circle-opacity": 1 } }, + { id: "outfalls", type: "circle", source: "outfalls", "source-layer": SOURCE_LAYERS.outfalls, minzoom: DRAINAGE_LAYER_VISUALS.outfalls.minzoom, paint: { "circle-color": MAP_STYLE_TOKENS.canvas.casing, "circle-radius": DRAINAGE_LAYER_VISUALS.outfalls.radius, "circle-opacity": 1, "circle-stroke-color": stateColor(MAP_STYLE_TOKENS.asset.outfall), "circle-stroke-width": 2 } } +); + +const lineInteractions = [ + ["pipes", "conduits", SOURCE_LAYERS.conduits, DRAINAGE_LAYER_VISUALS.conduits, MAP_STYLE_TOKENS.asset.conduit], + ["orifices", "orifices", SOURCE_LAYERS.orifices, DRAINAGE_LAYER_VISUALS.orifices, MAP_STYLE_TOKENS.asset.orifice], + ["pumps", "pumps", SOURCE_LAYERS.pumps, DRAINAGE_LAYER_VISUALS.pumps, MAP_STYLE_TOKENS.asset.pump] +] as const; + +lineInteractions.forEach(([prefix, source, sourceLayer, visual, color]) => { + layers.push( + { id: `${prefix}-hover-outline`, type: "line", source, "source-layer": sourceLayer, minzoom: visual.minzoom, paint: { "line-color": MAP_STYLE_TOKENS.canvas.casing, "line-width": visual.hoverOutlineWidth, "line-opacity": hoveredOpacity, "line-opacity-transition": opacityTransition } }, + { id: `${prefix}-hover`, type: "line", source, "source-layer": sourceLayer, minzoom: visual.minzoom, paint: { "line-color": stateColor(color), "line-width": visual.hoverWidth, "line-opacity": hoveredOpacity, "line-opacity-transition": opacityTransition } }, + { id: `${prefix}-selected-outer`, type: "line", source, "source-layer": sourceLayer, minzoom: visual.minzoom, paint: { "line-color": MAP_STYLE_TOKENS.canvas.casing, "line-gap-width": visual.selectedWidth, "line-width": 4.5, "line-opacity": selectedOpacity, "line-opacity-transition": opacityTransition } }, + { id: `${prefix}-selected-outline`, type: "line", source, "source-layer": sourceLayer, minzoom: visual.minzoom, paint: { "line-color": MAP_STYLE_TOKENS.state.selected, "line-gap-width": visual.selectedWidth, "line-width": visual.selectedBorderWidth, "line-opacity": selectedOpacity, "line-opacity-transition": opacityTransition } }, + { id: `${prefix}-selected-separator`, type: "line", source, "source-layer": sourceLayer, minzoom: visual.minzoom, paint: { "line-color": MAP_STYLE_TOKENS.canvas.casing, "line-gap-width": visual.selectedWidth, "line-width": 1, "line-offset": -1.5, "line-opacity": selectedOpacity, "line-opacity-transition": opacityTransition } } + ); +}); + +const pointInteractions = [ + ["junctions", "junctions", SOURCE_LAYERS.junctions, DRAINAGE_LAYER_VISUALS.junctions, MAP_STYLE_TOKENS.asset.junction], + ["outfalls", "outfalls", SOURCE_LAYERS.outfalls, DRAINAGE_LAYER_VISUALS.outfalls, MAP_STYLE_TOKENS.asset.outfall] +] as const; +pointInteractions.forEach(([prefix, source, sourceLayer, visual, color]) => { + layers.push( + { id: `${prefix}-hover`, type: "circle", source, "source-layer": sourceLayer, minzoom: visual.minzoom, paint: { "circle-color": stateColor(color), "circle-radius": visual.hoverRadius, "circle-opacity": hoveredOpacity, "circle-stroke-color": MAP_STYLE_TOKENS.canvas.casing, "circle-stroke-width": 2, "circle-opacity-transition": opacityTransition, "circle-stroke-opacity": hoveredOpacity, "circle-stroke-opacity-transition": opacityTransition } }, + { id: `${prefix}-selected-outer`, type: "circle", source, "source-layer": sourceLayer, minzoom: visual.minzoom, paint: { "circle-color": MAP_STYLE_TOKENS.canvas.transparent, "circle-radius": visual.selectedRadius, "circle-opacity": selectedOpacity, "circle-stroke-color": MAP_STYLE_TOKENS.canvas.casing, "circle-stroke-width": 6, "circle-stroke-opacity": selectedOpacity, "circle-opacity-transition": opacityTransition, "circle-stroke-opacity-transition": opacityTransition } }, + { id: `${prefix}-selected`, type: "circle", source, "source-layer": sourceLayer, minzoom: visual.minzoom, paint: { "circle-color": MAP_STYLE_TOKENS.canvas.transparent, "circle-radius": visual.selectedRadius, "circle-opacity": selectedOpacity, "circle-stroke-color": MAP_STYLE_TOKENS.state.selected, "circle-stroke-width": 3, "circle-stroke-opacity": selectedOpacity, "circle-opacity-transition": opacityTransition, "circle-stroke-opacity-transition": opacityTransition } } + ); +}); + +// Transparent hit layers stay last so pointer targeting is independent of paint order. +layers.push( + { id: "conduits-hit", type: "line", source: "conduits", "source-layer": SOURCE_LAYERS.conduits, minzoom: DRAINAGE_LAYER_VISUALS.conduits.minzoom, paint: { "line-color": MAP_STYLE_TOKENS.canvas.transparent, "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": MAP_STYLE_TOKENS.canvas.transparent, "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": MAP_STYLE_TOKENS.canvas.transparent, "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": MAP_STYLE_TOKENS.canvas.transparent, "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": MAP_STYLE_TOKENS.canvas.transparent, "circle-radius": DRAINAGE_LAYER_VISUALS.outfalls.hitRadius, "circle-opacity": 0 } } +); + +export const waterNetworkLayers: StyleSpecification["layers"] = layers as Layer[]; + +const interactionIndex = layers.findIndex((layer) => layer.id === "pipes-hover-outline"); +const hitIndex = layers.findIndex((layer) => layer.id === INTERACTIVE_HIT_LAYER_IDS[0]); + +export const waterNetworkBusinessLayers = layers.slice(0, interactionIndex) as Layer[]; +export const waterNetworkInteractionLayers = layers.slice(interactionIndex, hitIndex) as Layer[]; +export const waterNetworkHitLayers = layers.slice(hitIndex) as Layer[]; +export const WORKBENCH_INTERACTION_BEFORE_ID = "pipes-hover-outline"; diff --git a/features/workbench/map/map-colors.ts b/features/workbench/map/map-colors.ts index 541ecef..0c92f8f 100644 --- a/features/workbench/map/map-colors.ts +++ b/features/workbench/map/map-colors.ts @@ -1,31 +1,38 @@ -export const MAP_BACKGROUND_COLORS = { - primary: "#F9FAFB", - secondary: "#F0F1F3", - secondaryAlt: "#FAFBFC" +/** Runtime map colors. Keep literal colors in this file only. */ +export const MAP_STYLE_TOKENS = { + canvas: { + primary: "#F9FAFB", + secondary: "#F0F1F3", + secondaryAlt: "#FAFBFC", + casing: "#FFFFFF", + transparent: "rgba(0,0,0,0)" + }, + asset: { + conduit: "#0F766E", + junction: "#0369A1", + orifice: "#475569", + pump: "#155E75", + outfall: "#334155" + }, + state: { + selected: "#2563EB", + inactive: "#64748B", + risk: "#C2410C", + incident: "#B91C1C", + success: "#15803D", + agent: "#6D28D9" + }, + tool: { + geometry: "#334155" + }, + label: { + primary: "#0F172A", + secondary: "#334155", + inverse: "#FFFFFF" + }, + opacity: { + core: 0.9, + casing: 1, + area: 0.14 + } } as const; - -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 index 02ac4c1..8c37d30 100644 --- a/features/workbench/map/map-control-config.test.ts +++ b/features/workbench/map/map-control-config.test.ts @@ -16,7 +16,8 @@ describe("workbench source layer controls", () => { "junctions", "orifices", "outfalls", - "pumps" + "pumps", + "scada" ]); expect(items.slice(0, 5).map((item) => item.description)).toEqual([ "lingang:geo_conduits_mat", @@ -37,16 +38,16 @@ describe("workbench source layer controls", () => { "orifices", "orifices-hover-outline", "orifices-hover", - "orifices-selected-halo", + "orifices-selected-outer", "orifices-selected-outline", - "orifices-selected", + "orifices-selected-separator", "orifices-hit" ]); expect(getWorkbenchLayerIds(map, "outfalls")).toEqual([ "outfalls-halo", "outfalls", "outfalls-hover", - "outfalls-selected-halo", + "outfalls-selected-outer", "outfalls-selected", "outfalls-hit" ]); diff --git a/features/workbench/map/map-control-config.ts b/features/workbench/map/map-control-config.ts index d696d7a..83207eb 100644 --- a/features/workbench/map/map-control-config.ts +++ b/features/workbench/map/map-control-config.ts @@ -2,7 +2,7 @@ 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 { MAP_STYLE_TOKENS } from "./map-colors"; import { SOURCE_LAYERS, WATER_NETWORK_SOURCE_IDS, @@ -28,6 +28,7 @@ export const INITIAL_LAYER_VISIBILITY: Record = { orifices: true, outfalls: true, pumps: true, + scada: true, simulation: false }; @@ -36,16 +37,24 @@ const SOURCE_CONTROL_LABELS: Record unknown; addSource: (id: string, source: GeoJSONSourceSpecification) => void; getLayer: (id: string) => unknown; - addLayer: (layer: NonNullable[number]) => void; + addLayer: (layer: NonNullable[number], beforeId?: string) => void; }) { if (!map.getSource(MEASUREMENT_SOURCE_ID)) { map.addSource(MEASUREMENT_SOURCE_ID, measurementSource); @@ -84,7 +86,7 @@ export function ensureMeasurementLayers(map: { measurementLayers.forEach((layer) => { if (!map.getLayer(layer.id)) { - map.addLayer(layer); + map.addLayer(layer, WORKBENCH_INTERACTION_BEFORE_ID); } }); } diff --git a/features/workbench/map/scada.test.ts b/features/workbench/map/scada.test.ts new file mode 100644 index 0000000..82f453f --- /dev/null +++ b/features/workbench/map/scada.test.ts @@ -0,0 +1,78 @@ +import type { Map as MapLibreMap } from "maplibre-gl"; +import { describe, expect, it, vi } from "vitest"; +import { + SCADA_HIT_LAYER_ID, + SCADA_ICON_EXPRESSION, + SCADA_IMAGE_IDS, + SCADA_MAP_ICON_PATHS, + registerScadaImages, + scadaFallbackLayers, + scadaLayers +} from "./scada"; + +describe("SCADA map presentation", () => { + it("maps the three authoritative device types and falls back to unknown", () => { + expect(SCADA_ICON_EXPRESSION).toEqual([ + "match", ["to-number", ["get", "device_type_id"]], + 72, SCADA_IMAGE_IDS.waterQuality, + 58, SCADA_IMAGE_IDS.radarLevel, + 45, SCADA_IMAGE_IDS.ultrasonicFlow, + SCADA_IMAGE_IDS.unknown + ]); + }); + + it("keeps the hit target last in normal and fallback presentations", () => { + expect(scadaLayers.at(-1)?.id).toBe(SCADA_HIT_LAYER_ID); + expect(scadaFallbackLayers.at(-1)?.id).toBe(SCADA_HIT_LAYER_ID); + }); + + it("uses MapLibre-compatible PNG assets", () => { + expect(Object.values(SCADA_MAP_ICON_PATHS)).toHaveLength(4); + Object.values(SCADA_MAP_ICON_PATHS).forEach((path) => expect(path).toMatch(/\.png$/)); + }); + + it("registers all map images at two-times pixel density", async () => { + const image = { width: 64, height: 64 }; + const map = createImageMap(() => Promise.resolve({ data: image })); + + await expect(registerScadaImages(map)).resolves.toEqual({ status: "ready", failedImageIds: [] }); + expect(map.loadImage).toHaveBeenCalledTimes(4); + expect(map.addImage).toHaveBeenCalledTimes(4); + expect(map.addImage).toHaveBeenCalledWith(expect.any(String), image, { pixelRatio: 2 }); + }); + + it("falls back to the unknown image when one category fails", async () => { + const image = { width: 64, height: 64 }; + const map = createImageMap((path) => path.includes("radar-level") + ? Promise.reject(new Error("decode failed")) + : Promise.resolve({ data: image })); + + await expect(registerScadaImages(map)).resolves.toEqual({ + status: "degraded", + failedImageIds: [SCADA_IMAGE_IDS.radarLevel] + }); + expect(map.addImage).toHaveBeenCalledWith(SCADA_IMAGE_IDS.radarLevel, image, { pixelRatio: 2 }); + }); + + it("returns unavailable instead of rejecting when the fallback image fails", async () => { + const map = createImageMap(() => Promise.reject(new Error("unsupported image"))); + + await expect(registerScadaImages(map)).resolves.toEqual({ + status: "unavailable", + failedImageIds: Object.values(SCADA_IMAGE_IDS) + }); + expect(scadaFallbackLayers[0]?.id).toBe("scada-fallback"); + }); +}); + +function createImageMap(loadImage: (path: string) => Promise<{ data: { width: number; height: number } }>) { + const loadedImageIds = new Set(); + return { + loadImage: vi.fn(loadImage), + hasImage: vi.fn((imageId: string) => loadedImageIds.has(imageId)), + addImage: vi.fn((imageId: string) => loadedImageIds.add(imageId)) + } as unknown as Pick & { + loadImage: ReturnType; + addImage: ReturnType; + }; +} diff --git a/features/workbench/map/scada.ts b/features/workbench/map/scada.ts new file mode 100644 index 0000000..5b58d55 --- /dev/null +++ b/features/workbench/map/scada.ts @@ -0,0 +1,122 @@ +import type { ExpressionSpecification, Map as MapLibreMap, StyleSpecification } from "maplibre-gl"; + +export const SCADA_SOURCE_ID = "scada"; +export const SCADA_SOURCE_LAYER = "scada_points"; +export const SCADA_HIT_LAYER_ID = "scada-hit"; + +export const SCADA_ICON_PATHS = { + waterQuality: "/icons/scada-water-quality.svg", + radarLevel: "/icons/scada-radar-level.svg", + ultrasonicFlow: "/icons/scada-ultrasonic-flow.svg", + unknown: "/icons/scada-unknown.svg" +} as const; + +export const SCADA_MAP_ICON_PATHS = { + waterQuality: "/icons/scada-water-quality.png", + radarLevel: "/icons/scada-radar-level.png", + ultrasonicFlow: "/icons/scada-ultrasonic-flow.png", + unknown: "/icons/scada-unknown.png" +} as const; + +export const SCADA_IMAGE_IDS = { + waterQuality: "scada-water-quality", + radarLevel: "scada-radar-level", + ultrasonicFlow: "scada-ultrasonic-flow", + unknown: "scada-unknown" +} as const; + +export const SCADA_ICON_EXPRESSION: ExpressionSpecification = [ + "match", ["to-number", ["get", "device_type_id"]], + 72, SCADA_IMAGE_IDS.waterQuality, + 58, SCADA_IMAGE_IDS.radarLevel, + 45, SCADA_IMAGE_IDS.ultrasonicFlow, + SCADA_IMAGE_IDS.unknown +]; + +const stateOpacity = (state: "hovered" | "selected"): ExpressionSpecification => [ + "case", ["boolean", ["feature-state", state], false], 1, 0 +]; + +type Layer = NonNullable[number]; + +export type ScadaImageRegistrationResult = { + status: "ready" | "degraded" | "unavailable"; + failedImageIds: string[]; +}; + +const scadaSymbolLayer: Layer = { + id: "scada-symbol", type: "symbol", source: SCADA_SOURCE_ID, "source-layer": SCADA_SOURCE_LAYER, minzoom: 12, + layout: { "icon-image": SCADA_ICON_EXPRESSION, "icon-size": ["interpolate", ["linear"], ["zoom"], 12, 0.625, 20, 0.875], "icon-allow-overlap": true, "icon-ignore-placement": true } +}; + +const scadaFallbackLayer: Layer = { + id: "scada-fallback", type: "circle", source: SCADA_SOURCE_ID, "source-layer": SCADA_SOURCE_LAYER, minzoom: 12, + paint: { "circle-radius": ["interpolate", ["linear"], ["zoom"], 12, 5, 20, 8], "circle-color": "#0E7490", "circle-stroke-color": "white", "circle-stroke-width": 2 } +}; + +const scadaInteractionLayers: Layer[] = [ + { id: "scada-hover", type: "circle", source: SCADA_SOURCE_ID, "source-layer": SCADA_SOURCE_LAYER, minzoom: 12, paint: { "circle-radius": 14, "circle-color": "rgba(255,255,255,0)", "circle-stroke-color": "#0E7490", "circle-stroke-width": 2, "circle-opacity": stateOpacity("hovered"), "circle-stroke-opacity": stateOpacity("hovered") } }, + { id: "scada-selected-outer", type: "circle", source: SCADA_SOURCE_ID, "source-layer": SCADA_SOURCE_LAYER, minzoom: 12, paint: { "circle-radius": 16, "circle-color": "rgba(255,255,255,0)", "circle-stroke-color": "white", "circle-stroke-width": 6, "circle-opacity": stateOpacity("selected"), "circle-stroke-opacity": stateOpacity("selected") } }, + { id: "scada-selected", type: "circle", source: SCADA_SOURCE_ID, "source-layer": SCADA_SOURCE_LAYER, minzoom: 12, paint: { "circle-radius": 16, "circle-color": "rgba(255,255,255,0)", "circle-stroke-color": "#2563EB", "circle-stroke-width": 3, "circle-opacity": stateOpacity("selected"), "circle-stroke-opacity": stateOpacity("selected") } }, + { id: SCADA_HIT_LAYER_ID, type: "circle", source: SCADA_SOURCE_ID, "source-layer": SCADA_SOURCE_LAYER, minzoom: 12, paint: { "circle-radius": 16, "circle-color": "rgba(0,0,0,0)", "circle-opacity": 0 } } +]; + +export const scadaLayers: Layer[] = [ + scadaSymbolLayer, + ...scadaInteractionLayers +]; + +export const scadaFallbackLayers: Layer[] = [ + scadaFallbackLayer, + ...scadaInteractionLayers +]; + +export async function registerScadaImages( + map: Pick +): Promise { + let unknown: Awaited>; + + try { + unknown = await loadImage(map, SCADA_MAP_ICON_PATHS.unknown); + addImageIfMissing(map, SCADA_IMAGE_IDS.unknown, unknown); + } catch { + return { status: "unavailable", failedImageIds: Object.values(SCADA_IMAGE_IDS) }; + } + + const failedImageIds: string[] = []; + for (const [key, path] of Object.entries(SCADA_MAP_ICON_PATHS)) { + if (key === "unknown") continue; + const imageId = SCADA_IMAGE_IDS[key as Exclude]; + if (map.hasImage(imageId)) continue; + + try { + const image = await loadImage(map, path); + addImageIfMissing(map, imageId, image); + } catch { + failedImageIds.push(imageId); + try { + addImageIfMissing(map, imageId, unknown); + } catch { + return { status: "unavailable", failedImageIds }; + } + } + } + + return { + status: failedImageIds.length > 0 ? "degraded" : "ready", + failedImageIds + }; +} + +async function loadImage(map: Pick, path: string) { + const response = await map.loadImage(path); + return response.data; +} + +function addImageIfMissing( + map: Pick, + imageId: string, + image: Awaited> +) { + if (!map.hasImage(imageId)) map.addImage(imageId, image, { pixelRatio: 2 }); +} diff --git a/features/workbench/map/sources.ts b/features/workbench/map/sources.ts index c31a622..4871e39 100644 --- a/features/workbench/map/sources.ts +++ b/features/workbench/map/sources.ts @@ -1,5 +1,6 @@ import type { VectorSourceSpecification, StyleSpecification } from "maplibre-gl"; -import { MAP_BACKGROUND_COLORS } from "./map-colors"; +import { MAP_URL } from "../../../lib/config"; +import { MAP_STYLE_TOKENS } from "./map-colors"; export const WATER_NETWORK_GLOBAL_VIEW = { bbox3857: [ @@ -15,7 +16,8 @@ export const SOURCE_LAYERS = { junctions: "geo_junctions_mat", orifices: "geo_orifices_mat", outfalls: "geo_outfalls_mat", - pumps: "geo_pumps_mat" + pumps: "geo_pumps_mat", + scada: "scada_points" } as const; export const WATER_NETWORK_SOURCE_IDS = [ @@ -23,12 +25,13 @@ export const WATER_NETWORK_SOURCE_IDS = [ "junctions", "orifices", "outfalls", - "pumps" + "pumps", + "scada" ] as const; export type WaterNetworkSourceId = (typeof WATER_NETWORK_SOURCE_IDS)[number]; -const GEOSERVER_WMTS_ROOT = "https://geoserver.waternetwork.cn/geoserver/gwc/service/wmts/rest"; +const GEOSERVER_WMTS_ROOT = `${MAP_URL}/gwc/service/wmts/rest`; export function createWaterNetworkSources() { return { @@ -56,7 +59,8 @@ export function createWaterNetworkSources() { SOURCE_LAYERS.pumps, "line", [121.73585149761436, 30.861759757577705, 121.9498481645973, 30.983213202338085] - ) + ), + scada: createLingangVectorSource(SOURCE_LAYERS.scada, "point", [121.7350340307058, 30.84636502815, 121.97051839928491, 30.994737681416165]) }; } @@ -67,6 +71,7 @@ function createLingangVectorSource( ): VectorSourceSpecification { return { type: "vector", + promoteId: "id", tiles: [ `${GEOSERVER_WMTS_ROOT}/lingang:${sourceLayer}/${style}/WebMercatorQuad/{z}/{y}/{x}?format=application/vnd.mapbox-vector-tile` ], @@ -83,7 +88,7 @@ export function createBaseStyle(mapboxToken?: string): StyleSpecification { id: "background", type: "background", paint: { - "background-color": MAP_BACKGROUND_COLORS.primary + "background-color": MAP_STYLE_TOKENS.canvas.primary } } ]; diff --git a/features/workbench/utils/feature-properties.test.ts b/features/workbench/utils/feature-properties.test.ts index 3113d00..5e2da18 100644 --- a/features/workbench/utils/feature-properties.test.ts +++ b/features/workbench/utils/feature-properties.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { getFeaturePanelModel, getFeaturePanelProperties } from "./feature-properties"; +import { getFeaturePanelModel, getFeaturePanelProperties, getLocalizedFeatureProperties } from "./feature-properties"; describe("feature panel properties", () => { it.each([ @@ -68,4 +68,14 @@ describe("feature panel properties", () => { expect(model.badge).toBeUndefined(); expect(model.attributes.map((entry) => entry.key)).toEqual(["invert_elevation", "max_depth"]); }); + + it("omits the internal SCADA clustering size from user-facing properties", () => { + const entries = getLocalizedFeatureProperties({ + point_name: "THC雷达液位计(MQTT)", + cluster_size: 29, + device_external_id: "DEVICE-002" + }); + + expect(entries.map((entry) => entry.key)).toEqual(["point_name", "device_external_id"]); + }); }); diff --git a/features/workbench/utils/feature-properties.ts b/features/workbench/utils/feature-properties.ts index 1a2335f..4ddbaa0 100644 --- a/features/workbench/utils/feature-properties.ts +++ b/features/workbench/utils/feature-properties.ts @@ -58,7 +58,19 @@ const PROPERTY_LABELS: Record = { zone: "分区", district: "片区", source: "来源", - updated_at: "更新时间" + updated_at: "更新时间", + point_id: "测点 ID", + point_external_id: "测点编号", + point_name: "测点名称", + device_id: "设备 ID", + device_external_id: "设备编号", + device_type_id: "设备类型 ID", + device_type_name: "设备类型", + device_icon_code: "设备图标编码", + active: "运行状态", + junction_id: "关联检查井", + location_source: "位置来源", + synced_at: "同步时间" }; const PROPERTY_ORDER: Record = { @@ -121,7 +133,8 @@ const FEATURE_PANEL_PROPERTY_KEYS: Record> = { @@ -130,7 +143,7 @@ const FEATURE_PANEL_BADGE_KEYS: Partial> = }; export function getLocalizedFeatureProperties(properties: Record): LocalizedFeatureProperty[] { - return Object.entries(properties).map(([key, value]) => ({ + return Object.entries(properties).filter(([key]) => normalizePropertyKey(key) !== "cluster_size").map(([key, value]) => ({ key, label: getFeaturePropertyLabel(key), value: formatFeaturePropertyValue(key, value) @@ -159,14 +172,15 @@ export function getFeaturePanelModel( featureId?: string ): FeaturePanelModel { const entries = getFeaturePanelProperties(layer, properties, featureId); - const id = entries.find((entry) => entry.key === "id")?.value ?? "暂无"; + const idKey = layer === "scada" ? "point_external_id" : "id"; + const id = entries.find((entry) => entry.key === idKey)?.value ?? "暂无"; const badgeKey = FEATURE_PANEL_BADGE_KEYS[layer]; const badgeEntry = badgeKey ? entries.find((entry) => entry.key === badgeKey) : undefined; return { id, badge: badgeEntry ? getFeaturePanelBadge(layer, badgeEntry.value) : undefined, - attributes: entries.filter((entry) => entry.key !== "id" && entry.key !== badgeKey) + attributes: entries.filter((entry) => entry.key !== idKey && entry.key !== badgeKey) }; } @@ -190,6 +204,14 @@ export function formatFeaturePropertyValue(key: string, value: unknown) { return GATED_LABELS[String(value).toLowerCase()] ?? formatValue(value); } + if (normalizedKey === "active" && typeof value === "boolean") { + return value ? "在线" : "离线"; + } + + if (normalizedKey === "location_source" && value === "density_cluster_demo") { + return "密度聚类模拟位置"; + } + if ((normalizedKey === "diameter" || normalizedKey === "dn") && value !== null && value !== undefined && value !== "") { const diameterInMeters = typeof value === "number" ? value : Number(value); return Number.isFinite(diameterInMeters) diff --git a/lib/config.ts b/lib/config.ts new file mode 100644 index 0000000..10e3c57 --- /dev/null +++ b/lib/config.ts @@ -0,0 +1,5 @@ +const DEFAULT_MAP_URL = "https://geoserver.waternetwork.cn/geoserver"; + +export const MAP_URL = ( + process.env.NEXT_PUBLIC_MAP_URL || DEFAULT_MAP_URL +).replace(/\/$/, ""); diff --git a/public/icons/scada-radar-level.png b/public/icons/scada-radar-level.png new file mode 100644 index 0000000..e308a54 Binary files /dev/null and b/public/icons/scada-radar-level.png differ diff --git a/public/icons/scada-radar-level.svg b/public/icons/scada-radar-level.svg new file mode 100644 index 0000000..29283ac --- /dev/null +++ b/public/icons/scada-radar-level.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/public/icons/scada-ultrasonic-flow.png b/public/icons/scada-ultrasonic-flow.png new file mode 100644 index 0000000..5e868ef Binary files /dev/null and b/public/icons/scada-ultrasonic-flow.png differ diff --git a/public/icons/scada-ultrasonic-flow.svg b/public/icons/scada-ultrasonic-flow.svg new file mode 100644 index 0000000..d43f533 --- /dev/null +++ b/public/icons/scada-ultrasonic-flow.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/public/icons/scada-unknown.png b/public/icons/scada-unknown.png new file mode 100644 index 0000000..b8bd4b4 Binary files /dev/null and b/public/icons/scada-unknown.png differ diff --git a/public/icons/scada-unknown.svg b/public/icons/scada-unknown.svg new file mode 100644 index 0000000..5f6b7c8 --- /dev/null +++ b/public/icons/scada-unknown.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/public/icons/scada-water-quality.png b/public/icons/scada-water-quality.png new file mode 100644 index 0000000..371674a Binary files /dev/null and b/public/icons/scada-water-quality.png differ diff --git a/public/icons/scada-water-quality.svg b/public/icons/scada-water-quality.svg new file mode 100644 index 0000000..58291eb --- /dev/null +++ b/public/icons/scada-water-quality.svg @@ -0,0 +1,9 @@ + + + + + + + + +