feat(map): refine SCADA feature experience
@@ -1,2 +1,3 @@
|
|||||||
NEXT_PUBLIC_MAPBOX_ACCESS_TOKEN=your_mapbox_access_token_here
|
NEXT_PUBLIC_MAPBOX_ACCESS_TOKEN=your_mapbox_access_token_here
|
||||||
NEXT_PUBLIC_AGENT_API_BASE_URL=http://127.0.0.1:8787
|
NEXT_PUBLIC_AGENT_API_BASE_URL=http://127.0.0.1:8787
|
||||||
|
NEXT_PUBLIC_MAP_URL=https://geoserver.waternetwork.cn/geoserver
|
||||||
|
|||||||
@@ -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)。
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
import { NextResponse } from "next/server";
|
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;
|
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) {
|
export async function POST(request: Request) {
|
||||||
const body = await request.json().catch(() => null) as { ids?: unknown; featureType?: unknown } | null;
|
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 (!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 });
|
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 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) }); }
|
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 }); }
|
catch { return NextResponse.json({ code: "WFS_UNAVAILABLE" }, { status: 502 }); }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { LucideIcon } from "lucide-react";
|
import type { LucideIcon } from "lucide-react";
|
||||||
|
import Image from "next/image";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import {
|
import {
|
||||||
MAP_ICON_CELL_RADIUS_CLASS_NAME,
|
MAP_ICON_CELL_RADIUS_CLASS_NAME,
|
||||||
@@ -14,7 +15,8 @@ export type MapLegendItem = {
|
|||||||
description?: string;
|
description?: string;
|
||||||
color: string;
|
color: string;
|
||||||
icon?: LucideIcon;
|
icon?: LucideIcon;
|
||||||
shape?: "line" | "dot" | "square";
|
imageSrc?: string;
|
||||||
|
shape?: "line" | "dashed-line" | "dash-dot-line" | "dot" | "ring" | "square";
|
||||||
};
|
};
|
||||||
|
|
||||||
type MapLegendProps = {
|
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)}
|
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)}
|
||||||
>
|
>
|
||||||
<span className={cn("grid h-7 w-7 shrink-0 place-items-center bg-slate-50", MAP_ICON_CELL_RADIUS_CLASS_NAME)} aria-hidden="true">
|
<span className={cn("grid h-7 w-7 shrink-0 place-items-center bg-slate-50", MAP_ICON_CELL_RADIUS_CLASS_NAME)} aria-hidden="true">
|
||||||
{Icon ? <Icon size={15} style={{ color: item.color }} /> : <MapLegendSwatch color={item.color} shape={item.shape} />}
|
{item.imageSrc ? <Image src={item.imageSrc} alt="" width={24} height={24} /> : Icon ? <Icon size={15} style={{ color: item.color }} /> : <MapLegendSwatch color={item.color} shape={item.shape} />}
|
||||||
</span>
|
</span>
|
||||||
<span className="min-w-0 flex-1">
|
<span className="min-w-0 flex-1">
|
||||||
<span className="block truncate font-semibold">{item.label}</span>
|
<span className="block truncate font-semibold">{item.label}</span>
|
||||||
@@ -78,9 +80,21 @@ export function MapLegendSwatch({ color, shape = "dot" }: Pick<MapLegendItem, "c
|
|||||||
return <span className="h-1 w-6 rounded-full" style={{ backgroundColor: color }} />;
|
return <span className="h-1 w-6 rounded-full" style={{ backgroundColor: color }} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (shape === "dashed-line" || shape === "dash-dot-line") {
|
||||||
|
return (
|
||||||
|
<svg width="24" height="8" viewBox="0 0 24 8" aria-hidden="true">
|
||||||
|
<line x1="1" y1="4" x2="23" y2="4" stroke={color} strokeWidth="3" strokeDasharray={shape === "dashed-line" ? "5 3" : "8 3 2 3"} />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (shape === "square") {
|
if (shape === "square") {
|
||||||
return <span className="h-4 w-4 rounded-sm" style={{ backgroundColor: color }} />;
|
return <span className="h-4 w-4 rounded-sm" style={{ backgroundColor: color }} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (shape === "ring") {
|
||||||
|
return <span className="h-4 w-4 rounded-full border-[3px] bg-white" style={{ borderColor: color }} />;
|
||||||
|
}
|
||||||
|
|
||||||
return <span className="h-4 w-4 rounded-full ring-2 ring-white" style={{ backgroundColor: color }} />;
|
return <span className="h-4 w-4 rounded-full ring-2 ring-white" style={{ backgroundColor: color }} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Target, TriangleAlert, X } from "lucide-react";
|
import { Target, TriangleAlert, X } from "lucide-react";
|
||||||
|
import Image from "next/image";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { SCADA_ICON_PATHS } from "../map/scada";
|
||||||
import type { DetailFeature } from "../types";
|
import type { DetailFeature } from "../types";
|
||||||
import {
|
import {
|
||||||
formatFeaturePropertyValue,
|
formatFeaturePropertyValue,
|
||||||
@@ -55,6 +58,13 @@ export function FeatureInsightPanel({ feature, onClose }: FeatureInsightPanelPro
|
|||||||
["阶段数据", formatFeaturePropertyValue("stage_data", feature.properties.stage_data)],
|
["阶段数据", 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]);
|
}, [feature]);
|
||||||
|
|
||||||
@@ -103,6 +113,19 @@ export function FeatureInsightPanel({ feature, onClose }: FeatureInsightPanelPro
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{feature.layer === "scada" ? (
|
||||||
|
<section className={cn("mt-4 rounded border p-3", getScadaPresentation(feature.properties.device_type_id).sectionClassName)}>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Image src={getScadaPresentation(feature.properties.device_type_id).iconPath} alt="" width={42} height={42} />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className={cn("truncate text-sm font-semibold", getScadaPresentation(feature.properties.device_type_id).labelClassName)}>{formatFeaturePropertyValue("point_name", feature.properties.point_name)}</p>
|
||||||
|
<p className="mt-0.5 truncate text-xs text-slate-500 tabular-nums">{formatFeaturePropertyValue("device_external_id", feature.properties.device_external_id)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-sm leading-6 text-slate-700">当前点位由管网密度聚类生成,仅用于空间展示。图层暂未提供实时测值或历史曲线。</p>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<section className="mt-4 rounded border border-orange-100 bg-orange-50/70 p-3">
|
<section className="mt-4 rounded border border-orange-100 bg-orange-50/70 p-3">
|
||||||
<div className="flex items-center gap-2 text-sm font-semibold text-orange-900">
|
<div className="flex items-center gap-2 text-sm font-semibold text-orange-900">
|
||||||
<TriangleAlert size={16} aria-hidden="true" />
|
<TriangleAlert size={16} aria-hidden="true" />
|
||||||
@@ -140,3 +163,11 @@ export function FeatureInsightPanel({ feature, onClose }: FeatureInsightPanelPro
|
|||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getScadaPresentation(rawTypeId: unknown) {
|
||||||
|
const typeId = Number(rawTypeId);
|
||||||
|
if (typeId === 72) return { iconPath: SCADA_ICON_PATHS.waterQuality, sectionClassName: "border-teal-200 bg-teal-50/70", labelClassName: "text-teal-800" };
|
||||||
|
if (typeId === 58) return { iconPath: SCADA_ICON_PATHS.radarLevel, sectionClassName: "border-blue-200 bg-blue-50/70", labelClassName: "text-blue-800" };
|
||||||
|
if (typeId === 45) return { iconPath: SCADA_ICON_PATHS.ultrasonicFlow, sectionClassName: "border-orange-200 bg-orange-50/70", labelClassName: "text-orange-800" };
|
||||||
|
return { iconPath: SCADA_ICON_PATHS.unknown, sectionClassName: "border-slate-200 bg-slate-50", labelClassName: "text-slate-800" };
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,19 +3,22 @@
|
|||||||
import {
|
import {
|
||||||
Check,
|
Check,
|
||||||
Copy,
|
Copy,
|
||||||
|
Database,
|
||||||
|
MapPin,
|
||||||
|
Radio,
|
||||||
X
|
X
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import Image from "next/image";
|
||||||
import type { ComponentType } from "react";
|
import type { ComponentType } from "react";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import {
|
import { MAP_MAJOR_PANEL_RADIUS_CLASS_NAME } from "@/features/map/core/components/map-control-styles";
|
||||||
MAP_ICON_CELL_RADIUS_CLASS_NAME,
|
|
||||||
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME
|
|
||||||
} from "@/features/map/core/components/map-control-styles";
|
|
||||||
import { showMapNotice } from "@/features/map/core/components/notice";
|
import { showMapNotice } from "@/features/map/core/components/notice";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { SCADA_ICON_PATHS } from "../map/scada";
|
||||||
import type { WaterNetworkSourceId } from "../map/sources";
|
import type { WaterNetworkSourceId } from "../map/sources";
|
||||||
import type { DetailFeature } from "../types";
|
import type { DetailFeature } from "../types";
|
||||||
import {
|
import {
|
||||||
|
formatFeaturePropertyValue,
|
||||||
getFeaturePanelModel,
|
getFeaturePanelModel,
|
||||||
type FeaturePanelBadgeTone
|
type FeaturePanelBadgeTone
|
||||||
} from "../utils/feature-properties";
|
} from "../utils/feature-properties";
|
||||||
@@ -35,13 +38,20 @@ type FeaturePopoverProps = {
|
|||||||
|
|
||||||
const FEATURE_LAYER_META: Record<
|
const FEATURE_LAYER_META: Record<
|
||||||
WaterNetworkSourceId,
|
WaterNetworkSourceId,
|
||||||
{ icon: ComponentType<DrainageFeatureIconProps>; label: string }
|
{
|
||||||
|
icon: ComponentType<DrainageFeatureIconProps>;
|
||||||
|
label: string;
|
||||||
|
headerClassName: string;
|
||||||
|
labelClassName: string;
|
||||||
|
iconClassName: string;
|
||||||
|
}
|
||||||
> = {
|
> = {
|
||||||
conduits: { icon: ConduitFeatureIcon, label: "管渠" },
|
conduits: { icon: ConduitFeatureIcon, label: "管渠", headerClassName: "bg-teal-50", labelClassName: "text-teal-700", iconClassName: "bg-teal-700 text-white ring-teal-900/10" },
|
||||||
junctions: { icon: JunctionFeatureIcon, label: "检查井" },
|
junctions: { icon: JunctionFeatureIcon, label: "检查井", headerClassName: "bg-sky-50", labelClassName: "text-sky-700", iconClassName: "bg-sky-700 text-white ring-sky-900/10" },
|
||||||
orifices: { icon: OrificeFeatureIcon, label: "孔口" },
|
orifices: { icon: OrificeFeatureIcon, label: "孔口", headerClassName: "bg-slate-100", labelClassName: "text-slate-700", iconClassName: "bg-slate-600 text-white ring-slate-900/10" },
|
||||||
outfalls: { icon: OutfallFeatureIcon, label: "排放口" },
|
outfalls: { icon: OutfallFeatureIcon, label: "排放口", headerClassName: "bg-blue-50", labelClassName: "text-blue-800", iconClassName: "bg-slate-700 text-white ring-slate-900/10" },
|
||||||
pumps: { icon: PumpFeatureIcon, label: "泵" }
|
pumps: { icon: PumpFeatureIcon, label: "泵", headerClassName: "bg-cyan-50", labelClassName: "text-cyan-800", iconClassName: "bg-cyan-800 text-white ring-cyan-950/10" },
|
||||||
|
scada: { icon: JunctionFeatureIcon, label: "SCADA 测点", headerClassName: "bg-blue-50", labelClassName: "text-blue-700", iconClassName: "bg-blue-700 text-white ring-blue-900/10" }
|
||||||
};
|
};
|
||||||
|
|
||||||
const BADGE_CLASS_NAMES: Record<FeaturePanelBadgeTone, string> = {
|
const BADGE_CLASS_NAMES: Record<FeaturePanelBadgeTone, string> = {
|
||||||
@@ -103,106 +113,81 @@ export function FeaturePopover({ feature, onClose }: FeaturePopoverProps) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (feature.layer === "scada") {
|
||||||
|
return (
|
||||||
|
<ScadaFeaturePopover
|
||||||
|
feature={feature}
|
||||||
|
copied={copied}
|
||||||
|
canCopy={canCopy}
|
||||||
|
onCopy={() => void handleCopyId()}
|
||||||
|
onClose={onClose}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside
|
<aside
|
||||||
className={cn(
|
className={cn(
|
||||||
"pointer-events-auto absolute left-1/2 top-[92px] z-20 w-[min(360px,calc(100vw-24px))] -translate-x-1/2 bg-white/90 p-3 shadow-2xl shadow-blue-950/20 ring-1 ring-white/80 backdrop-blur-xl",
|
"pointer-events-auto absolute left-1/2 top-[92px] z-20 w-[min(380px,calc(100vw-24px))] -translate-x-1/2 overflow-hidden bg-white/94 shadow-2xl shadow-blue-950/20 ring-1 ring-white/80 backdrop-blur-xl",
|
||||||
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME
|
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<header className="grid grid-cols-[40px_minmax(0,1fr)_auto] items-center gap-2.5">
|
<header className={cn("px-4 pb-3 pt-4", layerMeta.headerClassName)}>
|
||||||
<span
|
<div className="grid grid-cols-[48px_minmax(0,1fr)_auto] items-start gap-3">
|
||||||
className={cn(
|
<span className={cn("grid h-12 w-12 place-items-center rounded-full ring-1", layerMeta.iconClassName)} aria-hidden="true">
|
||||||
"grid h-10 w-10 place-items-center bg-blue-50 text-blue-700 ring-1 ring-blue-600/10",
|
<LayerIcon size={24} />
|
||||||
MAP_ICON_CELL_RADIUS_CLASS_NAME
|
</span>
|
||||||
)}
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<LayerIcon size={20} />
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0">
|
||||||
<div className="flex min-h-5 flex-wrap items-center gap-1.5">
|
<div className="flex min-w-0 flex-nowrap items-center gap-1.5">
|
||||||
<p className="text-xs font-semibold text-blue-700">{layerMeta.label}</p>
|
<span className={cn("min-w-0 truncate text-xs font-semibold", layerMeta.labelClassName)}>{layerMeta.label}</span>
|
||||||
{panelModel.badge ? (
|
{panelModel.badge ? (
|
||||||
<span
|
<span className={cn("inline-flex shrink-0 items-center gap-1.5 rounded-full px-2 py-0.5 text-xs font-semibold leading-4 ring-1 ring-inset", BADGE_CLASS_NAMES[panelModel.badge.tone])}>
|
||||||
className={cn(
|
<span className={cn("h-1.5 w-1.5 rounded-full", BADGE_DOT_CLASS_NAMES[panelModel.badge.tone])} aria-hidden="true" />
|
||||||
"inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-xs font-semibold leading-4 ring-1 ring-inset",
|
{panelModel.badge.label}
|
||||||
BADGE_CLASS_NAMES[panelModel.badge.tone]
|
</span>
|
||||||
)}
|
) : null}
|
||||||
>
|
</div>
|
||||||
<span
|
<h3 className="mt-1 truncate text-base font-semibold text-slate-950">{feature.title}</h3>
|
||||||
className={cn("h-1.5 w-1.5 rounded-full", BADGE_DOT_CLASS_NAMES[panelModel.badge.tone])}
|
<p className="mt-0.5 truncate text-xs text-slate-600 tabular-nums">编号 {panelModel.id}</p>
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
{panelModel.badge.label}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
<h3 className="mt-1 min-w-0 select-text break-all text-sm font-semibold leading-5 text-slate-950 tabular-nums">
|
|
||||||
{panelModel.id}
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-0.5">
|
||||||
{canCopy ? (
|
{canCopy ? (
|
||||||
<button
|
<button type="button" aria-label={copied ? "编号已复制" : "复制编号"} title={copied ? "编号已复制" : "复制编号"} onClick={() => void handleCopyId()} className="relative grid h-10 w-10 shrink-0 place-items-center rounded-xl text-slate-500 transition-[background-color,color,transform] duration-150 [@media(hover:hover)]:hover:bg-white/70 [@media(hover:hover)]:hover:text-slate-950 active:scale-95 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-600/25">
|
||||||
type="button"
|
<Copy size={15} aria-hidden="true" className={cn("absolute transition-[opacity,transform] duration-[120ms]", copied ? "scale-90 opacity-0" : "scale-100 opacity-100")} />
|
||||||
aria-label={copied ? "编号已复制" : "复制编号"}
|
<Check size={16} aria-hidden="true" className={cn("absolute text-emerald-600 transition-[opacity,transform] duration-[120ms]", copied ? "scale-100 opacity-100" : "scale-90 opacity-0")} />
|
||||||
title={copied ? "编号已复制" : "复制编号"}
|
</button>
|
||||||
onClick={() => void handleCopyId()}
|
) : null}
|
||||||
className="relative grid h-10 w-10 shrink-0 place-items-center rounded-xl text-slate-400 transition-[background-color,color,transform] duration-150 [@media(hover:hover)]:hover:bg-slate-100 [@media(hover:hover)]:hover:text-blue-700 active:scale-95 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-600/25"
|
<button type="button" aria-label="关闭要素信息" title="关闭要素信息" onClick={onClose} className="grid h-10 w-10 shrink-0 place-items-center rounded-xl text-slate-500 transition-[background-color,color,transform] duration-150 [@media(hover:hover)]:hover:bg-white/70 [@media(hover:hover)]:hover:text-slate-950 active:scale-95 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-600/25">
|
||||||
>
|
<X size={16} aria-hidden="true" />
|
||||||
<Copy
|
|
||||||
size={15}
|
|
||||||
aria-hidden="true"
|
|
||||||
className={cn(
|
|
||||||
"absolute transition-[opacity,transform] duration-[120ms]",
|
|
||||||
copied ? "scale-90 opacity-0" : "scale-100 opacity-100"
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<Check
|
|
||||||
size={16}
|
|
||||||
aria-hidden="true"
|
|
||||||
className={cn(
|
|
||||||
"absolute text-emerald-600 transition-[opacity,transform] duration-[120ms]",
|
|
||||||
copied ? "scale-100 opacity-100" : "scale-90 opacity-0"
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
</div>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label="关闭要素信息"
|
|
||||||
title="关闭要素信息"
|
|
||||||
onClick={onClose}
|
|
||||||
className="grid h-10 w-10 shrink-0 place-items-center rounded-xl text-slate-400 transition-[background-color,color,transform] duration-150 [@media(hover:hover)]:hover:bg-slate-100 [@media(hover:hover)]:hover:text-slate-900 active:scale-95 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-600/25"
|
|
||||||
>
|
|
||||||
<X size={16} aria-hidden="true" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{panelModel.attributes.length > 0 ? (
|
<div className="px-4 py-3">
|
||||||
<dl className="mt-2 border-y border-slate-200/80">
|
{panelModel.attributes.length > 0 ? (
|
||||||
{panelModel.attributes.map((entry) => (
|
<>
|
||||||
<div
|
<div className="mb-2 flex items-center gap-2 text-xs font-semibold text-slate-500">
|
||||||
key={entry.key}
|
<Database size={14} aria-hidden="true" />
|
||||||
className="grid min-h-10 grid-cols-[96px_minmax(0,1fr)] items-baseline gap-3 border-b border-slate-200/70 px-1 py-2.5 last:border-b-0"
|
设施属性
|
||||||
>
|
|
||||||
<dt className="text-xs font-medium leading-5 text-slate-500">{entry.label}</dt>
|
|
||||||
<dd
|
|
||||||
className={cn(
|
|
||||||
"break-words text-right text-sm font-semibold leading-5 text-slate-800 tabular-nums",
|
|
||||||
entry.value === "暂无" && "font-medium text-slate-400"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{entry.value}
|
|
||||||
</dd>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
<dl className="divide-y divide-slate-100 border-y border-slate-200/80">
|
||||||
</dl>
|
{panelModel.attributes.map((entry) => (
|
||||||
) : null}
|
<div key={entry.key} className="grid min-h-10 grid-cols-[96px_minmax(0,1fr)] items-baseline gap-3 py-2.5">
|
||||||
|
<dt className="text-xs font-medium leading-5 text-slate-500">{entry.label}</dt>
|
||||||
|
<dd className={cn("break-words text-right text-sm font-semibold leading-5 text-slate-800 tabular-nums", entry.value === "暂无" && "font-medium text-slate-400")}>
|
||||||
|
{entry.value}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="py-3 text-center text-sm text-slate-500">暂无可展示属性</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<span className="sr-only" aria-live="polite">
|
<span className="sr-only" aria-live="polite">
|
||||||
{copied ? "编号已复制" : ""}
|
{copied ? "编号已复制" : ""}
|
||||||
@@ -210,3 +195,101 @@ export function FeaturePopover({ feature, onClose }: FeaturePopoverProps) {
|
|||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<aside
|
||||||
|
className={cn(
|
||||||
|
"pointer-events-auto absolute left-1/2 top-[92px] z-20 w-[min(380px,calc(100vw-24px))] bg-white/94 shadow-2xl shadow-blue-950/20 ring-1 ring-white/80 backdrop-blur-xl",
|
||||||
|
"-translate-x-1/2 overflow-hidden",
|
||||||
|
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<header className={cn("px-4 pb-3 pt-4", presentation.headerClassName)}>
|
||||||
|
<div className="grid grid-cols-[48px_minmax(0,1fr)_auto] items-start gap-3">
|
||||||
|
<Image src={presentation.iconPath} alt="" width={48} height={48} className="drop-shadow-sm" />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex min-w-0 flex-nowrap items-center gap-1.5">
|
||||||
|
<span className={cn("min-w-0 truncate text-xs font-semibold", presentation.labelClassName)} title={formatFeaturePropertyValue("device_type_name", properties.device_type_name)}>
|
||||||
|
{formatFeaturePropertyValue("device_type_name", properties.device_type_name)}
|
||||||
|
</span>
|
||||||
|
<span className={cn(
|
||||||
|
"inline-flex shrink-0 items-center gap-1.5 rounded-full bg-white/80 px-2 py-0.5 text-xs font-semibold ring-1 ring-inset",
|
||||||
|
active ? "text-emerald-700 ring-emerald-600/20" : "text-slate-600 ring-slate-500/20"
|
||||||
|
)}>
|
||||||
|
<span className={cn("h-1.5 w-1.5 rounded-full", active ? "bg-emerald-500" : "bg-slate-400")} />
|
||||||
|
{active ? "在线" : "离线"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<h3 className="mt-1 truncate text-base font-semibold text-slate-950">
|
||||||
|
{formatFeaturePropertyValue("point_name", properties.point_name)}
|
||||||
|
</h3>
|
||||||
|
<p className="mt-0.5 truncate text-xs text-slate-600 tabular-nums">测点 {pointId}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-0.5">
|
||||||
|
{canCopy ? (
|
||||||
|
<button type="button" aria-label={copied ? "测点编号已复制" : "复制测点编号"} title={copied ? "测点编号已复制" : "复制测点编号"} onClick={onCopy} className="relative grid h-10 w-10 place-items-center rounded-xl text-slate-500 transition-[background-color,color,transform] duration-150 [@media(hover:hover)]:hover:bg-white/70 [@media(hover:hover)]:hover:text-slate-950 active:scale-95 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-600/25">
|
||||||
|
<Copy size={15} aria-hidden="true" className={cn("absolute transition-[opacity,transform] duration-[120ms]", copied ? "scale-90 opacity-0" : "scale-100 opacity-100")} />
|
||||||
|
<Check size={16} aria-hidden="true" className={cn("absolute text-emerald-600 transition-[opacity,transform] duration-[120ms]", copied ? "scale-100 opacity-100" : "scale-90 opacity-0")} />
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
<button type="button" aria-label="关闭 SCADA 测点信息" title="关闭 SCADA 测点信息" onClick={onClose} className="grid h-10 w-10 place-items-center rounded-xl text-slate-500 transition-[background-color,color,transform] duration-150 [@media(hover:hover)]:hover:bg-white/70 [@media(hover:hover)]:hover:text-slate-950 active:scale-95 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-600/25">
|
||||||
|
<X size={16} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="px-4 py-3">
|
||||||
|
<div className="mb-2 flex items-center gap-2 text-xs font-semibold text-slate-500">
|
||||||
|
<Radio size={14} aria-hidden="true" />
|
||||||
|
设备与关联信息
|
||||||
|
</div>
|
||||||
|
<dl className="divide-y divide-slate-100 border-y border-slate-200/80">
|
||||||
|
{attributes.map(([label, value]) => (
|
||||||
|
<div key={label} className="grid min-h-10 grid-cols-[92px_minmax(0,1fr)] items-baseline gap-3 py-2.5">
|
||||||
|
<dt className="text-xs font-medium text-slate-500">{label}</dt>
|
||||||
|
<dd className={cn("break-words text-right text-sm font-semibold text-slate-800 tabular-nums", value === "暂无" && "font-medium text-slate-400")}>{value}</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<div className="mt-3 grid grid-cols-[18px_1fr] gap-2 rounded-xl bg-slate-50 px-3 py-2.5 text-xs leading-5 text-slate-600 ring-1 ring-inset ring-slate-200/70">
|
||||||
|
<MapPin size={15} className="mt-0.5 text-slate-500" aria-hidden="true" />
|
||||||
|
<p>当前点位由管网密度聚类生成,用于空间展示,不代表设备真实安装坐标。</p>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 flex items-center gap-2 px-1 text-xs text-slate-400">
|
||||||
|
<Database size={13} aria-hidden="true" />
|
||||||
|
当前图层仅提供设备元数据,暂未接入实时测值与历史曲线
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="sr-only" aria-live="polite">{copied ? "测点编号已复制" : ""}</span>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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" };
|
||||||
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -10,23 +10,48 @@ import {
|
|||||||
toDetailFeature
|
toDetailFeature
|
||||||
} from "../map/feature-adapter";
|
} from "../map/feature-adapter";
|
||||||
import { INTERACTIVE_HIT_LAYER_IDS } from "../map/layers";
|
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<WaterNetworkSourceId, readonly string[]> = {
|
export type MapFeatureInteractionState = {
|
||||||
conduits: ["pipes-hover-outline", "pipes-hover"],
|
source: WaterNetworkSourceId;
|
||||||
junctions: ["junctions-hover"],
|
sourceLayer: string;
|
||||||
orifices: ["orifices-hover-outline", "orifices-hover"],
|
id: string;
|
||||||
pumps: ["pumps-hover-outline", "pumps-hover"],
|
hovered: boolean;
|
||||||
outfalls: ["outfalls-hover"]
|
selected: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const SELECTED_LAYER_IDS: Record<WaterNetworkSourceId, readonly string[]> = {
|
type FeatureStateMap = Pick<MapLibreMap, "setFeatureState" | "removeFeatureState">;
|
||||||
conduits: ["pipes-selected-halo", "pipes-selected-outline", "pipes-selected"],
|
type FeatureReference = Pick<MapFeatureInteractionState, "source" | "sourceLayer" | "id">;
|
||||||
junctions: ["junctions-selected-halo", "junctions-selected"],
|
|
||||||
orifices: ["orifices-selected-halo", "orifices-selected-outline", "orifices-selected"],
|
export function toMapFeatureReference(
|
||||||
pumps: ["pumps-selected-halo", "pumps-selected-outline", "pumps-selected"],
|
feature: Pick<DetailFeature, "id" | "layer"> | null
|
||||||
outfalls: ["outfalls-selected-halo", "outfalls-selected"]
|
): 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<Pick<MapFeatureInteractionState, "hovered" | "selected">>
|
||||||
|
) {
|
||||||
|
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 = {
|
type UseMapInteractionsOptions = {
|
||||||
mapRef: RefObject<MapLibreMap | null>;
|
mapRef: RefObject<MapLibreMap | null>;
|
||||||
@@ -43,88 +68,65 @@ export function useMapInteractions({
|
|||||||
}: UseMapInteractionsOptions) {
|
}: UseMapInteractionsOptions) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const map = mapRef.current;
|
const map = mapRef.current;
|
||||||
if (!mapReady || !map) {
|
if (!mapReady || !map) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const activeMap = map;
|
let hoveredFeature: FeatureReference | null = null;
|
||||||
let hoveredFeature: { id: string; sourceId: WaterNetworkSourceId } | null = null;
|
|
||||||
|
|
||||||
function clearHoveredFeature() {
|
const clearHoveredFeature = () => {
|
||||||
if (!hoveredFeature) {
|
if (!hoveredFeature) return;
|
||||||
return;
|
clearMapFeatureInteractionState(map, hoveredFeature, "hovered");
|
||||||
}
|
|
||||||
|
|
||||||
HOVER_LAYER_IDS[hoveredFeature.sourceId].forEach((layerId) => {
|
|
||||||
activeMap.setFilter(layerId, ["==", ["get", "id"], ""]);
|
|
||||||
});
|
|
||||||
hoveredFeature = null;
|
hoveredFeature = null;
|
||||||
}
|
};
|
||||||
|
|
||||||
function handleMouseMove(event: MapLayerMouseEvent) {
|
const handleMouseMove = (event: MapLayerMouseEvent) => {
|
||||||
const feature = event.features?.[0];
|
const feature = event.features?.[0];
|
||||||
if (!feature) {
|
const source = feature && getWaterNetworkSourceId(feature);
|
||||||
return;
|
const id = feature && getFeatureId(feature);
|
||||||
}
|
if (!source || !id) return;
|
||||||
|
|
||||||
activeMap.getCanvas().style.cursor = "pointer";
|
const next = { source, sourceLayer: SOURCE_LAYERS[source], id };
|
||||||
const id = getFeatureId(feature);
|
if (selectedFeature?.id === id && selectedFeature.layer === source) {
|
||||||
const sourceId = getWaterNetworkSourceId(feature);
|
|
||||||
if (!id || !sourceId) {
|
|
||||||
clearHoveredFeature();
|
clearHoveredFeature();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (hoveredFeature?.id === id && hoveredFeature.source === source) return;
|
||||||
if (hoveredFeature?.id === id && hoveredFeature.sourceId === sourceId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
clearHoveredFeature();
|
clearHoveredFeature();
|
||||||
HOVER_LAYER_IDS[sourceId].forEach((layerId) => {
|
setMapFeatureInteractionState(map, next, { hovered: true });
|
||||||
activeMap.setFilter(layerId, ["==", ["get", "id"], id]);
|
hoveredFeature = next;
|
||||||
});
|
map.getCanvas().style.cursor = "pointer";
|
||||||
hoveredFeature = { id, sourceId };
|
};
|
||||||
}
|
|
||||||
|
|
||||||
function handleMouseLeave() {
|
const handleMouseLeave = () => {
|
||||||
activeMap.getCanvas().style.cursor = "";
|
|
||||||
clearHoveredFeature();
|
clearHoveredFeature();
|
||||||
}
|
map.getCanvas().style.cursor = "";
|
||||||
|
};
|
||||||
|
|
||||||
function handleClick(event: MapLayerMouseEvent) {
|
const handleClick = (event: MapLayerMouseEvent) => {
|
||||||
const feature = event.features?.[0];
|
const feature = event.features?.[0];
|
||||||
if (feature) {
|
if (feature) onSelectFeature(toDetailFeature(feature));
|
||||||
onSelectFeature(toDetailFeature(feature));
|
};
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
activeMap.on("mousemove", INTERACTIVE_HIT_LAYER_IDS, handleMouseMove);
|
const hitLayerIds = [SCADA_HIT_LAYER_ID, ...INTERACTIVE_HIT_LAYER_IDS];
|
||||||
activeMap.on("mouseleave", INTERACTIVE_HIT_LAYER_IDS, handleMouseLeave);
|
map.on("mousemove", hitLayerIds, handleMouseMove);
|
||||||
activeMap.on("click", INTERACTIVE_HIT_LAYER_IDS, handleClick);
|
map.on("mouseleave", hitLayerIds, handleMouseLeave);
|
||||||
|
map.on("click", hitLayerIds, handleClick);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
activeMap.off("mousemove", INTERACTIVE_HIT_LAYER_IDS, handleMouseMove);
|
clearHoveredFeature();
|
||||||
activeMap.off("mouseleave", INTERACTIVE_HIT_LAYER_IDS, handleMouseLeave);
|
map.off("mousemove", hitLayerIds, handleMouseMove);
|
||||||
activeMap.off("click", INTERACTIVE_HIT_LAYER_IDS, handleClick);
|
map.off("mouseleave", hitLayerIds, handleMouseLeave);
|
||||||
|
map.off("click", hitLayerIds, handleClick);
|
||||||
};
|
};
|
||||||
}, [mapRef, mapReady, onSelectFeature]);
|
}, [mapRef, mapReady, onSelectFeature, selectedFeature]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const map = mapRef.current;
|
const map = mapRef.current;
|
||||||
if (!mapReady || !map) {
|
if (!mapReady || !map) return;
|
||||||
return;
|
const next = toMapFeatureReference(selectedFeature);
|
||||||
}
|
if (next) setMapFeatureInteractionState(map, next, { selected: true, hovered: false });
|
||||||
|
return () => {
|
||||||
Object.values(SELECTED_LAYER_IDS).flat().forEach((layerId) => {
|
if (next) clearMapFeatureInteractionState(map, next, "selected");
|
||||||
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]);
|
}, [mapReady, mapRef, selectedFeature]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,9 +14,19 @@ import {
|
|||||||
fitNetworkBounds,
|
fitNetworkBounds,
|
||||||
getResponsiveWorkbenchPadding
|
getResponsiveWorkbenchPadding
|
||||||
} from "../map/camera";
|
} 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 { MAP_MAX_ZOOM } from "../map/map-layer-visuals";
|
||||||
import { setSimulationLayersVisibility } from "../map/simulation-layers";
|
import { setSimulationLayersVisibility } from "../map/simulation-layers";
|
||||||
|
import {
|
||||||
|
registerScadaImages,
|
||||||
|
scadaFallbackLayers,
|
||||||
|
scadaLayers,
|
||||||
|
type ScadaImageRegistrationResult
|
||||||
|
} from "../map/scada";
|
||||||
import {
|
import {
|
||||||
createBaseStyle,
|
createBaseStyle,
|
||||||
createWaterNetworkSources,
|
createWaterNetworkSources,
|
||||||
@@ -60,6 +70,7 @@ export type WorkbenchSourceStatus = {
|
|||||||
const SOURCE_STATUS_LABELS: Record<string, string> = {
|
const SOURCE_STATUS_LABELS: Record<string, string> = {
|
||||||
"mapbox-base": "Mapbox 底图",
|
"mapbox-base": "Mapbox 底图",
|
||||||
"geoserver-mvt": "GeoServer MVT",
|
"geoserver-mvt": "GeoServer MVT",
|
||||||
|
"scada-icons": "SCADA 图标",
|
||||||
"annotation-source": "业务标注源"
|
"annotation-source": "业务标注源"
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -71,6 +82,7 @@ const SOURCE_GROUP_BY_ID: Record<string, string> = {
|
|||||||
orifices: "geoserver-mvt",
|
orifices: "geoserver-mvt",
|
||||||
outfalls: "geoserver-mvt",
|
outfalls: "geoserver-mvt",
|
||||||
pumps: "geoserver-mvt",
|
pumps: "geoserver-mvt",
|
||||||
|
scada: "geoserver-mvt",
|
||||||
[SIMULATION_SOURCE_IDS.impactArea]: "annotation-source",
|
[SIMULATION_SOURCE_IDS.impactArea]: "annotation-source",
|
||||||
[SIMULATION_SOURCE_IDS.annotations]: "annotation-source"
|
[SIMULATION_SOURCE_IDS.annotations]: "annotation-source"
|
||||||
};
|
};
|
||||||
@@ -117,7 +129,7 @@ export function useWorkbenchMap({
|
|||||||
|
|
||||||
window.__waterNetworkMap = map;
|
window.__waterNetworkMap = map;
|
||||||
|
|
||||||
map.on("load", () => {
|
map.on("load", async () => {
|
||||||
map.resize();
|
map.resize();
|
||||||
const sources = createWaterNetworkSources();
|
const sources = createWaterNetworkSources();
|
||||||
map.addSource("conduits", sources.conduits);
|
map.addSource("conduits", sources.conduits);
|
||||||
@@ -125,15 +137,47 @@ export function useWorkbenchMap({
|
|||||||
map.addSource("orifices", sources.orifices);
|
map.addSource("orifices", sources.orifices);
|
||||||
map.addSource("outfalls", sources.outfalls);
|
map.addSource("outfalls", sources.outfalls);
|
||||||
map.addSource("pumps", sources.pumps);
|
map.addSource("pumps", sources.pumps);
|
||||||
|
map.addSource("scada", sources.scada);
|
||||||
map.addSource(SIMULATION_SOURCE_IDS.impactArea, simulationSources.impactArea);
|
map.addSource(SIMULATION_SOURCE_IDS.impactArea, simulationSources.impactArea);
|
||||||
map.addSource(SIMULATION_SOURCE_IDS.annotations, simulationSources.annotations);
|
map.addSource(SIMULATION_SOURCE_IDS.annotations, simulationSources.annotations);
|
||||||
waterNetworkLayers.forEach((layer) => map.addLayer(layer));
|
simulationAnnotationLayers.filter((layer) => layer.id === "simulation-impact-fill").forEach((layer) => map.addLayer(layer));
|
||||||
simulationAnnotationLayers.forEach((layer) => map.addLayer(layer));
|
waterNetworkBusinessLayers.forEach((layer) => map.addLayer(layer));
|
||||||
setSimulationLayersVisibility(map, impactVisibleRef.current);
|
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);
|
try {
|
||||||
updateSourceStatus(setSourceStatuses, "annotation-source", "online", "业务标注源已就绪。");
|
scadaRegistration = await registerScadaImages(map);
|
||||||
fitNetworkBounds(map, WATER_NETWORK_GLOBAL_VIEW);
|
} 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();
|
const resizeMap = () => map.resize();
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { GeoJSONSourceSpecification, StyleSpecification } from "maplibre-gl";
|
import type { GeoJSONSourceSpecification, StyleSpecification } from "maplibre-gl";
|
||||||
import { annotationPoints, impactAreaPolygon } from "../data/workbench-simulation";
|
import { annotationPoints, impactAreaPolygon } from "../data/workbench-simulation";
|
||||||
|
import { MAP_STYLE_TOKENS } from "./map-colors";
|
||||||
|
|
||||||
export const SIMULATION_SOURCE_IDS = {
|
export const SIMULATION_SOURCE_IDS = {
|
||||||
impactArea: "simulation-impact-area",
|
impactArea: "simulation-impact-area",
|
||||||
@@ -23,8 +24,8 @@ export const simulationAnnotationLayers: StyleSpecification["layers"] = [
|
|||||||
type: "fill",
|
type: "fill",
|
||||||
source: SIMULATION_SOURCE_IDS.impactArea,
|
source: SIMULATION_SOURCE_IDS.impactArea,
|
||||||
paint: {
|
paint: {
|
||||||
"fill-color": "#ef4444",
|
"fill-color": MAP_STYLE_TOKENS.state.risk,
|
||||||
"fill-opacity": 0.18
|
"fill-opacity": MAP_STYLE_TOKENS.opacity.area
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -32,7 +33,7 @@ export const simulationAnnotationLayers: StyleSpecification["layers"] = [
|
|||||||
type: "line",
|
type: "line",
|
||||||
source: SIMULATION_SOURCE_IDS.impactArea,
|
source: SIMULATION_SOURCE_IDS.impactArea,
|
||||||
paint: {
|
paint: {
|
||||||
"line-color": "#ef4444",
|
"line-color": MAP_STYLE_TOKENS.state.risk,
|
||||||
"line-width": 1.5,
|
"line-width": 1.5,
|
||||||
"line-dasharray": [2, 2],
|
"line-dasharray": [2, 2],
|
||||||
"line-opacity": 0.72
|
"line-opacity": 0.72
|
||||||
@@ -44,9 +45,9 @@ export const simulationAnnotationLayers: StyleSpecification["layers"] = [
|
|||||||
source: SIMULATION_SOURCE_IDS.annotations,
|
source: SIMULATION_SOURCE_IDS.annotations,
|
||||||
filter: ["==", ["get", "kind"], "burst"],
|
filter: ["==", ["get", "kind"], "burst"],
|
||||||
paint: {
|
paint: {
|
||||||
"circle-color": "#fef2f2",
|
"circle-color": MAP_STYLE_TOKENS.canvas.casing,
|
||||||
"circle-radius": 18,
|
"circle-radius": 18,
|
||||||
"circle-stroke-color": "#ef4444",
|
"circle-stroke-color": MAP_STYLE_TOKENS.state.incident,
|
||||||
"circle-stroke-width": 3,
|
"circle-stroke-width": 3,
|
||||||
"circle-opacity": 0.88
|
"circle-opacity": 0.88
|
||||||
}
|
}
|
||||||
@@ -57,9 +58,9 @@ export const simulationAnnotationLayers: StyleSpecification["layers"] = [
|
|||||||
source: SIMULATION_SOURCE_IDS.annotations,
|
source: SIMULATION_SOURCE_IDS.annotations,
|
||||||
filter: ["==", ["get", "kind"], "burst"],
|
filter: ["==", ["get", "kind"], "burst"],
|
||||||
paint: {
|
paint: {
|
||||||
"circle-color": "#ef4444",
|
"circle-color": MAP_STYLE_TOKENS.state.incident,
|
||||||
"circle-radius": 7,
|
"circle-radius": 7,
|
||||||
"circle-stroke-color": "#ffffff",
|
"circle-stroke-color": MAP_STYLE_TOKENS.canvas.casing,
|
||||||
"circle-stroke-width": 3
|
"circle-stroke-width": 3
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -75,8 +76,8 @@ export const simulationAnnotationLayers: StyleSpecification["layers"] = [
|
|||||||
"text-allow-overlap": true
|
"text-allow-overlap": true
|
||||||
},
|
},
|
||||||
paint: {
|
paint: {
|
||||||
"text-color": "#ffffff",
|
"text-color": MAP_STYLE_TOKENS.label.inverse,
|
||||||
"text-halo-color": "#2563eb",
|
"text-halo-color": MAP_STYLE_TOKENS.state.agent,
|
||||||
"text-halo-width": 9
|
"text-halo-width": 9
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -93,8 +94,8 @@ export const simulationAnnotationLayers: StyleSpecification["layers"] = [
|
|||||||
"text-allow-overlap": true
|
"text-allow-overlap": true
|
||||||
},
|
},
|
||||||
paint: {
|
paint: {
|
||||||
"text-color": "#dc2626",
|
"text-color": MAP_STYLE_TOKENS.state.risk,
|
||||||
"text-halo-color": "#ffffff",
|
"text-halo-color": MAP_STYLE_TOKENS.canvas.casing,
|
||||||
"text-halo-width": 4
|
"text-halo-width": 4
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -110,8 +111,8 @@ export const simulationAnnotationLayers: StyleSpecification["layers"] = [
|
|||||||
"text-allow-overlap": true
|
"text-allow-overlap": true
|
||||||
},
|
},
|
||||||
paint: {
|
paint: {
|
||||||
"text-color": "#334155",
|
"text-color": MAP_STYLE_TOKENS.label.secondary,
|
||||||
"text-halo-color": "#ffffff",
|
"text-halo-color": MAP_STYLE_TOKENS.canvas.casing,
|
||||||
"text-halo-width": 3
|
"text-halo-width": 3
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -129,8 +130,8 @@ export const simulationAnnotationLayers: StyleSpecification["layers"] = [
|
|||||||
"text-allow-overlap": true
|
"text-allow-overlap": true
|
||||||
},
|
},
|
||||||
paint: {
|
paint: {
|
||||||
"text-color": "#0f172a",
|
"text-color": MAP_STYLE_TOKENS.label.primary,
|
||||||
"text-halo-color": "#ffffff",
|
"text-halo-color": MAP_STYLE_TOKENS.canvas.casing,
|
||||||
"text-halo-width": 5
|
"text-halo-width": 5
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,18 +5,16 @@ import {
|
|||||||
getContrastRatio
|
getContrastRatio
|
||||||
} from "./color-contrast";
|
} from "./color-contrast";
|
||||||
import {
|
import {
|
||||||
CLOSED_CONDUIT_COLOR,
|
MAP_STYLE_TOKENS
|
||||||
DRAINAGE_SELECTED_OUTLINE_COLORS,
|
|
||||||
DRAINAGE_SOURCE_COLORS,
|
|
||||||
DRAINAGE_SOURCE_OPACITIES,
|
|
||||||
MAP_BACKGROUND_COLORS
|
|
||||||
} from "./map-colors";
|
} from "./map-colors";
|
||||||
import { createBaseStyle } from "./sources";
|
import { createBaseStyle } from "./sources";
|
||||||
|
|
||||||
type DrainageColorId = keyof typeof DRAINAGE_SOURCE_COLORS;
|
const sourceColors = Object.values(MAP_STYLE_TOKENS.asset);
|
||||||
|
const mapBackgrounds = [
|
||||||
const drainageColorIds = Object.keys(DRAINAGE_SOURCE_COLORS) as DrainageColorId[];
|
MAP_STYLE_TOKENS.canvas.primary,
|
||||||
const mapBackgrounds = Object.values(MAP_BACKGROUND_COLORS);
|
MAP_STYLE_TOKENS.canvas.secondary,
|
||||||
|
MAP_STYLE_TOKENS.canvas.secondaryAlt
|
||||||
|
];
|
||||||
|
|
||||||
describe("map color contrast", () => {
|
describe("map color contrast", () => {
|
||||||
it("calculates WCAG contrast and alpha composites", () => {
|
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", () => {
|
it("keeps rendered source colors above 3:1 on every map background", () => {
|
||||||
drainageColorIds.forEach((sourceId) => {
|
sourceColors.forEach((sourceColor) => {
|
||||||
mapBackgrounds.forEach((background) => {
|
mapBackgrounds.forEach((background) => {
|
||||||
const renderedColor = compositeHexColor(
|
const renderedColor = compositeHexColor(
|
||||||
DRAINAGE_SOURCE_COLORS[sourceId],
|
sourceColor,
|
||||||
background,
|
background,
|
||||||
DRAINAGE_SOURCE_OPACITIES[sourceId]
|
MAP_STYLE_TOKENS.opacity.core
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
@@ -48,14 +46,14 @@ describe("map color contrast", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps selected outlines above 3:1 on every map background", () => {
|
it("keeps selected outlines above 4.5:1 on every map background", () => {
|
||||||
drainageColorIds.forEach((sourceId) => {
|
sourceColors.forEach(() => {
|
||||||
mapBackgrounds.forEach((background) => {
|
mapBackgrounds.forEach((background) => {
|
||||||
const selectedColor = DRAINAGE_SELECTED_OUTLINE_COLORS[sourceId];
|
const selectedColor = MAP_STYLE_TOKENS.state.selected;
|
||||||
expect(
|
expect(
|
||||||
getContrastRatio(selectedColor, background),
|
getContrastRatio(selectedColor, background),
|
||||||
createCoolorsContrastUrl(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", () => {
|
it("keeps closed conduits above 3:1 and applies the primary map background", () => {
|
||||||
mapBackgrounds.forEach((background) => {
|
mapBackgrounds.forEach((background) => {
|
||||||
const renderedColor = compositeHexColor(
|
const renderedColor = compositeHexColor(
|
||||||
CLOSED_CONDUIT_COLOR,
|
MAP_STYLE_TOKENS.state.inactive,
|
||||||
background,
|
background,
|
||||||
DRAINAGE_SOURCE_OPACITIES.conduits
|
MAP_STYLE_TOKENS.opacity.core
|
||||||
);
|
);
|
||||||
expect(getContrastRatio(renderedColor, background)).toBeGreaterThanOrEqual(3);
|
expect(getContrastRatio(renderedColor, background)).toBeGreaterThanOrEqual(3);
|
||||||
});
|
});
|
||||||
@@ -74,6 +72,6 @@ describe("map color contrast", () => {
|
|||||||
if (backgroundLayer?.type !== "background") {
|
if (backgroundLayer?.type !== "background") {
|
||||||
throw new Error("Expected the base style to contain a background layer.");
|
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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import type {
|
|||||||
} from "maplibre-gl";
|
} from "maplibre-gl";
|
||||||
import type { FeatureCollection, Geometry, LineString, Point, Polygon } from "geojson";
|
import type { FeatureCollection, Geometry, LineString, Point, Polygon } from "geojson";
|
||||||
import { createCircleCoordinates } from "./geo";
|
import { createCircleCoordinates } from "./geo";
|
||||||
|
import { MAP_STYLE_TOKENS } from "./map-colors";
|
||||||
|
import { WORKBENCH_INTERACTION_BEFORE_ID } from "./layers";
|
||||||
|
|
||||||
export const DRAWING_SOURCE_IDS = {
|
export const DRAWING_SOURCE_IDS = {
|
||||||
features: "workbench-drawing-features",
|
features: "workbench-drawing-features",
|
||||||
@@ -54,7 +56,7 @@ export const drawingLayers: StyleSpecification["layers"] = [
|
|||||||
source: DRAWING_SOURCE_IDS.features,
|
source: DRAWING_SOURCE_IDS.features,
|
||||||
filter: ["==", ["geometry-type"], "Polygon"],
|
filter: ["==", ["geometry-type"], "Polygon"],
|
||||||
paint: {
|
paint: {
|
||||||
"fill-color": "#2563eb",
|
"fill-color": MAP_STYLE_TOKENS.tool.geometry,
|
||||||
"fill-opacity": 0.16
|
"fill-opacity": 0.16
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -64,7 +66,7 @@ export const drawingLayers: StyleSpecification["layers"] = [
|
|||||||
source: DRAWING_SOURCE_IDS.features,
|
source: DRAWING_SOURCE_IDS.features,
|
||||||
filter: ["==", ["geometry-type"], "Polygon"],
|
filter: ["==", ["geometry-type"], "Polygon"],
|
||||||
paint: {
|
paint: {
|
||||||
"line-color": "#2563eb",
|
"line-color": MAP_STYLE_TOKENS.tool.geometry,
|
||||||
"line-width": 2,
|
"line-width": 2,
|
||||||
"line-opacity": 0.82
|
"line-opacity": 0.82
|
||||||
}
|
}
|
||||||
@@ -75,7 +77,7 @@ export const drawingLayers: StyleSpecification["layers"] = [
|
|||||||
source: DRAWING_SOURCE_IDS.features,
|
source: DRAWING_SOURCE_IDS.features,
|
||||||
filter: ["==", ["geometry-type"], "LineString"],
|
filter: ["==", ["geometry-type"], "LineString"],
|
||||||
paint: {
|
paint: {
|
||||||
"line-color": "#2563eb",
|
"line-color": MAP_STYLE_TOKENS.tool.geometry,
|
||||||
"line-width": 3,
|
"line-width": 3,
|
||||||
"line-opacity": 0.88
|
"line-opacity": 0.88
|
||||||
}
|
}
|
||||||
@@ -86,9 +88,9 @@ export const drawingLayers: StyleSpecification["layers"] = [
|
|||||||
source: DRAWING_SOURCE_IDS.features,
|
source: DRAWING_SOURCE_IDS.features,
|
||||||
filter: ["==", ["geometry-type"], "Point"],
|
filter: ["==", ["geometry-type"], "Point"],
|
||||||
paint: {
|
paint: {
|
||||||
"circle-color": "#2563eb",
|
"circle-color": MAP_STYLE_TOKENS.tool.geometry,
|
||||||
"circle-radius": 6,
|
"circle-radius": 6,
|
||||||
"circle-stroke-color": "#ffffff",
|
"circle-stroke-color": MAP_STYLE_TOKENS.canvas.casing,
|
||||||
"circle-stroke-width": 2
|
"circle-stroke-width": 2
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -98,7 +100,7 @@ export const drawingLayers: StyleSpecification["layers"] = [
|
|||||||
source: DRAWING_SOURCE_IDS.features,
|
source: DRAWING_SOURCE_IDS.features,
|
||||||
filter: ["all", ["==", ["geometry-type"], "Polygon"], ["==", ["get", "id"], ""]],
|
filter: ["all", ["==", ["geometry-type"], "Polygon"], ["==", ["get", "id"], ""]],
|
||||||
paint: {
|
paint: {
|
||||||
"line-color": "#ff7a45",
|
"line-color": MAP_STYLE_TOKENS.state.selected,
|
||||||
"line-width": 4,
|
"line-width": 4,
|
||||||
"line-opacity": 0.86
|
"line-opacity": 0.86
|
||||||
}
|
}
|
||||||
@@ -109,7 +111,7 @@ export const drawingLayers: StyleSpecification["layers"] = [
|
|||||||
source: DRAWING_SOURCE_IDS.features,
|
source: DRAWING_SOURCE_IDS.features,
|
||||||
filter: ["all", ["==", ["geometry-type"], "LineString"], ["==", ["get", "id"], ""]],
|
filter: ["all", ["==", ["geometry-type"], "LineString"], ["==", ["get", "id"], ""]],
|
||||||
paint: {
|
paint: {
|
||||||
"line-color": "#ff7a45",
|
"line-color": MAP_STYLE_TOKENS.state.selected,
|
||||||
"line-width": 6,
|
"line-width": 6,
|
||||||
"line-opacity": 0.72
|
"line-opacity": 0.72
|
||||||
}
|
}
|
||||||
@@ -120,10 +122,10 @@ export const drawingLayers: StyleSpecification["layers"] = [
|
|||||||
source: DRAWING_SOURCE_IDS.features,
|
source: DRAWING_SOURCE_IDS.features,
|
||||||
filter: ["all", ["==", ["geometry-type"], "Point"], ["==", ["get", "id"], ""]],
|
filter: ["all", ["==", ["geometry-type"], "Point"], ["==", ["get", "id"], ""]],
|
||||||
paint: {
|
paint: {
|
||||||
"circle-color": "#ff7a45",
|
"circle-color": MAP_STYLE_TOKENS.state.selected,
|
||||||
"circle-radius": 10,
|
"circle-radius": 10,
|
||||||
"circle-opacity": 0.2,
|
"circle-opacity": 0.2,
|
||||||
"circle-stroke-color": "#ff7a45",
|
"circle-stroke-color": MAP_STYLE_TOKENS.state.selected,
|
||||||
"circle-stroke-width": 2
|
"circle-stroke-width": 2
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -133,7 +135,7 @@ export const drawingLayers: StyleSpecification["layers"] = [
|
|||||||
source: DRAWING_SOURCE_IDS.draft,
|
source: DRAWING_SOURCE_IDS.draft,
|
||||||
filter: ["==", ["geometry-type"], "Polygon"],
|
filter: ["==", ["geometry-type"], "Polygon"],
|
||||||
paint: {
|
paint: {
|
||||||
"fill-color": "#ff7a45",
|
"fill-color": MAP_STYLE_TOKENS.tool.geometry,
|
||||||
"fill-opacity": 0.12
|
"fill-opacity": 0.12
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -143,7 +145,7 @@ export const drawingLayers: StyleSpecification["layers"] = [
|
|||||||
source: DRAWING_SOURCE_IDS.draft,
|
source: DRAWING_SOURCE_IDS.draft,
|
||||||
filter: ["any", ["==", ["geometry-type"], "LineString"], ["==", ["geometry-type"], "Polygon"]],
|
filter: ["any", ["==", ["geometry-type"], "LineString"], ["==", ["geometry-type"], "Polygon"]],
|
||||||
paint: {
|
paint: {
|
||||||
"line-color": "#ff7a45",
|
"line-color": MAP_STYLE_TOKENS.tool.geometry,
|
||||||
"line-width": 2,
|
"line-width": 2,
|
||||||
"line-dasharray": [2, 2],
|
"line-dasharray": [2, 2],
|
||||||
"line-opacity": 0.9
|
"line-opacity": 0.9
|
||||||
@@ -155,9 +157,9 @@ export const drawingLayers: StyleSpecification["layers"] = [
|
|||||||
source: DRAWING_SOURCE_IDS.draft,
|
source: DRAWING_SOURCE_IDS.draft,
|
||||||
filter: ["==", ["geometry-type"], "Point"],
|
filter: ["==", ["geometry-type"], "Point"],
|
||||||
paint: {
|
paint: {
|
||||||
"circle-color": "#ff7a45",
|
"circle-color": MAP_STYLE_TOKENS.state.selected,
|
||||||
"circle-radius": 4,
|
"circle-radius": 4,
|
||||||
"circle-stroke-color": "#ffffff",
|
"circle-stroke-color": MAP_STYLE_TOKENS.canvas.casing,
|
||||||
"circle-stroke-width": 1.5
|
"circle-stroke-width": 1.5
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -167,7 +169,7 @@ export function ensureDrawingLayers(map: {
|
|||||||
getSource: (id: string) => unknown;
|
getSource: (id: string) => unknown;
|
||||||
addSource: (id: string, source: GeoJSONSourceSpecification) => void;
|
addSource: (id: string, source: GeoJSONSourceSpecification) => void;
|
||||||
getLayer: (id: string) => unknown;
|
getLayer: (id: string) => unknown;
|
||||||
addLayer: (layer: NonNullable<StyleSpecification["layers"]>[number]) => void;
|
addLayer: (layer: NonNullable<StyleSpecification["layers"]>[number], beforeId?: string) => void;
|
||||||
}) {
|
}) {
|
||||||
if (!map.getSource(DRAWING_SOURCE_IDS.features)) {
|
if (!map.getSource(DRAWING_SOURCE_IDS.features)) {
|
||||||
map.addSource(DRAWING_SOURCE_IDS.features, drawingSources.features);
|
map.addSource(DRAWING_SOURCE_IDS.features, drawingSources.features);
|
||||||
@@ -179,7 +181,7 @@ export function ensureDrawingLayers(map: {
|
|||||||
|
|
||||||
drawingLayers.forEach((layer) => {
|
drawingLayers.forEach((layer) => {
|
||||||
if (!map.getLayer(layer.id)) {
|
if (!map.getLayer(layer.id)) {
|
||||||
map.addLayer(layer);
|
map.addLayer(layer, WORKBENCH_INTERACTION_BEFORE_ID);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import maplibregl, { type Map as MapLibreMap, type StyleSpecification } from "maplibre-gl";
|
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 MAX_EXPORT_DIMENSION = 4096;
|
||||||
const EXPORT_IDLE_TIMEOUT_MS = 6000;
|
const EXPORT_IDLE_TIMEOUT_MS = 6000;
|
||||||
@@ -7,9 +8,10 @@ export type ExportMapViewOptions = {
|
|||||||
scale?: number;
|
scale?: number;
|
||||||
targetLongEdge?: number;
|
targetLongEdge?: number;
|
||||||
filename?: string;
|
filename?: string;
|
||||||
|
selectedFeature?: Pick<MapFeatureInteractionState, "source" | "sourceLayer" | "id"> | 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 canvas = map.getCanvas();
|
||||||
const sourceWidth = canvas.clientWidth || canvas.width;
|
const sourceWidth = canvas.clientWidth || canvas.width;
|
||||||
const sourceHeight = canvas.clientHeight || canvas.height;
|
const sourceHeight = canvas.clientHeight || canvas.height;
|
||||||
@@ -47,6 +49,12 @@ export async function exportMapViewImage(map: MapLibreMap, { scale, targetLongEd
|
|||||||
padding: 0,
|
padding: 0,
|
||||||
duration: 0
|
duration: 0
|
||||||
});
|
});
|
||||||
|
if (selectedFeature) {
|
||||||
|
exportMap.setFeatureState(
|
||||||
|
{ source: selectedFeature.source, sourceLayer: selectedFeature.sourceLayer, id: selectedFeature.id },
|
||||||
|
{ selected: true, hovered: false }
|
||||||
|
);
|
||||||
|
}
|
||||||
await waitForMapIdle(exportMap);
|
await waitForMapIdle(exportMap);
|
||||||
|
|
||||||
const link = document.createElement("a");
|
const link = document.createElement("a");
|
||||||
|
|||||||
@@ -64,5 +64,10 @@ function getFeaturePresentation(
|
|||||||
title: `排放口 ${displayId}`,
|
title: `排放口 ${displayId}`,
|
||||||
subtitle: `类型 ${formatValue(properties.outfall_type)} · 井底高程 ${formatValue(properties.invert_elevation)} m`
|
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)}`
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,10 +3,7 @@ import {
|
|||||||
INTERACTIVE_HIT_LAYER_IDS,
|
INTERACTIVE_HIT_LAYER_IDS,
|
||||||
waterNetworkLayers
|
waterNetworkLayers
|
||||||
} from "./layers";
|
} from "./layers";
|
||||||
import {
|
import { MAP_STYLE_TOKENS } from "./map-colors";
|
||||||
DRAINAGE_SELECTED_OUTLINE_COLORS,
|
|
||||||
DRAINAGE_SOURCE_COLORS
|
|
||||||
} from "./map-colors";
|
|
||||||
import { DRAINAGE_LAYER_VISUALS, MAP_MAX_ZOOM } from "./map-layer-visuals";
|
import { DRAINAGE_LAYER_VISUALS, MAP_MAX_ZOOM } from "./map-layer-visuals";
|
||||||
|
|
||||||
describe("drainage network interaction layers", () => {
|
describe("drainage network interaction layers", () => {
|
||||||
@@ -56,61 +53,49 @@ describe("drainage network interaction layers", () => {
|
|||||||
|
|
||||||
it("provides persistent selected layers for every source", () => {
|
it("provides persistent selected layers for every source", () => {
|
||||||
const selectedLayerIds = [
|
const selectedLayerIds = [
|
||||||
"pipes-selected-halo",
|
|
||||||
"pipes-selected-outline",
|
"pipes-selected-outline",
|
||||||
"pipes-selected",
|
|
||||||
"junctions-selected-halo",
|
|
||||||
"junctions-selected",
|
"junctions-selected",
|
||||||
"orifices-selected-halo",
|
|
||||||
"orifices-selected-outline",
|
"orifices-selected-outline",
|
||||||
"orifices-selected",
|
|
||||||
"pumps-selected-halo",
|
|
||||||
"pumps-selected-outline",
|
"pumps-selected-outline",
|
||||||
"pumps-selected",
|
|
||||||
"outfalls-selected-halo",
|
|
||||||
"outfalls-selected"
|
"outfalls-selected"
|
||||||
];
|
];
|
||||||
|
|
||||||
selectedLayerIds.forEach((layerId) => {
|
selectedLayerIds.forEach((layerId) => {
|
||||||
const layer = waterNetworkLayers.find((candidate) => candidate.id === layerId);
|
const layer = waterNetworkLayers.find((candidate) => candidate.id === layerId);
|
||||||
expect(layer && "filter" in layer ? layer.filter : undefined).toEqual(["==", ["get", "id"], ""]);
|
expect(layer && "filter" in layer ? layer.filter : undefined).toBeUndefined();
|
||||||
expect(layer && "minzoom" in layer ? layer.minzoom : undefined).toBeUndefined();
|
const paint = layer?.paint as Record<string, unknown> | 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", () => {
|
it("renders selected features as opaque hollow outlines", () => {
|
||||||
const expectedSelectedColors = [
|
const expectedLineOutlines = [
|
||||||
["pipes-selected-outline", "line-color", DRAINAGE_SELECTED_OUTLINE_COLORS.conduits],
|
["pipes-selected-outline", DRAINAGE_LAYER_VISUALS.conduits],
|
||||||
["pipes-selected", "line-color", DRAINAGE_SOURCE_COLORS.conduits],
|
["orifices-selected-outline", DRAINAGE_LAYER_VISUALS.orifices],
|
||||||
["junctions-selected", "circle-stroke-color", DRAINAGE_SELECTED_OUTLINE_COLORS.junctions],
|
["pumps-selected-outline", DRAINAGE_LAYER_VISUALS.pumps]
|
||||||
["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;
|
] as const;
|
||||||
|
|
||||||
expectedSelectedColors.forEach(([layerId, paintProperty, color]) => {
|
expectedLineOutlines.forEach(([layerId, visuals]) => {
|
||||||
const layer = waterNetworkLayers.find((candidate) => candidate.id === layerId);
|
const layer = waterNetworkLayers.find((candidate) => candidate.id === layerId);
|
||||||
const paint = layer?.paint as Record<string, unknown> | undefined;
|
const paint = layer?.paint as Record<string, unknown> | 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);
|
const expectedPointOutlines = [
|
||||||
["pipes", "orifices", "pumps"].forEach((prefix) => {
|
"junctions-selected",
|
||||||
expect(layerIds.indexOf(`${prefix}-selected-halo`)).toBeLessThan(
|
"outfalls-selected"
|
||||||
layerIds.indexOf(`${prefix}-selected-outline`)
|
] as const;
|
||||||
);
|
expectedPointOutlines.forEach((layerId) => {
|
||||||
expect(layerIds.indexOf(`${prefix}-selected-outline`)).toBeLessThan(
|
const layer = waterNetworkLayers.find((candidate) => candidate.id === layerId);
|
||||||
layerIds.indexOf(`${prefix}-selected`)
|
const paint = layer?.paint as Record<string, unknown> | undefined;
|
||||||
);
|
expect(paint?.["circle-color"]).toBe(MAP_STYLE_TOKENS.canvas.transparent);
|
||||||
});
|
expect(JSON.stringify(paint?.["circle-opacity"])).toContain("feature-state");
|
||||||
["junctions", "outfalls"].forEach((prefix) => {
|
expect(paint?.["circle-stroke-color"]).toBe(MAP_STYLE_TOKENS.state.selected);
|
||||||
expect(layerIds.indexOf(`${prefix}-selected-halo`)).toBeLessThan(
|
expect(JSON.stringify(paint?.["circle-stroke-opacity"])).toContain("selected");
|
||||||
layerIds.indexOf(`${prefix}-selected`)
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -118,7 +103,7 @@ describe("drainage network interaction layers", () => {
|
|||||||
expect(MAP_MAX_ZOOM).toBe(24);
|
expect(MAP_MAX_ZOOM).toBe(24);
|
||||||
expect(DRAINAGE_LAYER_VISUALS.junctions.minzoom).toBe(12);
|
expect(DRAINAGE_LAYER_VISUALS.junctions.minzoom).toBe(12);
|
||||||
expect(DRAINAGE_LAYER_VISUALS.junctions.radius).toEqual([
|
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([
|
expect(DRAINAGE_LAYER_VISUALS.outfalls.radius).toEqual([
|
||||||
"interpolate", ["linear"], ["zoom"], 11, 3, 16, 5, 20, 7, 24, 9
|
"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);
|
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<string, unknown>)?.["circle-opacity"]).toBe(DRAINAGE_LAYER_VISUALS.junctions.haloOpacity);
|
||||||
|
expect((orifices?.paint as Record<string, unknown>)?.["line-dasharray"]).toEqual([4, 2]);
|
||||||
|
expect((pumps?.paint as Record<string, unknown>)?.["line-dasharray"]).toEqual([6, 2, 1.5, 2]);
|
||||||
|
});
|
||||||
|
|
||||||
it("uses the centralized geometry tokens in rendered layers", () => {
|
it("uses the centralized geometry tokens in rendered layers", () => {
|
||||||
const expectedGeometry = [
|
const expectedGeometry = [
|
||||||
["pipes-flow", "line-width", DRAINAGE_LAYER_VISUALS.conduits.width],
|
["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", "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", "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", "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", "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;
|
] as const;
|
||||||
|
|
||||||
expectedGeometry.forEach(([layerId, paintProperty, visualToken]) => {
|
expectedGeometry.forEach(([layerId, paintProperty, visualToken]) => {
|
||||||
|
|||||||
@@ -1,493 +1,90 @@
|
|||||||
import type { StyleSpecification } from "maplibre-gl";
|
import type { ExpressionSpecification, 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 { DRAINAGE_LAYER_VISUALS } from "./map-layer-visuals";
|
||||||
|
import { MAP_STYLE_TOKENS } from "./map-colors";
|
||||||
import { SOURCE_LAYERS } from "./sources";
|
import { SOURCE_LAYERS } from "./sources";
|
||||||
|
|
||||||
export { DRAINAGE_SOURCE_COLORS } from "./map-colors";
|
|
||||||
|
|
||||||
export const INTERACTIVE_HIT_LAYER_IDS = [
|
export const INTERACTIVE_HIT_LAYER_IDS = [
|
||||||
"conduits-hit",
|
"conduits-hit", "junctions-hit", "orifices-hit", "pumps-hit", "outfalls-hit"
|
||||||
"junctions-hit",
|
] as const;
|
||||||
"orifices-hit",
|
|
||||||
"pumps-hit",
|
|
||||||
"outfalls-hit"
|
|
||||||
];
|
|
||||||
|
|
||||||
export const waterNetworkLayers: StyleSpecification["layers"] = [
|
const hoveredOpacity: ExpressionSpecification = [
|
||||||
{
|
"case",
|
||||||
id: "pipes-casing",
|
["all", ["boolean", ["feature-state", "hovered"], false], ["!", ["boolean", ["feature-state", "selected"], false]]],
|
||||||
type: "line",
|
1,
|
||||||
source: "conduits",
|
0
|
||||||
"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 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<StyleSpecification["layers"]>[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";
|
||||||
|
|||||||
@@ -1,31 +1,38 @@
|
|||||||
export const MAP_BACKGROUND_COLORS = {
|
/** Runtime map colors. Keep literal colors in this file only. */
|
||||||
primary: "#F9FAFB",
|
export const MAP_STYLE_TOKENS = {
|
||||||
secondary: "#F0F1F3",
|
canvas: {
|
||||||
secondaryAlt: "#FAFBFC"
|
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;
|
} 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";
|
|
||||||
|
|||||||
@@ -16,7 +16,8 @@ describe("workbench source layer controls", () => {
|
|||||||
"junctions",
|
"junctions",
|
||||||
"orifices",
|
"orifices",
|
||||||
"outfalls",
|
"outfalls",
|
||||||
"pumps"
|
"pumps",
|
||||||
|
"scada"
|
||||||
]);
|
]);
|
||||||
expect(items.slice(0, 5).map((item) => item.description)).toEqual([
|
expect(items.slice(0, 5).map((item) => item.description)).toEqual([
|
||||||
"lingang:geo_conduits_mat",
|
"lingang:geo_conduits_mat",
|
||||||
@@ -37,16 +38,16 @@ describe("workbench source layer controls", () => {
|
|||||||
"orifices",
|
"orifices",
|
||||||
"orifices-hover-outline",
|
"orifices-hover-outline",
|
||||||
"orifices-hover",
|
"orifices-hover",
|
||||||
"orifices-selected-halo",
|
"orifices-selected-outer",
|
||||||
"orifices-selected-outline",
|
"orifices-selected-outline",
|
||||||
"orifices-selected",
|
"orifices-selected-separator",
|
||||||
"orifices-hit"
|
"orifices-hit"
|
||||||
]);
|
]);
|
||||||
expect(getWorkbenchLayerIds(map, "outfalls")).toEqual([
|
expect(getWorkbenchLayerIds(map, "outfalls")).toEqual([
|
||||||
"outfalls-halo",
|
"outfalls-halo",
|
||||||
"outfalls",
|
"outfalls",
|
||||||
"outfalls-hover",
|
"outfalls-hover",
|
||||||
"outfalls-selected-halo",
|
"outfalls-selected-outer",
|
||||||
"outfalls-selected",
|
"outfalls-selected",
|
||||||
"outfalls-hit"
|
"outfalls-hit"
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -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 { BaseLayerOption } from "@/features/map/core/components/base-layers-control";
|
||||||
import type { MapLayerControlItem } from "@/features/map/core/components/layer-control";
|
import type { MapLayerControlItem } from "@/features/map/core/components/layer-control";
|
||||||
import type { MapLegendItem } from "@/features/map/core/components/legend";
|
import type { MapLegendItem } from "@/features/map/core/components/legend";
|
||||||
import { DRAINAGE_SOURCE_COLORS } from "./map-colors";
|
import { MAP_STYLE_TOKENS } from "./map-colors";
|
||||||
import {
|
import {
|
||||||
SOURCE_LAYERS,
|
SOURCE_LAYERS,
|
||||||
WATER_NETWORK_SOURCE_IDS,
|
WATER_NETWORK_SOURCE_IDS,
|
||||||
@@ -28,6 +28,7 @@ export const INITIAL_LAYER_VISIBILITY: Record<string, boolean> = {
|
|||||||
orifices: true,
|
orifices: true,
|
||||||
outfalls: true,
|
outfalls: true,
|
||||||
pumps: true,
|
pumps: true,
|
||||||
|
scada: true,
|
||||||
simulation: false
|
simulation: false
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -36,16 +37,24 @@ const SOURCE_CONTROL_LABELS: Record<WaterNetworkSourceId, { label: string; descr
|
|||||||
junctions: { label: "检查井", description: `lingang:${SOURCE_LAYERS.junctions}` },
|
junctions: { label: "检查井", description: `lingang:${SOURCE_LAYERS.junctions}` },
|
||||||
orifices: { label: "孔口", description: `lingang:${SOURCE_LAYERS.orifices}` },
|
orifices: { label: "孔口", description: `lingang:${SOURCE_LAYERS.orifices}` },
|
||||||
outfalls: { label: "排放口", description: `lingang:${SOURCE_LAYERS.outfalls}` },
|
outfalls: { label: "排放口", description: `lingang:${SOURCE_LAYERS.outfalls}` },
|
||||||
pumps: { label: "泵", description: `lingang:${SOURCE_LAYERS.pumps}` }
|
pumps: { label: "泵", description: `lingang:${SOURCE_LAYERS.pumps}` },
|
||||||
|
scada: { label: "SCADA 测点", description: `lingang:${SOURCE_LAYERS.scada}` }
|
||||||
};
|
};
|
||||||
|
|
||||||
export const MAP_LEGEND_ITEMS: MapLegendItem[] = [
|
export const MAP_LEGEND_ITEMS: MapLegendItem[] = [
|
||||||
{ id: "conduit", label: "管渠(线宽表示口径)", color: DRAINAGE_SOURCE_COLORS.conduits, shape: "line" },
|
{ id: "conduit", label: "管渠(线宽表示口径)", color: MAP_STYLE_TOKENS.asset.conduit, shape: "line" },
|
||||||
{ id: "junction", label: "检查井", color: DRAINAGE_SOURCE_COLORS.junctions, shape: "dot" },
|
{ id: "junction", label: "检查井", color: MAP_STYLE_TOKENS.asset.junction, shape: "dot" },
|
||||||
{ id: "orifice", label: "孔口", color: DRAINAGE_SOURCE_COLORS.orifices, shape: "line" },
|
{ id: "orifice", label: "孔口", color: MAP_STYLE_TOKENS.asset.orifice, shape: "dashed-line" },
|
||||||
{ id: "pump", label: "泵", color: DRAINAGE_SOURCE_COLORS.pumps, shape: "line" },
|
{ id: "pump", label: "泵", color: MAP_STYLE_TOKENS.asset.pump, shape: "dash-dot-line" },
|
||||||
{ id: "outfall", label: "排放口", color: DRAINAGE_SOURCE_COLORS.outfalls, shape: "dot" },
|
{ id: "outfall", label: "排放口", color: MAP_STYLE_TOKENS.asset.outfall, shape: "ring" },
|
||||||
{ id: "impact-area", label: "影响范围", color: "#ef4444", shape: "square" }
|
{ id: "scada-quality", label: "水质仪", color: "#0F766E", imageSrc: "/icons/scada-water-quality.svg" },
|
||||||
|
{ id: "scada-level", label: "雷达液位计", color: "#2563EB", imageSrc: "/icons/scada-radar-level.svg" },
|
||||||
|
{ id: "scada-flow", label: "超声流量计", color: "#EA580C", imageSrc: "/icons/scada-ultrasonic-flow.svg" },
|
||||||
|
{ id: "inactive", label: "停用 / 关闭", color: MAP_STYLE_TOKENS.state.inactive, shape: "line" },
|
||||||
|
{ id: "impact-area", label: "风险影响范围", color: MAP_STYLE_TOKENS.state.risk, shape: "square" },
|
||||||
|
{ id: "incident", label: "事故 / 爆管", color: MAP_STYLE_TOKENS.state.incident, shape: "dot" },
|
||||||
|
{ id: "agent", label: "Agent 推断", color: MAP_STYLE_TOKENS.state.agent, shape: "dot" },
|
||||||
|
{ id: "selected", label: "当前选中", color: MAP_STYLE_TOKENS.state.selected, shape: "ring" }
|
||||||
];
|
];
|
||||||
|
|
||||||
export const BASE_LAYER_IDS = ["mapbox-light", "mapbox-satellite"] as const;
|
export const BASE_LAYER_IDS = ["mapbox-light", "mapbox-satellite"] as const;
|
||||||
|
|||||||
@@ -41,34 +41,32 @@ export const DRAINAGE_LAYER_VISUALS = {
|
|||||||
minzoom: 9,
|
minzoom: 9,
|
||||||
casingWidth: [
|
casingWidth: [
|
||||||
"interpolate", ["linear"], ["zoom"],
|
"interpolate", ["linear"], ["zoom"],
|
||||||
9, 1.8,
|
9, 1.7,
|
||||||
13, 3,
|
13, 2.8,
|
||||||
17, ["+", 4.5, ["/", ["coalesce", ["get", "diameter"], 100], 300]],
|
17, ["+", 4.5, ["/", ["coalesce", ["get", "diameter"], 100], 300]],
|
||||||
22, ["+", 6, ["/", ["coalesce", ["get", "diameter"], 100], 260]],
|
22, ["+", 6, ["/", ["coalesce", ["get", "diameter"], 100], 260]],
|
||||||
24, ["+", 7, ["/", ["coalesce", ["get", "diameter"], 100], 240]]
|
24, ["+", 7, ["/", ["coalesce", ["get", "diameter"], 100], 240]]
|
||||||
] as LineWidth,
|
] as LineWidth,
|
||||||
width: [
|
width: [
|
||||||
"interpolate", ["linear"], ["zoom"],
|
"interpolate", ["linear"], ["zoom"],
|
||||||
9, 0.9,
|
9, 1.1,
|
||||||
13, 1.8,
|
13, 2,
|
||||||
17, ["+", 2.5, ["/", ["coalesce", ["get", "diameter"], 100], 360]],
|
17, ["+", 2.5, ["/", ["coalesce", ["get", "diameter"], 100], 360]],
|
||||||
22, ["+", 3.5, ["/", ["coalesce", ["get", "diameter"], 100], 300]],
|
22, ["+", 3.5, ["/", ["coalesce", ["get", "diameter"], 100], 300]],
|
||||||
24, ["+", 4, ["/", ["coalesce", ["get", "diameter"], 100], 280]]
|
24, ["+", 4, ["/", ["coalesce", ["get", "diameter"], 100], 280]]
|
||||||
] as LineWidth,
|
] 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,
|
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,
|
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,
|
selectedWidth: ["interpolate", ["linear"], ["zoom"], 9, 4, 13, 6.5, 17, 10.5, 22, 13.5, 24, 16] as LineWidth,
|
||||||
|
selectedBorderWidth: ["interpolate", ["linear"], ["zoom"], 9, 1.2, 13, 1.5, 17, 2, 22, 2.5, 24, 3] as LineWidth,
|
||||||
hitWidth: lineHitWidth
|
hitWidth: lineHitWidth
|
||||||
},
|
},
|
||||||
junctions: {
|
junctions: {
|
||||||
minzoom: 12,
|
minzoom: 12,
|
||||||
haloRadius: ["interpolate", ["linear"], ["zoom"], 12, 2.5, 16, 4.5, 20, 7, 24, 9.5] as CircleRadius,
|
haloRadius: ["interpolate", ["linear"], ["zoom"], 12, 1.2, 14, 1.8, 15, 2.8, 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,
|
haloOpacity: ["interpolate", ["linear"], ["zoom"], 12, 0, 14, 0, 15, 1] as const,
|
||||||
|
radius: ["interpolate", ["linear"], ["zoom"], 12, 0.6, 14, 1, 15, 1.5, 16, 3, 20, 5.5, 24, 8] as CircleRadius,
|
||||||
hoverRadius: ["interpolate", ["linear"], ["zoom"], 12, 5, 16, 7, 20, 10, 24, 12] as CircleRadius,
|
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,
|
selectedRadius: ["interpolate", ["linear"], ["zoom"], 12, 5.5, 16, 7.5, 20, 10.5, 24, 13] as CircleRadius,
|
||||||
hitRadius: pointHitRadius
|
hitRadius: pointHitRadius
|
||||||
},
|
},
|
||||||
@@ -78,9 +76,8 @@ export const DRAINAGE_LAYER_VISUALS = {
|
|||||||
width: ["interpolate", ["linear"], ["zoom"], 11, 1.6, 17, 4.5, 22, 6, 24, 7] 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,
|
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,
|
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,
|
selectedWidth: ["interpolate", ["linear"], ["zoom"], 11, 5.5, 17, 9, 22, 12, 24, 14] as LineWidth,
|
||||||
|
selectedBorderWidth: ["interpolate", ["linear"], ["zoom"], 11, 1.25, 17, 1.75, 22, 2.25, 24, 2.5] as LineWidth,
|
||||||
hitWidth: lineHitWidth
|
hitWidth: lineHitWidth
|
||||||
},
|
},
|
||||||
pumps: {
|
pumps: {
|
||||||
@@ -89,9 +86,8 @@ export const DRAINAGE_LAYER_VISUALS = {
|
|||||||
width: ["interpolate", ["linear"], ["zoom"], 11, 2.4, 17, 6.2, 22, 8, 24, 9] 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,
|
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,
|
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,
|
selectedWidth: ["interpolate", ["linear"], ["zoom"], 11, 6, 17, 10, 22, 13, 24, 15] as LineWidth,
|
||||||
|
selectedBorderWidth: ["interpolate", ["linear"], ["zoom"], 11, 1.25, 17, 1.75, 22, 2.25, 24, 2.5] as LineWidth,
|
||||||
hitWidth: lineHitWidth
|
hitWidth: lineHitWidth
|
||||||
},
|
},
|
||||||
outfalls: {
|
outfalls: {
|
||||||
@@ -99,7 +95,6 @@ export const DRAINAGE_LAYER_VISUALS = {
|
|||||||
haloRadius: ["interpolate", ["linear"], ["zoom"], 11, 5, 16, 7, 20, 9, 24, 11] as CircleRadius,
|
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,
|
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,
|
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,
|
selectedRadius: ["interpolate", ["linear"], ["zoom"], 11, 7.5, 16, 9.5, 20, 12.5, 24, 15] as CircleRadius,
|
||||||
hitRadius: pointHitRadius
|
hitRadius: pointHitRadius
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import type {
|
|||||||
} from "maplibre-gl";
|
} from "maplibre-gl";
|
||||||
import type { FeatureCollection, Geometry, LineString, Point, Polygon } from "geojson";
|
import type { FeatureCollection, Geometry, LineString, Point, Polygon } from "geojson";
|
||||||
import { SOURCE_LAYERS } from "./sources";
|
import { SOURCE_LAYERS } from "./sources";
|
||||||
|
import { MAP_STYLE_TOKENS } from "./map-colors";
|
||||||
|
import { WORKBENCH_INTERACTION_BEFORE_ID } from "./layers";
|
||||||
|
|
||||||
export const MEASUREMENT_SOURCE_ID = "workbench-measurement";
|
export const MEASUREMENT_SOURCE_ID = "workbench-measurement";
|
||||||
export const MEASUREMENT_SELECTED_PIPES_LAYER_ID = "workbench-measurement-selected-pipes";
|
export const MEASUREMENT_SELECTED_PIPES_LAYER_ID = "workbench-measurement-selected-pipes";
|
||||||
@@ -31,7 +33,7 @@ export const measurementLayers: StyleSpecification["layers"] = [
|
|||||||
minzoom: 9,
|
minzoom: 9,
|
||||||
filter: ["in", ["get", "id"], ["literal", []]],
|
filter: ["in", ["get", "id"], ["literal", []]],
|
||||||
paint: {
|
paint: {
|
||||||
"line-color": "#2563eb",
|
"line-color": MAP_STYLE_TOKENS.state.selected,
|
||||||
"line-width": ["interpolate", ["linear"], ["zoom"], 9, 4, 13, 7, 17, 11],
|
"line-width": ["interpolate", ["linear"], ["zoom"], 9, 4, 13, 7, 17, 11],
|
||||||
"line-opacity": 0.68
|
"line-opacity": 0.68
|
||||||
}
|
}
|
||||||
@@ -42,7 +44,7 @@ export const measurementLayers: StyleSpecification["layers"] = [
|
|||||||
source: MEASUREMENT_SOURCE_ID,
|
source: MEASUREMENT_SOURCE_ID,
|
||||||
filter: ["==", ["get", "kind"], "polygon"],
|
filter: ["==", ["get", "kind"], "polygon"],
|
||||||
paint: {
|
paint: {
|
||||||
"fill-color": "#2563eb",
|
"fill-color": MAP_STYLE_TOKENS.tool.geometry,
|
||||||
"fill-opacity": 0.12
|
"fill-opacity": 0.12
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -52,7 +54,7 @@ export const measurementLayers: StyleSpecification["layers"] = [
|
|||||||
source: MEASUREMENT_SOURCE_ID,
|
source: MEASUREMENT_SOURCE_ID,
|
||||||
filter: ["any", ["==", ["get", "kind"], "line"], ["==", ["get", "kind"], "polygon"]],
|
filter: ["any", ["==", ["get", "kind"], "line"], ["==", ["get", "kind"], "polygon"]],
|
||||||
paint: {
|
paint: {
|
||||||
"line-color": "#2563eb",
|
"line-color": MAP_STYLE_TOKENS.tool.geometry,
|
||||||
"line-width": 2.5,
|
"line-width": 2.5,
|
||||||
"line-dasharray": [2, 1.5],
|
"line-dasharray": [2, 1.5],
|
||||||
"line-opacity": 0.9
|
"line-opacity": 0.9
|
||||||
@@ -64,9 +66,9 @@ export const measurementLayers: StyleSpecification["layers"] = [
|
|||||||
source: MEASUREMENT_SOURCE_ID,
|
source: MEASUREMENT_SOURCE_ID,
|
||||||
filter: ["==", ["get", "kind"], "vertex"],
|
filter: ["==", ["get", "kind"], "vertex"],
|
||||||
paint: {
|
paint: {
|
||||||
"circle-color": "#2563eb",
|
"circle-color": MAP_STYLE_TOKENS.state.selected,
|
||||||
"circle-radius": 4,
|
"circle-radius": 4,
|
||||||
"circle-stroke-color": "#ffffff",
|
"circle-stroke-color": MAP_STYLE_TOKENS.canvas.casing,
|
||||||
"circle-stroke-width": 1.5
|
"circle-stroke-width": 1.5
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -76,7 +78,7 @@ export function ensureMeasurementLayers(map: {
|
|||||||
getSource: (id: string) => unknown;
|
getSource: (id: string) => unknown;
|
||||||
addSource: (id: string, source: GeoJSONSourceSpecification) => void;
|
addSource: (id: string, source: GeoJSONSourceSpecification) => void;
|
||||||
getLayer: (id: string) => unknown;
|
getLayer: (id: string) => unknown;
|
||||||
addLayer: (layer: NonNullable<StyleSpecification["layers"]>[number]) => void;
|
addLayer: (layer: NonNullable<StyleSpecification["layers"]>[number], beforeId?: string) => void;
|
||||||
}) {
|
}) {
|
||||||
if (!map.getSource(MEASUREMENT_SOURCE_ID)) {
|
if (!map.getSource(MEASUREMENT_SOURCE_ID)) {
|
||||||
map.addSource(MEASUREMENT_SOURCE_ID, measurementSource);
|
map.addSource(MEASUREMENT_SOURCE_ID, measurementSource);
|
||||||
@@ -84,7 +86,7 @@ export function ensureMeasurementLayers(map: {
|
|||||||
|
|
||||||
measurementLayers.forEach((layer) => {
|
measurementLayers.forEach((layer) => {
|
||||||
if (!map.getLayer(layer.id)) {
|
if (!map.getLayer(layer.id)) {
|
||||||
map.addLayer(layer);
|
map.addLayer(layer, WORKBENCH_INTERACTION_BEFORE_ID);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<string>();
|
||||||
|
return {
|
||||||
|
loadImage: vi.fn(loadImage),
|
||||||
|
hasImage: vi.fn((imageId: string) => loadedImageIds.has(imageId)),
|
||||||
|
addImage: vi.fn((imageId: string) => loadedImageIds.add(imageId))
|
||||||
|
} as unknown as Pick<MapLibreMap, "loadImage" | "addImage" | "hasImage"> & {
|
||||||
|
loadImage: ReturnType<typeof vi.fn>;
|
||||||
|
addImage: ReturnType<typeof vi.fn>;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,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<StyleSpecification["layers"]>[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<MapLibreMap, "loadImage" | "addImage" | "hasImage">
|
||||||
|
): Promise<ScadaImageRegistrationResult> {
|
||||||
|
let unknown: Awaited<ReturnType<typeof loadImage>>;
|
||||||
|
|
||||||
|
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<keyof typeof SCADA_IMAGE_IDS, "unknown">];
|
||||||
|
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<MapLibreMap, "loadImage">, path: string) {
|
||||||
|
const response = await map.loadImage(path);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addImageIfMissing(
|
||||||
|
map: Pick<MapLibreMap, "addImage" | "hasImage">,
|
||||||
|
imageId: string,
|
||||||
|
image: Awaited<ReturnType<typeof loadImage>>
|
||||||
|
) {
|
||||||
|
if (!map.hasImage(imageId)) map.addImage(imageId, image, { pixelRatio: 2 });
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { VectorSourceSpecification, StyleSpecification } from "maplibre-gl";
|
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 = {
|
export const WATER_NETWORK_GLOBAL_VIEW = {
|
||||||
bbox3857: [
|
bbox3857: [
|
||||||
@@ -15,7 +16,8 @@ export const SOURCE_LAYERS = {
|
|||||||
junctions: "geo_junctions_mat",
|
junctions: "geo_junctions_mat",
|
||||||
orifices: "geo_orifices_mat",
|
orifices: "geo_orifices_mat",
|
||||||
outfalls: "geo_outfalls_mat",
|
outfalls: "geo_outfalls_mat",
|
||||||
pumps: "geo_pumps_mat"
|
pumps: "geo_pumps_mat",
|
||||||
|
scada: "scada_points"
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const WATER_NETWORK_SOURCE_IDS = [
|
export const WATER_NETWORK_SOURCE_IDS = [
|
||||||
@@ -23,12 +25,13 @@ export const WATER_NETWORK_SOURCE_IDS = [
|
|||||||
"junctions",
|
"junctions",
|
||||||
"orifices",
|
"orifices",
|
||||||
"outfalls",
|
"outfalls",
|
||||||
"pumps"
|
"pumps",
|
||||||
|
"scada"
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type WaterNetworkSourceId = (typeof WATER_NETWORK_SOURCE_IDS)[number];
|
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() {
|
export function createWaterNetworkSources() {
|
||||||
return {
|
return {
|
||||||
@@ -56,7 +59,8 @@ export function createWaterNetworkSources() {
|
|||||||
SOURCE_LAYERS.pumps,
|
SOURCE_LAYERS.pumps,
|
||||||
"line",
|
"line",
|
||||||
[121.73585149761436, 30.861759757577705, 121.9498481645973, 30.983213202338085]
|
[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 {
|
): VectorSourceSpecification {
|
||||||
return {
|
return {
|
||||||
type: "vector",
|
type: "vector",
|
||||||
|
promoteId: "id",
|
||||||
tiles: [
|
tiles: [
|
||||||
`${GEOSERVER_WMTS_ROOT}/lingang:${sourceLayer}/${style}/WebMercatorQuad/{z}/{y}/{x}?format=application/vnd.mapbox-vector-tile`
|
`${GEOSERVER_WMTS_ROOT}/lingang:${sourceLayer}/${style}/WebMercatorQuad/{z}/{y}/{x}?format=application/vnd.mapbox-vector-tile`
|
||||||
],
|
],
|
||||||
@@ -83,7 +88,7 @@ export function createBaseStyle(mapboxToken?: string): StyleSpecification {
|
|||||||
id: "background",
|
id: "background",
|
||||||
type: "background",
|
type: "background",
|
||||||
paint: {
|
paint: {
|
||||||
"background-color": MAP_BACKGROUND_COLORS.primary
|
"background-color": MAP_STYLE_TOKENS.canvas.primary
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { getFeaturePanelModel, getFeaturePanelProperties } from "./feature-properties";
|
import { getFeaturePanelModel, getFeaturePanelProperties, getLocalizedFeatureProperties } from "./feature-properties";
|
||||||
|
|
||||||
describe("feature panel properties", () => {
|
describe("feature panel properties", () => {
|
||||||
it.each([
|
it.each([
|
||||||
@@ -68,4 +68,14 @@ describe("feature panel properties", () => {
|
|||||||
expect(model.badge).toBeUndefined();
|
expect(model.badge).toBeUndefined();
|
||||||
expect(model.attributes.map((entry) => entry.key)).toEqual(["invert_elevation", "max_depth"]);
|
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"]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -58,7 +58,19 @@ const PROPERTY_LABELS: Record<string, string> = {
|
|||||||
zone: "分区",
|
zone: "分区",
|
||||||
district: "片区",
|
district: "片区",
|
||||||
source: "来源",
|
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<string, number> = {
|
const PROPERTY_ORDER: Record<string, number> = {
|
||||||
@@ -121,7 +133,8 @@ const FEATURE_PANEL_PROPERTY_KEYS: Record<WaterNetworkSourceId, readonly string[
|
|||||||
conduits: ["id", "length", "diameter"],
|
conduits: ["id", "length", "diameter"],
|
||||||
junctions: ["id", "invert_elevation", "max_depth"],
|
junctions: ["id", "invert_elevation", "max_depth"],
|
||||||
orifices: ["id", "discharge_coeff", "gated", "shape", "geom1", "geom2"],
|
orifices: ["id", "discharge_coeff", "gated", "shape", "geom1", "geom2"],
|
||||||
outfalls: ["id", "invert_elevation", "outfall_type"]
|
outfalls: ["id", "invert_elevation", "outfall_type"],
|
||||||
|
scada: ["point_external_id", "device_type_name", "device_external_id", "junction_id", "synced_at"]
|
||||||
};
|
};
|
||||||
|
|
||||||
const FEATURE_PANEL_BADGE_KEYS: Partial<Record<WaterNetworkSourceId, string>> = {
|
const FEATURE_PANEL_BADGE_KEYS: Partial<Record<WaterNetworkSourceId, string>> = {
|
||||||
@@ -130,7 +143,7 @@ const FEATURE_PANEL_BADGE_KEYS: Partial<Record<WaterNetworkSourceId, string>> =
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function getLocalizedFeatureProperties(properties: Record<string, unknown>): LocalizedFeatureProperty[] {
|
export function getLocalizedFeatureProperties(properties: Record<string, unknown>): LocalizedFeatureProperty[] {
|
||||||
return Object.entries(properties).map(([key, value]) => ({
|
return Object.entries(properties).filter(([key]) => normalizePropertyKey(key) !== "cluster_size").map(([key, value]) => ({
|
||||||
key,
|
key,
|
||||||
label: getFeaturePropertyLabel(key),
|
label: getFeaturePropertyLabel(key),
|
||||||
value: formatFeaturePropertyValue(key, value)
|
value: formatFeaturePropertyValue(key, value)
|
||||||
@@ -159,14 +172,15 @@ export function getFeaturePanelModel(
|
|||||||
featureId?: string
|
featureId?: string
|
||||||
): FeaturePanelModel {
|
): FeaturePanelModel {
|
||||||
const entries = getFeaturePanelProperties(layer, properties, featureId);
|
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 badgeKey = FEATURE_PANEL_BADGE_KEYS[layer];
|
||||||
const badgeEntry = badgeKey ? entries.find((entry) => entry.key === badgeKey) : undefined;
|
const badgeEntry = badgeKey ? entries.find((entry) => entry.key === badgeKey) : undefined;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
badge: badgeEntry ? getFeaturePanelBadge(layer, badgeEntry.value) : undefined,
|
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);
|
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 !== "") {
|
if ((normalizedKey === "diameter" || normalizedKey === "dn") && value !== null && value !== undefined && value !== "") {
|
||||||
const diameterInMeters = typeof value === "number" ? value : Number(value);
|
const diameterInMeters = typeof value === "number" ? value : Number(value);
|
||||||
return Number.isFinite(diameterInMeters)
|
return Number.isFinite(diameterInMeters)
|
||||||
|
|||||||
@@ -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(/\/$/, "");
|
||||||
|
After Width: | Height: | Size: 3.8 KiB |
@@ -0,0 +1,9 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
|
||||||
|
<circle cx="32" cy="32" r="32" fill="#2563EB" fill-opacity=".9"/>
|
||||||
|
<circle cx="32" cy="32" r="30.5" stroke="#60A5FA" stroke-width="3"/>
|
||||||
|
<g stroke="#FFFFFF" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M21 16h22l-4 8H25l-4-8Z"/>
|
||||||
|
<path d="M27 28c3.3 3 6.7 3 10 0M23 34c6 5.3 12 5.3 18 0M32 24v20"/>
|
||||||
|
<path d="M15 48c6-3 10 3 16 0s10 3 18 0"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 473 B |
|
After Width: | Height: | Size: 3.6 KiB |
@@ -0,0 +1,9 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
|
||||||
|
<circle cx="32" cy="32" r="32" fill="#EA580C" fill-opacity=".9"/>
|
||||||
|
<circle cx="32" cy="32" r="30.5" stroke="#FB923C" stroke-width="3"/>
|
||||||
|
<g stroke="#FFFFFF" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M15 23h34v18H15z"/>
|
||||||
|
<path d="m23 18 8 14-8 14M41 18l-8 14 8 14M19 32h26"/>
|
||||||
|
<path d="m40 27 5 5-5 5"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 436 B |
|
After Width: | Height: | Size: 3.3 KiB |
@@ -0,0 +1,6 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
|
||||||
|
<circle cx="32" cy="32" r="32" fill="#64748B" fill-opacity=".88"/>
|
||||||
|
<circle cx="32" cy="32" r="30.5" stroke="#94A3B8" stroke-width="3"/>
|
||||||
|
<path d="M25 25a7.5 7.5 0 1 1 11 6.5c-2.8 1.6-4 2.8-4 6" stroke="#FFFFFF" stroke-width="4" stroke-linecap="round"/>
|
||||||
|
<circle cx="32" cy="46" r="2.5" fill="#FFFFFF"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 389 B |
|
After Width: | Height: | Size: 4.2 KiB |
@@ -0,0 +1,9 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
|
||||||
|
<circle cx="32" cy="32" r="32" fill="#0F766E" fill-opacity=".9"/>
|
||||||
|
<circle cx="32" cy="32" r="30.5" stroke="#2DD4BF" stroke-width="3"/>
|
||||||
|
<g stroke="#FFFFFF" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M32 13c-6 9-12 16-12 24a12 12 0 0 0 24 0c0-8-6-15-12-24Z"/>
|
||||||
|
<path d="M29 42a7 7 0 0 0 9-7"/>
|
||||||
|
<path d="M17 51h30"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 449 B |