Author SHA1 Message Date
jiang f9c71cc076 feat(workbench): align v2 GIS and SCADA API
Generic Container CI/CD / test-build-publish (push) Successful in 26s
Frontend CI/CD / build-test-publish-and-deploy (push) Successful in 26s
2026-09-08 18:18:40 +08:00
jiang 28287a1447 fix: coordinate mobile workbench overlays
Generic Container CI/CD / test-build-publish (push) Successful in 1m9s
Frontend CI/CD / build-test-publish-and-deploy (push) Successful in 1m40s
Independent fixed offsets caused the timeline, map dock, tool panel, and zoom controls to overlap. Derive them from one layout function and scope collapse to the expanded timeline state.
2026-08-19 18:55:43 +08:00
47 changed files with 1161 additions and 274 deletions
+2 -1
View File
@@ -3,7 +3,8 @@ TJWATER_KEYCLOAK_ISSUER=https://keycloak.example.com/realms/tjwater
TJWATER_KEYCLOAK_CLIENT_ID=next-tjwater TJWATER_KEYCLOAK_CLIENT_ID=next-tjwater
TJWATER_MAPBOX_ACCESS_TOKEN= TJWATER_MAPBOX_ACCESS_TOKEN=
TJWATER_MAP_URL=https://geoserver.waternetwork.cn/geoserver TJWATER_MAP_URL=https://geoserver.waternetwork.cn/geoserver
TJWATER_GEOSERVER_WORKSPACE=tjwater TJWATER_GEOSERVER_WORKSPACE=tjwater_next
TJWATER_SERVER_API_BASE_URL=http://127.0.0.1:8000
TJWATER_AGENT_API_BASE_URL=http://127.0.0.1:8787 TJWATER_AGENT_API_BASE_URL=http://127.0.0.1:8787
TJWATER_ENABLE_DEV_PANEL=false TJWATER_ENABLE_DEV_PANEL=false
TJWATER_ENABLE_MSW=false TJWATER_ENABLE_MSW=false
+3 -2
View File
@@ -39,7 +39,8 @@ Agent 服务默认地址为 `http://127.0.0.1:8787`。需要完整对话能力
| `TJWATER_KEYCLOAK_CLIENT_ID` | `next-tjwater` | Keycloak public client ID | | `TJWATER_KEYCLOAK_CLIENT_ID` | `next-tjwater` | Keycloak public client ID |
| `TJWATER_MAPBOX_ACCESS_TOKEN` | 空 | Mapbox 底图访问令牌 | | `TJWATER_MAPBOX_ACCESS_TOKEN` | 空 | Mapbox 底图访问令牌 |
| `TJWATER_MAP_URL` | `https://geoserver.waternetwork.cn/geoserver` | GeoServer 服务地址 | | `TJWATER_MAP_URL` | `https://geoserver.waternetwork.cn/geoserver` | GeoServer 服务地址 |
| `TJWATER_GEOSERVER_WORKSPACE` | `tjwater` | GeoServer 工作区 | | `TJWATER_GEOSERVER_WORKSPACE` | `tjwater_next` | GeoServer 工作区 |
| `TJWATER_SERVER_API_BASE_URL` | `http://127.0.0.1:8000` | 浏览器访问的 TJWater 后端 API 地址 |
| `TJWATER_AGENT_API_BASE_URL` | `http://127.0.0.1:8787` | 浏览器访问的 Agent API 地址 | | `TJWATER_AGENT_API_BASE_URL` | `http://127.0.0.1:8787` | 浏览器访问的 Agent API 地址 |
| `TJWATER_ENABLE_DEV_PANEL` | `false` | 是否显示开发面板 | | `TJWATER_ENABLE_DEV_PANEL` | `false` | 是否显示开发面板 |
| `TJWATER_ENABLE_MSW` | `false` | 是否启用浏览器端 Mock Service Worker | | `TJWATER_ENABLE_MSW` | `false` | 是否启用浏览器端 Mock Service Worker |
@@ -50,7 +51,7 @@ Agent 服务默认地址为 `http://127.0.0.1:8787`。需要完整对话能力
Keycloak 中的 `next-tjwater` Client 应关闭 Client Authentication,开启 Standard Flow,并关闭 Implicit Flow 与 Direct Access Grants。Valid Redirect URIs 和 Web Origins 只配置实际使用的前端地址。 Keycloak 中的 `next-tjwater` Client 应关闭 Client Authentication,开启 Standard Flow,并关闭 Implicit Flow 与 Direct Access Grants。Valid Redirect URIs 和 Web Origins 只配置实际使用的前端地址。
Agent 请求由浏览器直接访问 `TJWATER_AGENT_API_BASE_URL`,部署环境需要为前端来源配置 CORS。语音播放始终请求同源 `/api/tts/edge`;开发和预览由 Vite 中间件处理,生产镜像会启动仅监听容器回环地址的 Edge TTS 适配器。无需配置 TTS 服务 URL,可选的服务端变量 `EDGE_TTS_VOICE` 用于覆盖默认中文语音。 业务数据与 Agent 请求分别由浏览器直接访问 `TJWATER_SERVER_API_BASE_URL` `TJWATER_AGENT_API_BASE_URL`,部署环境需要为前端来源配置 CORS。语音播放始终请求同源 `/api/tts/edge`;开发和预览由 Vite 中间件处理,生产镜像会启动仅监听容器回环地址的 Edge TTS 适配器。无需配置 TTS 服务 URL,可选的服务端变量 `EDGE_TTS_VOICE` 用于覆盖默认中文语音。
## 常用命令 ## 常用命令
+3 -1
View File
@@ -7,7 +7,8 @@ runtime_config=$(jq -cn \
--arg keycloakClientId "${TJWATER_KEYCLOAK_CLIENT_ID:-next-tjwater}" \ --arg keycloakClientId "${TJWATER_KEYCLOAK_CLIENT_ID:-next-tjwater}" \
--arg mapboxAccessToken "${TJWATER_MAPBOX_ACCESS_TOKEN:-}" \ --arg mapboxAccessToken "${TJWATER_MAPBOX_ACCESS_TOKEN:-}" \
--arg mapUrl "${TJWATER_MAP_URL:-https://geoserver.waternetwork.cn/geoserver}" \ --arg mapUrl "${TJWATER_MAP_URL:-https://geoserver.waternetwork.cn/geoserver}" \
--arg geoserverWorkspace "${TJWATER_GEOSERVER_WORKSPACE:-tjwater}" \ --arg geoserverWorkspace "${TJWATER_GEOSERVER_WORKSPACE:-tjwater_next}" \
--arg serverApiBaseUrl "${TJWATER_SERVER_API_BASE_URL:-http://127.0.0.1:8000}" \
--arg agentApiBaseUrl "${TJWATER_AGENT_API_BASE_URL:-http://127.0.0.1:8787}" \ --arg agentApiBaseUrl "${TJWATER_AGENT_API_BASE_URL:-http://127.0.0.1:8787}" \
--arg enableDevPanel "${TJWATER_ENABLE_DEV_PANEL:-false}" \ --arg enableDevPanel "${TJWATER_ENABLE_DEV_PANEL:-false}" \
--arg enableMsw "${TJWATER_ENABLE_MSW:-false}" \ --arg enableMsw "${TJWATER_ENABLE_MSW:-false}" \
@@ -18,6 +19,7 @@ runtime_config=$(jq -cn \
TJWATER_MAPBOX_ACCESS_TOKEN: $mapboxAccessToken, TJWATER_MAPBOX_ACCESS_TOKEN: $mapboxAccessToken,
TJWATER_MAP_URL: $mapUrl, TJWATER_MAP_URL: $mapUrl,
TJWATER_GEOSERVER_WORKSPACE: $geoserverWorkspace, TJWATER_GEOSERVER_WORKSPACE: $geoserverWorkspace,
TJWATER_SERVER_API_BASE_URL: $serverApiBaseUrl,
TJWATER_AGENT_API_BASE_URL: $agentApiBaseUrl, TJWATER_AGENT_API_BASE_URL: $agentApiBaseUrl,
TJWATER_ENABLE_DEV_PANEL: $enableDevPanel, TJWATER_ENABLE_DEV_PANEL: $enableDevPanel,
TJWATER_ENABLE_MSW: $enableMsw TJWATER_ENABLE_MSW: $enableMsw
+227
View File
@@ -0,0 +1,227 @@
# 亚克力设计类别与组件使用清单
本文档记录当前前端的亚克力材质体系及实际使用位置。样式定义以 `src/styles.css` 为准,组件映射以 `src/features/` 下的现有代码为准。
## 总览
当前材质体系分为 3 组,共 13 个语义类:
| 分组 | 数量 | 类别 | 是否使用背景模糊 |
|---|---:|---|---|
| 外层亚克力与临时玻璃 | 4 | 导航、面板、独立控件、临时任务浮层 | 是 |
| 内部实体表面 | 4 | Dock、控件面、内容凹槽、阅读面 | 否 |
| 语义状态表面 | 5 | 信息、正常、警告、危险、Agent 推理 | 否 |
只有第一组属于真正的亚克力或玻璃外壳。第二组用于亚克力壳层内部,保证文字、表单和密集数据不受底图干扰。第三组只表达业务状态,不承担界面层级。
## 1. 外层亚克力与临时玻璃
这组样式包含冷色半透明底色、饱和度、明度混合、细噪点、边框和阴影,并使用 `backdrop-filter`。同一区域只应保留一个带模糊的外层壳体,内部子元素应改用实体表面。
### `acrylic-navigation`
- 用途:全局导航栏。
- 模糊:使用面板级模糊,默认 22px,浅色底图 24px,卫星底图 20px。
- 特征:保留底部分隔和下投影,不使用顶部高光。
- 当前组件:
- `WorkbenchTopBar`,工作台顶部 Header。
- 文件:`src/features/workbench/components/workbench-top-bar.tsx`
### `acrylic-panel`
- 用途:承载持续显示或需要集中阅读的主要浮动面板。
- 模糊:使用面板级模糊,20px 至 24px。
- 当前组件:
- `AgentCommandPanel`Agent 命令面板外壳。
- `AgentCollapsedRail`Agent 收起状态侧栏。
- `ScheduledConditionFeed`,预约任务与工况面板,通过 `MAP_TOOL_PANEL_SURFACE_CLASS_NAME` 间接使用。
- `LayerControl``ControlPanel``Legend``BaseLayersControl`,地图工具面板,通过共享材质常量间接使用。
- `FeaturePopover`,要素详情浮层,通过 `MAP_FOCUS_SURFACE_CLASS_NAME` 间接使用。
- `FeatureInsightPanel`,要素洞察面板。
- `MapDevPanel`,地图开发调试面板。
- `AlertMenu``ScenarioMenu``UserMenu`,顶部导航下拉菜单。
- `MapNotice`,地图通知。
- 直接或间接涉及文件:
- `src/features/agent/components/agent-command-panel.tsx`
- `src/features/agent/components/agent-collapsed-rail.tsx`
- `src/features/map/core/components/map-control-styles.ts`
- `src/features/map/core/components/notice.tsx`
- `src/features/workbench/components/scheduled-condition-feed.tsx`
- `src/features/workbench/components/feature-popover.tsx`
- `src/features/workbench/components/feature-insight-panel.tsx`
- `src/features/workbench/components/map-dev-panel.tsx`
- `src/features/workbench/components/workbench-top-bar-menus.tsx`
### `acrylic-control`
- 用途:始终悬浮在地图上的紧凑型独立控件。
- 模糊:使用控件级模糊,默认 18px,浅色底图 20px,卫星底图 16px。
- 当前组件:
- `Toolbar`,地图主工具栏。
- `DrawToolbar`,绘制工具栏。
- `ZoomControl`,缩放控件。
- `ScaleLine`,比例尺。
- `BaseLayersControl`,底图切换入口与预览控件。
- `map-workbench-page.tsx` 中的浮动地图控制入口。
- 使用方式:上述组件主要通过 `MAP_CONTROL_SURFACE_CLASS_NAME` 间接引用。
- 共享定义:`src/features/map/core/components/map-control-styles.ts`
### `glass-transient`
- 用途:短时出现、需要突出当前任务状态的玻璃浮层。
- 模糊:沿用控件级模糊,16px 至 20px。
- 当前组件:
- `AgentTaskTicker` 的活动任务卡片。
- 文件:`src/features/workbench/components/agent-task-ticker.tsx`
- 约束:仅用于活动任务浮条,非活动堆叠卡改用 `surface-control`
## 2. 内部实体表面
这组样式不使用 `backdrop-filter`。它们用于亚克力外壳内部,通过不透明度和背景层级提高可读性,避免出现嵌套模糊。
### `surface-dock`
- 用途:预留给固定 Dock 或侧边停靠区域。
- 当前状态:已在 `src/styles.css` 中定义,暂无组件直接使用。
- 适用组件:
- 桌面端固定展开的 Agent 侧栏,前提是它从地图浮层改为占据稳定布局宽度的 Dock。
- 固定展开的预约工况详情侧栏或图层管理侧栏,前提是侧栏长期贴边并参与工作区布局。
- 后续可能增加的对象目录、调度队列等常驻侧栏。
- 不适用:临时弹层、悬浮工具面板、下拉菜单和可覆盖地图的短时侧栏。这些组件仍应使用 `acrylic-panel`
### `surface-control`
- 用途:面板内部的 Header、输入区域、筛选器、按钮、紧凑控制区和任务堆叠卡。
- 当前组件范围:
- Agent 收起侧栏条目、Agent 操作摘要按钮、命令面板快捷入口。
- 图层、量测和通用控制面板中的交互控件。
- 预约任务面板与工况详情中的筛选器、元信息块和次级操作。
- 任务浮条的非活动卡片。
- 要素洞察面板的内部按钮。
- Header 下拉菜单的内部控制区。
- 通用 `IconButton`
- 主要文件:
- `src/features/agent/components/agent-collapsed-rail.tsx`
- `src/features/agent/components/agent-command-panel.tsx`
- `src/features/agent/components/agent-operational-brief.tsx`
- `src/features/map/core/components/control-panel.tsx`
- `src/features/map/core/components/layer-control.tsx`
- `src/features/map/core/components/measure-panel.tsx`
- `src/features/workbench/components/agent-task-ticker.tsx`
- `src/features/workbench/components/scheduled-condition-feed.tsx`
- `src/features/workbench/components/scheduled-condition-detail-panel.tsx`
- `src/features/workbench/components/feature-insight-panel.tsx`
- `src/features/workbench/components/workbench-top-bar-menus.tsx`
### `surface-well`
- 用途:低密度内容区、空白支撑区、未选中的面板条目和内部凹槽。
- 当前组件:
- `LayerPanel``LayerControl` 的未激活条目。
- `MeasurePanel``DrawToolbar``AnnotationPanel` 的内部区域。
- `ControlPanel``Legend``BaseLayersControl` 的分组与空白区域。
- Header 下拉菜单的普通菜单项。
- 使用方式:部分组件直接引用,部分通过 `MAP_READABLE_SURFACE_CLASS_NAME` 间接引用。
### `surface-reading`
- 用途:消息、报告、表单、属性列表、时间线内容和其他密集阅读区域。
- 当前组件范围:
- Agent 消息详情、操作摘要、UI Envelope 内容和命令面板空状态。
- 预约任务列表、工况详情、KPI、报告内容与下拉列表。
- 要素洞察、属性列表和底图名称标签。
- Header 下拉菜单中的详情块与空状态。
- 主要文件:
- `src/features/agent/components/agent-command-panel.tsx`
- `src/features/agent/components/agent-message-details.tsx`
- `src/features/agent/components/agent-operational-brief.tsx`
- `src/features/agent/components/agent-ui-envelope-renderer.tsx`
- `src/features/workbench/components/scheduled-condition-feed.tsx`
- `src/features/workbench/components/scheduled-condition-detail-panel.tsx`
- `src/features/workbench/components/feature-insight-panel.tsx`
- `src/features/workbench/components/feature-property-list.tsx`
- `src/features/workbench/components/workbench-top-bar-menus.tsx`
- `src/features/map/core/components/base-layers-control.tsx`
## 3. 语义状态表面
语义状态表面不使用模糊,只表达业务含义。它们不能替代 `acrylic-panel``surface-control``surface-reading` 来建立界面层级。
| 类名 | 含义 | 当前使用位置 |
|---|---|---|
| `material-tone-info` | 信息、液位等蓝色状态 | `FeatureInsightPanel` 的雷达液位区块 |
| `material-tone-normal` | 正常、健康、流量或水质状态 | `FeatureInsightPanel` 的水质、综合监测等区块 |
| `material-tone-warning` | 风险、告警、超声流量等橙色状态 | `FeatureInsightPanel` 的告警与超声流量区块 |
| `material-tone-danger` | 严重故障或事故状态 | 已定义,建议用于严重事故、关键设备故障和高风险工况内容块 |
| `material-tone-agent` | Agent 推理或模型建议 | 已定义,建议用于 Agent 解释、推理结论和建议动作内容块 |
## 未使用样式的建议落点
### `surface-dock`
建议用于参与页面布局的常驻停靠区,而不是覆盖在地图上的浮层。现有组件中,以下场景适合在布局调整后使用:
- `AgentCommandPanel` 的桌面常驻模式。只有当面板固定贴左、占据稳定宽度并推动地图安全区时才使用。
- `ScheduledConditionFeed``ConditionDetailPanel` 的常驻右侧模式。适用于调度员持续对照地图与工况详情的工作流。
- `LayerPanel` 的常驻图层目录模式。适用于图层较多、需要持续管理的专业工作台。
如果组件仍然悬浮在地图上、可以随时关闭或与其他浮层替换,应继续使用 `acrylic-panel`
### `material-tone-danger`
建议用于已经确认的严重业务状态,不用于普通校验失败或短暂接口错误。现有组件中适合的落点包括:
- `FeatureInsightPanel`:要素状态为 `critical`、严重故障、爆管或关键设备失效时的状态说明区块。
- `ScheduledConditionDetailPanel`:工况风险等级为 `critical`,或任务状态为 `error` 且代表真实运行事故时的风险摘要和影响范围区块。
- `MapNotice`:仅在需要持续强调严重运行事故时用于通知内容区,不替换通知最外层的 `acrylic-panel`
- Agent 权限或确认卡片:仅当待执行动作具有明确的高风险业务后果时使用,不能仅因提交失败就使用。
不应使用在网络请求失败、加载失败、表单校验错误等通用技术错误上。这类错误继续使用局部红色图标、边框或文字即可,避免把技术故障误读为管网事故。
### `material-tone-agent`
建议用于明确由 Agent 或模型生成的内容,帮助用户区分事实数据、业务状态与机器推理。现有组件中适合的落点包括:
- `FeatureInsightPanel` 的“Agent 解释”区块。该区块目前使用 `material-tone-warning`,如果内容只是解释或推理而非风险告警,更适合改用 `material-tone-agent`
- `AgentOperationalBrief`:Agent 生成的任务判断、建议动作或需要人工确认的结论区块。
- `AgentMessageDetails`:推理摘要、模型结论、建议方案等非原始证据内容。
- `AgentUiEnvelopeRenderer`:由 Agent 返回的建议面板或注册组件中的结论区块。
- `ScheduledConditionDetailPanel`:Agent 针对工况生成的处置建议区块。
原始监测值、设备属性、人工录入内容和系统事实不应使用该样式。即使这些内容出现在 Agent 面板内,也应继续使用 `surface-reading` 或对应的业务状态色。
## 共享类映射
地图核心组件通过 `src/features/map/core/components/map-control-styles.ts` 复用材质类,避免在各组件中重复写样式值。
| 共享常量 | 实际材质类 | 典型用途 |
|---|---|---|
| `MAP_CONTROL_SURFACE_CLASS_NAME` | `acrylic-control border` | 工具栏、缩放、比例尺、底图入口 |
| `MAP_TOOL_PANEL_SURFACE_CLASS_NAME` | `acrylic-panel border` | 图层、图例、控制和预约任务面板 |
| `MAP_FOCUS_SURFACE_CLASS_NAME` | `acrylic-panel border` | 要素详情等焦点浮层 |
| `MAP_READABLE_SURFACE_CLASS_NAME` | `surface-well border` | 面板内部低密度区域 |
| `MAP_READABLE_SURFACE_STRONG_CLASS_NAME` | `surface-control border` | 面板内部高可读控制区 |
## 自适应与无障碍规则
- `data-basemap-tone="light"` 会提高部分面板模糊强度,并降低导航、控件和任务浮条的背景不透明度。
- `data-basemap-tone="satellite"` 会提高背景不透明度、轮廓和阴影强度,压制卫星底图纹理。
- `prefers-reduced-transparency: reduce` 下,亚克力和玻璃表面会切换到接近不透明的背景并关闭模糊。
- 不支持 `backdrop-filter` 的浏览器使用同样的高不透明度回退。
- `forced-colors: active` 下,材质背景和边框改用系统颜色,并关闭背景图与阴影。
## 使用约束
1. 一个浮动区域只允许最外层使用 `acrylic-*``glass-transient`
2. 外层亚克力内部使用 `surface-control``surface-well``surface-reading`,不要叠加第二层模糊。
3. 密集文字、表格、属性和值必须落在 `surface-reading` 等高可读表面上。
4. `material-tone-*` 只表达状态,不表达层级,也不添加模糊。
5. 新增地图控件时优先复用 `map-control-styles.ts` 中的共享常量。
6. 组件只负责布局、圆角和交互状态,材质颜色、边框和阴影由全局语义类统一维护。
## 当前待清理项
- `surface-dock` 可在固定 Dock 落地时启用。如果产品不计划引入参与布局的常驻侧栏,可以移除该样式。
- `material-tone-danger` 应等待明确的严重业务状态落点,不要为了消除未使用状态而套用到通用错误提示。
- `material-tone-agent` 可优先用于 `FeatureInsightPanel` 的“Agent 解释”和 Agent 建议内容,替代不准确的警告语义。
- Agent 面板仍有少量局部类直接使用 `--surface-control``--surface-reading` 或自定义透明背景。后续统一材质语义时,应评估是否迁移到对应的 `surface-*` 类。
+2 -1
View File
@@ -1,5 +1,6 @@
globalThis.__TJWATER_CONFIG__ = { globalThis.__TJWATER_CONFIG__ = {
TJWATER_AUTH_MODE: "required", TJWATER_AUTH_MODE: "required",
TJWATER_KEYCLOAK_ISSUER: "", TJWATER_KEYCLOAK_ISSUER: "",
TJWATER_KEYCLOAK_CLIENT_ID: "next-tjwater" TJWATER_KEYCLOAK_CLIENT_ID: "next-tjwater",
TJWATER_SERVER_API_BASE_URL: "http://127.0.0.1:8000"
}; };
+1
View File
@@ -17,6 +17,7 @@ test("renders the migrated WebGIS workbench shell", async ({ page }) => {
expect(runtimeConfig).toMatchObject({ expect(runtimeConfig).toMatchObject({
TJWATER_MAP_URL: expect.any(String), TJWATER_MAP_URL: expect.any(String),
TJWATER_GEOSERVER_WORKSPACE: expect.any(String), TJWATER_GEOSERVER_WORKSPACE: expect.any(String),
TJWATER_SERVER_API_BASE_URL: expect.any(String),
TJWATER_AGENT_API_BASE_URL: expect.any(String) TJWATER_AGENT_API_BASE_URL: expect.any(String)
}); });
expect(Object.keys(runtimeConfig as Record<string, unknown>)).not.toContainEqual( expect(Object.keys(runtimeConfig as Record<string, unknown>)).not.toContainEqual(
@@ -62,7 +62,7 @@ function createRequest(name: string) {
toolCallId: "call-1", toolCallId: "call-1",
sessionId: "session-1", sessionId: "session-1",
name, name,
params: { items: [{ sensor_id: "MP01", level: "high" }] }, params: { items: [{ device_id: "MP01", level: "high" }] },
issuedAt: Date.now(), issuedAt: Date.now(),
expiresAt: Date.now() + 15_000 expiresAt: Date.now() + 15_000
}; };
@@ -74,7 +74,7 @@ describe("toTrustedMapAction", () => {
expect(toTrustedMapAction("locate_pipes", { pipe_ids: "P1,P2" })).toEqual({ expect(toTrustedMapAction("locate_pipes", { pipe_ids: "P1,P2" })).toEqual({
type: "locate_features", type: "locate_features",
featureIds: ["P1", "P2"], featureIds: ["P1", "P2"],
layer: "geo_pipes_mat", layer: "pipes",
fallbackText: undefined fallbackText: undefined
}); });
}); });
@@ -83,7 +83,7 @@ describe("toTrustedMapAction", () => {
expect(toTrustedMapAction("locate_junctions", { junction_id: 42 })).toMatchObject({ expect(toTrustedMapAction("locate_junctions", { junction_id: 42 })).toMatchObject({
type: "locate_features", type: "locate_features",
featureIds: ["42"], featureIds: ["42"],
layer: "geo_junctions_mat" layer: "junctions"
}); });
}); });
@@ -97,7 +97,7 @@ describe("toTrustedMapAction", () => {
expect(toTrustedMapAction("locate_scada", { scada_id: "S-1" })).toMatchObject({ expect(toTrustedMapAction("locate_scada", { scada_id: "S-1" })).toMatchObject({
type: "locate_features", type: "locate_features",
featureIds: ["S-1"], featureIds: ["S-1"],
layer: "geo_scada" layer: "scada"
}); });
}); });
+9 -18
View File
@@ -164,30 +164,21 @@ function isRecord(value: unknown): value is Record<string, unknown> {
} }
const LEGACY_LOCATE_ACTION_LAYERS: Record<string, string> = { const LEGACY_LOCATE_ACTION_LAYERS: Record<string, string> = {
locate_junctions: "geo_junctions_mat", locate_junctions: "junctions",
locate_pipes: "geo_pipes_mat", locate_pipes: "pipes",
locate_valves: "geo_valves", locate_valves: "valves",
locate_reservoirs: "geo_reservoirs", locate_reservoirs: "reservoirs",
locate_scada: "geo_scada", locate_scada: "scada",
locate_pumps: "geo_pumps", locate_pumps: "pumps",
locate_tanks: "geo_tanks" locate_tanks: "tanks"
}; };
const RESULT_REF_PATTERN = /^res-[A-Za-z0-9_-]{8,128}$/; const RESULT_REF_PATTERN = /^res-[A-Za-z0-9_-]{8,128}$/;
const SUPPLY_SOURCE_IDS = ["junctions", "pipes", "valves", "reservoirs", "scada"]; const SUPPLY_SOURCE_IDS = ["junctions", "pipes", "valves", "reservoirs", "scada", "pumps", "tanks"];
const ALLOWED_LAYER_IDS = new Set(SUPPLY_SOURCE_IDS); const ALLOWED_LAYER_IDS = new Set(SUPPLY_SOURCE_IDS);
const ALLOWED_LAYER_GROUP_IDS = new Set([...SUPPLY_SOURCE_IDS, "simulation"]); const ALLOWED_LAYER_GROUP_IDS = new Set([...SUPPLY_SOURCE_IDS, "simulation"]);
const ALLOWED_LOCATE_LAYERS = new Set([ const ALLOWED_LOCATE_LAYERS = new Set([
...SUPPLY_SOURCE_IDS, ...SUPPLY_SOURCE_IDS
"pumps",
"tanks",
"geo_junctions_mat",
"geo_pipes_mat",
"geo_valves",
"geo_reservoirs",
"geo_scada",
"geo_pumps",
"geo_tanks"
]); ]);
const WEB_MERCATOR_RADIUS = 6378137; const WEB_MERCATOR_RADIUS = 6378137;
@@ -0,0 +1,73 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { fetchScadaDeviceCollection } from "./scada-client";
const device = {
device_id: "SCADA-1",
device_type: "pressure",
node_id: "J-1",
link_id: null,
api_query_id: "pressure-query",
transmission_mode: "realtime",
transmission_frequency: "2s",
reliability: 100,
x: 10,
y: 20,
longitude: 121.5,
latitude: 30.9
};
describe("SCADA API client", () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it("resolves the active project and builds GeoJSON keyed by device_id", async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(new Response(JSON.stringify({
items: [{ project_id: "project-1", gs_workspace: "tjwater_next", status: "active" }],
total: 1,
limit: 1000,
offset: 0
}), { status: 200 }))
.mockResolvedValueOnce(new Response(JSON.stringify({
items: [device, { ...device, device_id: "SCADA-NO-LOCATION", longitude: null }],
total: 2,
limit: 1000,
offset: 0
}), { status: 200 }));
vi.stubGlobal("fetch", fetchMock);
const getAccessToken = vi.fn().mockResolvedValue("access-token");
const collection = await fetchScadaDeviceCollection(getAccessToken);
expect(collection.features).toEqual([
expect.objectContaining({
id: "SCADA-1",
geometry: { type: "Point", coordinates: [121.5, 30.9] },
properties: expect.objectContaining({ device_id: "SCADA-1", node_id: "J-1" })
})
]);
expect(getAccessToken).toHaveBeenCalledOnce();
expect(fetchMock).toHaveBeenCalledTimes(2);
const projectHeaders = new Headers(fetchMock.mock.calls[0]?.[1]?.headers);
const deviceHeaders = new Headers(fetchMock.mock.calls[1]?.[1]?.headers);
expect(projectHeaders.get("Authorization")).toBe("Bearer access-token");
expect(deviceHeaders.get("Authorization")).toBe("Bearer access-token");
expect(deviceHeaders.get("X-Project-Id")).toBe("project-1");
expect(String(fetchMock.mock.calls[1]?.[0])).toContain("/api/v1/scada-devices?limit=1000&offset=0");
});
it("does not query devices when no active project matches the workspace", async () => {
const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({
items: [{ project_id: "project-1", gs_workspace: "other", status: "active" }],
total: 1,
limit: 1000,
offset: 0
}), { status: 200 }));
vi.stubGlobal("fetch", fetchMock);
await expect(fetchScadaDeviceCollection()).rejects.toThrow("未找到GeoServer工作空间");
expect(fetchMock).toHaveBeenCalledOnce();
});
});
+109
View File
@@ -0,0 +1,109 @@
import type { Feature, FeatureCollection, Point } from "geojson";
import { z } from "zod";
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
import { env } from "@/shared/config/env";
import { GEOSERVER_WORKSPACE } from "../map/geoserver-config";
const projectSchema = z.object({
project_id: z.string().min(1),
gs_workspace: z.string().min(1),
status: z.string()
});
const projectPageSchema = z.object({
items: z.array(projectSchema),
total: z.number().int().nonnegative(),
limit: z.number().int().positive(),
offset: z.number().int().nonnegative()
});
const scadaDeviceSchema = z.object({
device_id: z.string().trim().min(1),
device_type: z.string(),
node_id: z.string().nullable(),
link_id: z.string().nullable(),
api_query_id: z.string().nullable(),
transmission_mode: z.string(),
transmission_frequency: z.string(),
reliability: z.number().int().nullable(),
x: z.number().nullable(),
y: z.number().nullable(),
longitude: z.number().nullable(),
latitude: z.number().nullable()
});
const scadaDevicePageSchema = z.object({
items: z.array(scadaDeviceSchema),
total: z.number().int().nonnegative(),
limit: z.number().int().positive(),
offset: z.number().int().nonnegative()
});
export type ScadaDevice = z.infer<typeof scadaDeviceSchema>;
const SERVER_API_BASE_URL = env.TJWATER_SERVER_API_BASE_URL.replace(/\/$/, "");
export async function fetchScadaDeviceCollection(
getAccessToken?: AccessTokenProvider,
signal?: AbortSignal
): Promise<FeatureCollection<Point, ScadaDevice>> {
const accessToken = await getAccessToken?.();
const projects = projectPageSchema.parse(
await requestJson("/api/v1/projects?limit=1000&offset=0", accessToken, undefined, signal)
);
const project = projects.items.find(
(item) => item.status === "active" && item.gs_workspace === GEOSERVER_WORKSPACE
);
if (!project) {
throw new Error(`未找到GeoServer工作空间 ${GEOSERVER_WORKSPACE} 对应的项目`);
}
const page = scadaDevicePageSchema.parse(
await requestJson(
"/api/v1/scada-devices?limit=1000&offset=0",
accessToken,
project.project_id,
signal
)
);
if (page.total > page.items.length) {
throw new Error(`SCADA设备数量 ${page.total} 超过单次加载上限 ${page.items.length}`);
}
return {
type: "FeatureCollection",
features: page.items.flatMap(toScadaFeature)
};
}
function toScadaFeature(device: ScadaDevice): Array<Feature<Point, ScadaDevice>> {
if (device.longitude === null || device.latitude === null) return [];
return [{
type: "Feature",
id: device.device_id,
geometry: {
type: "Point",
coordinates: [device.longitude, device.latitude]
},
properties: device
}];
}
async function requestJson(
path: string,
accessToken: string | null | undefined,
projectId: string | undefined,
signal: AbortSignal | undefined
) {
const headers = new Headers({ Accept: "application/json" });
if (accessToken) headers.set("Authorization", `Bearer ${accessToken}`);
if (projectId) headers.set("X-Project-Id", projectId);
const response = await fetch(`${SERVER_API_BASE_URL}${path}`, { headers, signal });
const payload: unknown = await response.json().catch(() => null);
if (!response.ok) {
const detail = payload && typeof payload === "object" && "detail" in payload
? String(payload.detail)
: `HTTP ${response.status}`;
throw new Error(`SCADA API请求失败:${detail}`);
}
return payload;
}
@@ -32,7 +32,9 @@ const GROUP_LABELS: Record<AvailableWaterNetworkSourceId, string> = {
junctions: "节点", junctions: "节点",
valves: "阀门", valves: "阀门",
reservoirs: "水库", reservoirs: "水库",
scada: "SCADA" scada: "SCADA",
pumps: "水泵",
tanks: "水箱"
}; };
const STYLE_GROUP_IDS = Object.keys(LAYER_GROUP_GEOMETRIES) as LayerGroupId[]; const STYLE_GROUP_IDS = Object.keys(LAYER_GROUP_GEOMETRIES) as LayerGroupId[];
@@ -63,7 +65,7 @@ const LABEL_PRESETS: Record<
unit: "m", unit: "m",
options: [ options: [
{ value: "elevation", label: "高程 elevation" }, { value: "elevation", label: "高程 elevation" },
{ value: "demand", label: "需水量 demand" } { value: "base_demand", label: "基础需水量 base_demand" }
] ]
}, },
valves: { valves: {
@@ -88,6 +90,9 @@ const ERROR_LABELS: Record<string, string> = {
MAP_NOT_READY: "地图尚未就绪", MAP_NOT_READY: "地图尚未就绪",
FEATURE_NOT_FOUND: "目标要素不存在", FEATURE_NOT_FOUND: "目标要素不存在",
WFS_UNAVAILABLE: "WFS 查询暂不可用", WFS_UNAVAILABLE: "WFS 查询暂不可用",
SCADA_API_UNAVAILABLE: "SCADA API 查询暂不可用",
SCADA_FEATURES_NOT_FOUND: "未找到对应的 SCADA 设备",
ACTION_SUPERSEDED: "SCADA 地图动作已被新请求替代",
FLOW_UNAVAILABLE: "水流图层初始化失败", FLOW_UNAVAILABLE: "水流图层初始化失败",
INVALID_FLOW_CALCULATION: "管道 ID 或有符号流速无效", INVALID_FLOW_CALCULATION: "管道 ID 或有符号流速无效",
INVALID_STYLE_PATCH: "参数不符合受控范围" INVALID_STYLE_PATCH: "参数不符合受控范围"
@@ -7,7 +7,7 @@ import {
import { INTERACTIVE_HIT_LAYER_IDS } from "../map/layers"; import { INTERACTIVE_HIT_LAYER_IDS } from "../map/layers";
describe("map feature interaction state", () => { describe("map feature interaction state", () => {
const feature = { source: "junctions", sourceLayer: "geo_junctions_mat", id: "junction-7" } as const; const feature = { source: "junctions", sourceLayer: "junctions", id: "junction-7" } as const;
it("maps selected business features to promoted vector feature references", () => { it("maps selected business features to promoted vector feature references", () => {
expect(toMapFeatureReference({ id: "junction-7", layer: "junctions" })).toEqual(feature); expect(toMapFeatureReference({ id: "junction-7", layer: "junctions" })).toEqual(feature);
@@ -18,7 +18,7 @@ describe("map feature interaction state", () => {
const map = { setFeatureState: vi.fn(), removeFeatureState: vi.fn() }; const map = { setFeatureState: vi.fn(), removeFeatureState: vi.fn() };
setMapFeatureInteractionState(map, feature, { selected: true, hovered: false }); setMapFeatureInteractionState(map, feature, { selected: true, hovered: false });
expect(map.setFeatureState).toHaveBeenCalledWith( expect(map.setFeatureState).toHaveBeenCalledWith(
{ source: "junctions", sourceLayer: "geo_junctions_mat", id: "junction-7" }, { source: "junctions", sourceLayer: "junctions", id: "junction-7" },
{ selected: true, hovered: false } { selected: true, hovered: false }
); );
}); });
@@ -27,7 +27,7 @@ describe("map feature interaction state", () => {
const map = { setFeatureState: vi.fn(), removeFeatureState: vi.fn() }; const map = { setFeatureState: vi.fn(), removeFeatureState: vi.fn() };
clearMapFeatureInteractionState(map, feature, "selected"); clearMapFeatureInteractionState(map, feature, "selected");
expect(map.removeFeatureState).toHaveBeenCalledWith( expect(map.removeFeatureState).toHaveBeenCalledWith(
{ source: "junctions", sourceLayer: "geo_junctions_mat", id: "junction-7" }, { source: "junctions", sourceLayer: "junctions", id: "junction-7" },
"selected" "selected"
); );
}); });
@@ -38,6 +38,8 @@ describe("map feature interaction state", () => {
"junctions-hit", "junctions-hit",
"valves-hit", "valves-hit",
"reservoirs-hit", "reservoirs-hit",
"pumps-hit",
"tanks-hit",
"scada-hit" "scada-hit"
]); ]);
}); });
@@ -12,7 +12,7 @@ import { SOURCE_LAYERS, type WaterNetworkSourceId } from "../map/sources";
export type MapFeatureInteractionState = { export type MapFeatureInteractionState = {
source: WaterNetworkSourceId; source: WaterNetworkSourceId;
sourceLayer: string; sourceLayer?: string;
id: string; id: string;
hovered: boolean; hovered: boolean;
selected: boolean; selected: boolean;
@@ -25,7 +25,9 @@ export function toMapFeatureReference(
feature: Pick<DetailFeature, "id" | "layer"> | null feature: Pick<DetailFeature, "id" | "layer"> | null
): FeatureReference | null { ): FeatureReference | null {
if (!feature?.id) return null; if (!feature?.id) return null;
return { source: feature.layer, sourceLayer: SOURCE_LAYERS[feature.layer], id: feature.id }; return feature.layer === "scada"
? { source: feature.layer, id: feature.id }
: { source: feature.layer, sourceLayer: SOURCE_LAYERS[feature.layer], id: feature.id };
} }
export function setMapFeatureInteractionState( export function setMapFeatureInteractionState(
@@ -33,10 +35,7 @@ export function setMapFeatureInteractionState(
feature: FeatureReference, feature: FeatureReference,
state: Partial<Pick<MapFeatureInteractionState, "hovered" | "selected">> state: Partial<Pick<MapFeatureInteractionState, "hovered" | "selected">>
) { ) {
map.setFeatureState( map.setFeatureState(toFeatureStateTarget(feature), state);
{ source: feature.source, sourceLayer: feature.sourceLayer, id: feature.id },
state
);
} }
export function clearMapFeatureInteractionState( export function clearMapFeatureInteractionState(
@@ -44,10 +43,7 @@ export function clearMapFeatureInteractionState(
feature: FeatureReference, feature: FeatureReference,
key?: "hovered" | "selected" key?: "hovered" | "selected"
) { ) {
map.removeFeatureState( map.removeFeatureState(toFeatureStateTarget(feature), key);
{ source: feature.source, sourceLayer: feature.sourceLayer, id: feature.id },
key
);
} }
type UseMapInteractionsOptions = { type UseMapInteractionsOptions = {
@@ -81,7 +77,9 @@ export function useMapInteractions({
const id = feature && getFeatureId(feature); const id = feature && getFeatureId(feature);
if (!source || !id) return; if (!source || !id) return;
const next = { source, sourceLayer: SOURCE_LAYERS[source], id }; const next = source === "scada"
? { source, id }
: { source, sourceLayer: SOURCE_LAYERS[source], id };
if (selectedFeature?.id === id && selectedFeature.layer === source) { if (selectedFeature?.id === id && selectedFeature.layer === source) {
clearHoveredFeature(); clearHoveredFeature();
return; return;
@@ -127,3 +125,9 @@ export function useMapInteractions({
}; };
}, [mapReady, mapRef, selectedFeature]); }, [mapReady, mapRef, selectedFeature]);
} }
function toFeatureStateTarget(feature: FeatureReference) {
return feature.sourceLayer
? { source: feature.source, sourceLayer: feature.sourceLayer, id: feature.id }
: { source: feature.source, id: feature.id };
}
@@ -1,4 +1,6 @@
import type { FeatureCollection, Point } from "geojson";
import type { Map as MapLibreMap } from "maplibre-gl"; import type { Map as MapLibreMap } from "maplibre-gl";
import type { ScadaDevice } from "../api/scada-client";
import { useEffect, useRef, useSyncExternalStore, type RefObject } from "react"; import { useEffect, useRef, useSyncExternalStore, type RefObject } from "react";
import { getResponsiveWorkbenchPadding } from "../map/camera"; import { getResponsiveWorkbenchPadding } from "../map/camera";
import { WorkbenchMapController } from "../map/workbench-map-controller"; import { WorkbenchMapController } from "../map/workbench-map-controller";
@@ -9,7 +11,8 @@ export function useWorkbenchMapController({
leftPanelOpen, leftPanelOpen,
rightPanelOpen, rightPanelOpen,
conditionPanelExpanded = false, conditionPanelExpanded = false,
agentPanelWidth agentPanelWidth,
getScadaFeatures
}: { }: {
mapRef: RefObject<MapLibreMap | null>; mapRef: RefObject<MapLibreMap | null>;
mapReady: boolean; mapReady: boolean;
@@ -17,6 +20,7 @@ export function useWorkbenchMapController({
rightPanelOpen: boolean; rightPanelOpen: boolean;
conditionPanelExpanded?: boolean; conditionPanelExpanded?: boolean;
agentPanelWidth?: number; agentPanelWidth?: number;
getScadaFeatures?: (deviceIds?: string[], signal?: AbortSignal) => Promise<FeatureCollection<Point, ScadaDevice>>;
}) { }) {
const valuesRef = useRef({ const valuesRef = useRef({
mapReady, mapReady,
@@ -48,7 +52,8 @@ export function useWorkbenchMapController({
valuesRef.current.agentPanelWidth valuesRef.current.agentPanelWidth
) )
: { top: 48, right: 48, bottom: 48, left: 48 }; : { top: 48, right: 48, bottom: 48, left: 48 };
} },
getScadaFeatures
}); });
} }
@@ -1,8 +1,11 @@
import "maplibre-gl/dist/maplibre-gl.css"; import "maplibre-gl/dist/maplibre-gl.css";
import maplibregl, { type Map as MapLibreMap, type MapSourceDataEvent } from "maplibre-gl"; import type { FeatureCollection, Point } from "geojson";
import { useEffect, useRef, useState, type RefObject } from "react"; import maplibregl, { type GeoJSONSource, type Map as MapLibreMap, type MapSourceDataEvent } from "maplibre-gl";
import { useCallback, useEffect, useRef, useState, type RefObject } from "react";
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
import { env } from "@/shared/config/env"; import { env } from "@/shared/config/env";
import { fetchScadaDeviceCollection, type ScadaDevice } from "../api/scada-client";
import type { DetailFeature } from "../types"; import type { DetailFeature } from "../types";
import { import {
SIMULATION_SOURCE_IDS, SIMULATION_SOURCE_IDS,
@@ -20,11 +23,13 @@ import { createValueLabelLayers } from "../map/value-label";
import { setSimulationLayersVisibility } from "../map/simulation-layers"; import { setSimulationLayersVisibility } from "../map/simulation-layers";
import { import {
AVAILABLE_WATER_NETWORK_SOURCE_IDS, AVAILABLE_WATER_NETWORK_SOURCE_IDS,
GEOSERVER_WATER_NETWORK_SOURCE_IDS,
createBaseStyle, createBaseStyle,
createWaterNetworkSources, createWaterNetworkSources,
WATER_NETWORK_GLOBAL_VIEW WATER_NETWORK_GLOBAL_VIEW
} from "../map/sources"; } from "../map/sources";
import { import {
SCADA_SOURCE_ID,
registerScadaImages, registerScadaImages,
scadaBusinessLayers, scadaBusinessLayers,
scadaFallbackBusinessLayers, scadaFallbackBusinessLayers,
@@ -46,6 +51,7 @@ type UseWorkbenchMapOptions = {
impactVisible: boolean; impactVisible: boolean;
onSelectFeature: (feature: DetailFeature) => void; onSelectFeature: (feature: DetailFeature) => void;
selectedFeature: DetailFeature | null; selectedFeature: DetailFeature | null;
getAccessToken?: AccessTokenProvider;
}; };
type UseWorkbenchMapResult = { type UseWorkbenchMapResult = {
@@ -53,6 +59,7 @@ type UseWorkbenchMapResult = {
mapReady: boolean; mapReady: boolean;
mapError: string | null; mapError: string | null;
sourceStatuses: WorkbenchSourceStatus[]; sourceStatuses: WorkbenchSourceStatus[];
getScadaFeatures: (deviceIds?: string[], signal?: AbortSignal) => Promise<FeatureCollection<Point, ScadaDevice>>;
fitNetworkBounds: () => void; fitNetworkBounds: () => void;
}; };
@@ -68,6 +75,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-api": "SCADA API",
"scada-icons": "SCADA 图标", "scada-icons": "SCADA 图标",
"annotation-source": "业务标注源" "annotation-source": "业务标注源"
}; };
@@ -76,7 +84,7 @@ const SOURCE_GROUP_BY_ID: Record<string, string> = {
"mapbox-light": "mapbox-base", "mapbox-light": "mapbox-base",
"mapbox-satellite": "mapbox-base", "mapbox-satellite": "mapbox-base",
...Object.fromEntries( ...Object.fromEntries(
AVAILABLE_WATER_NETWORK_SOURCE_IDS.map((sourceId) => [sourceId, "geoserver-mvt"]) GEOSERVER_WATER_NETWORK_SOURCE_IDS.map((sourceId) => [sourceId, "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"
@@ -86,13 +94,32 @@ export function useWorkbenchMap({
containerRef, containerRef,
impactVisible, impactVisible,
onSelectFeature, onSelectFeature,
selectedFeature selectedFeature,
getAccessToken
}: UseWorkbenchMapOptions): UseWorkbenchMapResult { }: UseWorkbenchMapOptions): UseWorkbenchMapResult {
const mapRef = useRef<MapLibreMap | null>(null); const mapRef = useRef<MapLibreMap | null>(null);
const impactVisibleRef = useRef(impactVisible); const impactVisibleRef = useRef(impactVisible);
const [mapReady, setMapReady] = useState(false); const [mapReady, setMapReady] = useState(false);
const [mapError, setMapError] = useState<string | null>(null); const [mapError, setMapError] = useState<string | null>(null);
const [sourceStatuses, setSourceStatuses] = useState<Record<string, WorkbenchSourceStatus>>({}); const [sourceStatuses, setSourceStatuses] = useState<Record<string, WorkbenchSourceStatus>>({});
const scadaCollectionRef = useRef<FeatureCollection<Point, ScadaDevice>>({
type: "FeatureCollection",
features: []
});
const scadaLoadPromiseRef = useRef<Promise<FeatureCollection<Point, ScadaDevice>>>(
Promise.resolve(scadaCollectionRef.current)
);
const getScadaFeatures = useCallback(async (deviceIds?: string[], signal?: AbortSignal) => {
const collection = await scadaLoadPromiseRef.current;
if (signal?.aborted) throw new DOMException("The operation was aborted", "AbortError");
if (!deviceIds) return collection;
const selectedIds = new Set(deviceIds);
return {
type: "FeatureCollection" as const,
features: collection.features.filter((feature) => selectedIds.has(String(feature.id)))
};
}, []);
useEffect(() => { useEffect(() => {
impactVisibleRef.current = impactVisible; impactVisibleRef.current = impactVisible;
@@ -104,6 +131,13 @@ export function useWorkbenchMap({
} }
const mapboxToken = env.TJWATER_MAPBOX_ACCESS_TOKEN || undefined; const mapboxToken = env.TJWATER_MAPBOX_ACCESS_TOKEN || undefined;
const scadaAbortController = new AbortController();
updateSourceStatus(setSourceStatuses, "scada-api", "loading", "正在通过API加载SCADA设备。");
const scadaLoadPromise = fetchScadaDeviceCollection(
getAccessToken,
scadaAbortController.signal
);
scadaLoadPromiseRef.current = scadaLoadPromise;
const map = new maplibregl.Map({ const map = new maplibregl.Map({
container: containerRef.current, container: containerRef.current,
style: createBaseStyle(mapboxToken), style: createBaseStyle(mapboxToken),
@@ -118,8 +152,11 @@ export function useWorkbenchMap({
map.on("load", async () => { map.on("load", async () => {
map.resize(); map.resize();
(map.getSource(SCADA_SOURCE_ID) as GeoJSONSource | undefined)?.setData(
scadaCollectionRef.current
);
const sources = createWaterNetworkSources(); const sources = createWaterNetworkSources();
AVAILABLE_WATER_NETWORK_SOURCE_IDS.forEach((sourceId) => GEOSERVER_WATER_NETWORK_SOURCE_IDS.forEach((sourceId) =>
map.addSource(sourceId, sources[sourceId]) map.addSource(sourceId, sources[sourceId])
); );
map.addSource(SIMULATION_SOURCE_IDS.impactArea, simulationSources.impactArea); map.addSource(SIMULATION_SOURCE_IDS.impactArea, simulationSources.impactArea);
@@ -166,6 +203,25 @@ export function useWorkbenchMap({
fitNetworkBounds(map, WATER_NETWORK_GLOBAL_VIEW); fitNetworkBounds(map, WATER_NETWORK_GLOBAL_VIEW);
}); });
void scadaLoadPromise.then((collection) => {
scadaCollectionRef.current = collection;
(map.getSource(SCADA_SOURCE_ID) as GeoJSONSource | undefined)?.setData(collection);
updateSourceStatus(
setSourceStatuses,
"scada-api",
"online",
`已通过API加载 ${collection.features.length} 个SCADA设备。`
);
}).catch((error) => {
if (scadaAbortController.signal.aborted) return;
updateSourceStatus(
setSourceStatuses,
"scada-api",
"offline",
error instanceof Error ? error.message : "SCADA API加载失败。"
);
});
let resizeFrame: number | null = null; let resizeFrame: number | null = null;
const resizeMap = () => map.resize(); const resizeMap = () => map.resize();
const resizeObserver = new ResizeObserver(() => { const resizeObserver = new ResizeObserver(() => {
@@ -213,11 +269,12 @@ export function useWorkbenchMap({
window.cancelAnimationFrame(resizeFrame); window.cancelAnimationFrame(resizeFrame);
} }
resizeObserver.disconnect(); resizeObserver.disconnect();
scadaAbortController.abort();
map.remove(); map.remove();
mapRef.current = null; mapRef.current = null;
delete window.__waterNetworkMap; delete window.__waterNetworkMap;
}; };
}, [containerRef]); }, [containerRef, getAccessToken]);
useMapInteractions({ useMapInteractions({
mapRef, mapRef,
@@ -244,6 +301,7 @@ export function useWorkbenchMap({
mapReady, mapReady,
mapError, mapError,
sourceStatuses: Object.values(sourceStatuses), sourceStatuses: Object.values(sourceStatuses),
getScadaFeatures,
fitNetworkBounds: fitToNetworkBounds fitNetworkBounds: fitToNetworkBounds
}; };
} }
@@ -338,6 +396,12 @@ function getSourceStatusMessage(sourceGroupId: string, status: WorkbenchSourceSt
: "业务标注源加载中断,模拟标注可能暂不可见。"; : "业务标注源加载中断,模拟标注可能暂不可见。";
} }
if (sourceGroupId === "scada-api") {
return status === "online"
? "SCADA设备已通过API加载。"
: "SCADA API请求中断,管网图层仍可继续使用。";
}
return status === "online" ? "地图数据源已恢复正常。" : "地图数据源请求中断。"; return status === "online" ? "地图数据源已恢复正常。" : "地图数据源请求中断。";
} }
@@ -354,5 +418,9 @@ function getSourceErrorMessage(sourceGroupId: string, errorMessage: string) {
return `业务标注源请求失败:${errorMessage}`; return `业务标注源请求失败:${errorMessage}`;
} }
if (sourceGroupId === "scada-api") {
return `SCADA API请求失败:${errorMessage}`;
}
return errorMessage; return errorMessage;
} }
@@ -5,7 +5,8 @@ import {
getAgentPanelMaxWidth, getAgentPanelMaxWidth,
getWorkbenchBasemapTone, getWorkbenchBasemapTone,
getWorkbenchCameraPadding, getWorkbenchCameraPadding,
getWorkbenchViewportLayout getWorkbenchViewportLayout,
resolveMobileWorkbenchOverlayLayout
} from "./workbench-layout"; } from "./workbench-layout";
describe("workbench basemap surface tone", () => { describe("workbench basemap surface tone", () => {
@@ -20,6 +21,19 @@ describe("workbench basemap surface tone", () => {
}); });
describe("workbench floating panels", () => { describe("workbench floating panels", () => {
it("derives mobile dock and map control offsets from one overlay stack", () => {
expect(resolveMobileWorkbenchOverlayLayout(true)).toEqual({
timelineBottom: 12,
dockBottom: 68,
mapControlBottom: 120
});
expect(resolveMobileWorkbenchOverlayLayout(false)).toEqual({
timelineBottom: 12,
dockBottom: 12,
mapControlBottom: 64
});
});
it("uses bounded desktop and wide panel widths", () => { it("uses bounded desktop and wide panel widths", () => {
expect(getWorkbenchViewportLayout(1440)).toMatchObject({ expect(getWorkbenchViewportLayout(1440)).toMatchObject({
agentWidth: 500, agentWidth: 500,
@@ -26,9 +26,29 @@ export const WORKBENCH_LAYOUT = {
mobileSheet: { mobileSheet: {
halfViewportRatio: 0.52, halfViewportRatio: 0.52,
fullTopGap: 68 fullTopGap: 68
},
mobileOverlay: {
edgeInset: 12,
gap: 8,
dockHeight: 44,
timelineSummaryHeight: 48
} }
} as const; } as const;
export function resolveMobileWorkbenchOverlayLayout(timelineVisible: boolean) {
const { edgeInset, gap, dockHeight, timelineSummaryHeight } =
WORKBENCH_LAYOUT.mobileOverlay;
const dockBottom = timelineVisible
? edgeInset + timelineSummaryHeight + gap
: edgeInset;
return {
timelineBottom: edgeInset,
dockBottom,
mapControlBottom: dockBottom + dockHeight + gap
};
}
export function clampAgentPanelWidth( export function clampAgentPanelWidth(
width: number, width: number,
viewportWidth: number, viewportWidth: number,
+39 -14
View File
@@ -78,7 +78,8 @@ import {
import { import {
WORKBENCH_LAYOUT, WORKBENCH_LAYOUT,
getWorkbenchBasemapTone, getWorkbenchBasemapTone,
getWorkbenchLayoutCssVariables getWorkbenchLayoutCssVariables,
resolveMobileWorkbenchOverlayLayout
} from "./layout/workbench-layout"; } from "./layout/workbench-layout";
import { getResponsiveWorkbenchPadding } from "./map/camera"; import { getResponsiveWorkbenchPadding } from "./map/camera";
import { exportMapViewImage } from "./map/export-view"; import { exportMapViewImage } from "./map/export-view";
@@ -220,9 +221,10 @@ export function MapWorkbenchPage({
timelineMode, timelineMode,
timelineVisible timelineVisible
); );
const mobileOverlayLayout = resolveMobileWorkbenchOverlayLayout(timelineVisible);
const timelineBottom = isLargeScreen const timelineBottom = isLargeScreen
? timelineScaleLayout.timelineBottom ? timelineScaleLayout.timelineBottom
: 12; : mobileOverlayLayout.timelineBottom;
useEffect(() => { useEffect(() => {
setTimelineMode((current) => { setTimelineMode((current) => {
@@ -257,11 +259,12 @@ export function MapWorkbenchPage({
}); });
} }
const { mapRef, mapReady, mapError, sourceStatuses, fitNetworkBounds } = useWorkbenchMap({ const { mapRef, mapReady, mapError, sourceStatuses, fitNetworkBounds, getScadaFeatures } = useWorkbenchMap({
containerRef: mapContainerRef, containerRef: mapContainerRef,
impactVisible, impactVisible,
onSelectFeature: handleSelectFeature, onSelectFeature: handleSelectFeature,
selectedFeature: detailFeature selectedFeature: detailFeature,
getAccessToken
}); });
const { controller: mapController, state: mapControllerState } = useWorkbenchMapController({ const { controller: mapController, state: mapControllerState } = useWorkbenchMapController({
mapRef, mapRef,
@@ -269,7 +272,8 @@ export function MapWorkbenchPage({
leftPanelOpen: false, leftPanelOpen: false,
rightPanelOpen, rightPanelOpen,
conditionPanelExpanded: rightPanelExpanded, conditionPanelExpanded: rightPanelExpanded,
agentPanelWidth agentPanelWidth,
getScadaFeatures
}); });
const activeAgentUiResults = useMemo( const activeAgentUiResults = useMemo(
@@ -987,19 +991,30 @@ export function MapWorkbenchPage({
onReturnNow={handleReturnOperationalNow} onReturnNow={handleReturnOperationalNow}
/> />
</div> </div>
<div
role="region"
aria-label="移动端运行时间轴摘要"
className="acrylic-control flex h-12 w-full items-center rounded-2xl border p-1 text-xs text-slate-700 lg:hidden"
>
<button <button
type="button" type="button"
aria-label="打开完整运行时间轴"
onClick={openTimelineForViewport} onClick={openTimelineForViewport}
className="acrylic-control flex h-12 w-full items-center gap-2 rounded-2xl border px-3 text-left text-xs text-slate-700 lg:hidden" className="flex h-10 min-w-0 flex-1 items-center gap-1.5 rounded-xl px-2 text-left transition-colors hover:bg-white/55 active:bg-white/75"
> >
<Clock3 size={15} className="text-blue-600" aria-hidden="true" /> <Clock3 size={15} className="shrink-0 text-blue-600" aria-hidden="true" />
<span className="font-semibold"></span> <span className="shrink-0 font-semibold"></span>
<span className="flex min-w-0 items-center gap-1.5 whitespace-nowrap text-[10px]">
<span className="text-slate-500"> {operationalSummary.planned}</span> <span className="text-slate-500"> {operationalSummary.planned}</span>
<span className="text-slate-500"> {operationalSummary.active}</span> <span className="text-slate-500"> {operationalSummary.active}</span>
<span className="text-amber-700"> {operationalSummary.attention}</span> <span className="text-amber-700"> {operationalSummary.attention}</span>
<span className="text-rose-700"> {operationalSummary.exceptions}</span> <span className="text-rose-700"> {operationalSummary.exceptions}</span>
<span className="ml-auto text-slate-500"></span> </span>
<span className="ml-auto hidden shrink-0 text-slate-500 min-[360px]:inline">
</span>
</button> </button>
</div>
</> : null </> : null
)} )}
mapContent={( mapContent={(
@@ -1019,10 +1034,13 @@ export function MapWorkbenchPage({
<ToolbarPanel {...toolbarPanelProps} /> <ToolbarPanel {...toolbarPanelProps} />
</div> </div>
<div <div
className="absolute bottom-0 right-0 flex flex-col items-end gap-2" className="absolute right-3 flex flex-col items-end gap-2 bottom-[var(--mobile-map-control-bottom)] transition-[bottom] duration-200 ease-out motion-reduce:transition-none lg:right-0 lg:bottom-[var(--desktop-map-scale-bottom)]"
style={{ bottom: timelineScaleLayout.scaleBottom }} style={{
"--mobile-map-control-bottom": `${mobileOverlayLayout.mapControlBottom}px`,
"--desktop-map-scale-bottom": `${timelineScaleLayout.scaleBottom}px`
} as CSSProperties}
> >
<div className="flex items-end gap-2 pr-2"> <div className="flex items-end gap-2 lg:pr-2">
<MapZoom mapRef={mapRef} mapReady={mapReady} onHome={fitNetworkBounds} /> <MapZoom mapRef={mapRef} mapReady={mapReady} onHome={fitNetworkBounds} />
</div> </div>
<MapScaleLine <MapScaleLine
@@ -1077,7 +1095,10 @@ export function MapWorkbenchPage({
) : null} ) : null}
{mobileSheet === null && artifactWorkspace.surfaceMode !== "focus" ? ( {mobileSheet === null && artifactWorkspace.surfaceMode !== "focus" ? (
<div className="absolute bottom-16 left-3 right-3 z-30 lg:hidden"> <div
className="absolute left-3 right-3 z-30 transition-[bottom] duration-200 ease-out motion-reduce:transition-none lg:hidden"
style={{ bottom: mobileOverlayLayout.mapControlBottom }}
>
<ToolbarPanel <ToolbarPanel
{...toolbarPanelProps} {...toolbarPanelProps}
panelWidthClassName="w-full max-h-[52dvh] overflow-y-auto" panelWidthClassName="w-full max-h-[52dvh] overflow-y-auto"
@@ -1108,7 +1129,10 @@ export function MapWorkbenchPage({
) : null} ) : null}
</AnimatePresence> </AnimatePresence>
<div className={`absolute z-30 lg:hidden ${timelineVisible ? "bottom-[4.25rem]" : "bottom-3"} ${artifactWorkspace.surfaceMode === "focus" ? "right-3" : "left-3 right-3"}`}> <div
className={`absolute z-30 transition-[bottom] duration-200 ease-out motion-reduce:transition-none lg:hidden ${artifactWorkspace.surfaceMode === "focus" ? "right-3" : "left-3 right-3"}`}
style={{ bottom: mobileOverlayLayout.dockBottom }}
>
{mobileSheet === null ? ( {mobileSheet === null ? (
<div className="flex items-center justify-center gap-2"> <div className="flex items-center justify-center gap-2">
<button <button
@@ -1167,6 +1191,7 @@ export function MapWorkbenchPage({
events={operationalEvents} events={operationalEvents}
mode="expanded" mode="expanded"
selectedEventId={workspaceContext.operational.selectedEventId} selectedEventId={workspaceContext.operational.selectedEventId}
onModeChange={closeMobileSheet}
onSelectEvent={handleSelectOperationalEvent} onSelectEvent={handleSelectOperationalEvent}
onClearSelection={handleClearOperationalSelection} onClearSelection={handleClearOperationalSelection}
onReturnNow={handleReturnOperationalNow} onReturnNow={handleReturnOperationalNow}
+3 -1
View File
@@ -51,7 +51,9 @@ export async function exportMapViewImage(map: MapLibreMap, { scale, targetLongEd
}); });
if (selectedFeature) { if (selectedFeature) {
exportMap.setFeatureState( exportMap.setFeatureState(
{ source: selectedFeature.source, sourceLayer: selectedFeature.sourceLayer, id: selectedFeature.id }, selectedFeature.sourceLayer
? { source: selectedFeature.source, sourceLayer: selectedFeature.sourceLayer, id: selectedFeature.id }
: { source: selectedFeature.source, id: selectedFeature.id },
{ selected: true, hovered: false } { selected: true, hovered: false }
); );
} }
+26 -7
View File
@@ -1,15 +1,19 @@
import type { MapGeoJSONFeature } from "maplibre-gl"; import type { MapGeoJSONFeature } from "maplibre-gl";
import type { DetailFeature } from "../types"; import type { DetailFeature } from "../types";
import { formatValue } from "../utils/format-value"; import { formatValue } from "../utils/format-value";
import { SOURCE_LAYERS, type WaterNetworkSourceId } from "./sources"; import {
SOURCE_LAYERS,
type GeoServerWaterNetworkSourceId,
type WaterNetworkSourceId
} from "./sources";
export function getFeatureId(feature: MapGeoJSONFeature) { export function getFeatureId(feature: MapGeoJSONFeature) {
const raw = feature.properties?.id ?? feature.id; const raw = feature.properties?.device_id ?? feature.properties?.id ?? feature.id;
return raw === undefined || raw === null ? "" : String(raw); return raw === undefined || raw === null ? "" : String(raw);
} }
export function getWaterNetworkSourceId(feature: MapGeoJSONFeature): WaterNetworkSourceId | null { export function getWaterNetworkSourceId(feature: MapGeoJSONFeature): WaterNetworkSourceId | null {
for (const [sourceId, sourceLayer] of Object.entries(SOURCE_LAYERS) as [WaterNetworkSourceId, string][]) { for (const [sourceId, sourceLayer] of Object.entries(SOURCE_LAYERS) as [GeoServerWaterNetworkSourceId, string][]) {
if (feature.source === sourceId || feature.sourceLayer === sourceLayer) { if (feature.source === sourceId || feature.sourceLayer === sourceLayer) {
return sourceId; return sourceId;
} }
@@ -43,26 +47,41 @@ function getFeatureLabels(sourceId: WaterNetworkSourceId, id: string, properties
if (sourceId === "valves") { if (sourceId === "valves") {
return { return {
title: `阀门 ${id || "未命名"}`, title: `阀门 ${id || "未命名"}`,
subtitle: `DN${formatValue(properties.diameter)} · ${formatValue(properties.v_type)}` subtitle: `DN${formatValue(properties.diameter)} · ${formatValue(properties.valve_type)}`
}; };
} }
if (sourceId === "reservoirs") { if (sourceId === "reservoirs") {
return { return {
title: `水库 ${id || "未命名"}`, title: `水库 ${id || "未命名"}`,
subtitle: `水头 ${formatValue(properties.head)} · 模式 ${formatValue(properties.pattern)}` subtitle: `水头 ${formatValue(properties.head)} · 模式 ${formatValue(properties.pattern_id)}`
}; };
} }
if (sourceId === "scada") { if (sourceId === "scada") {
const associatedElementId = properties.node_id ?? properties.link_id;
return { return {
title: `SCADA ${id || "未命名"}`, title: `SCADA ${id || "未命名"}`,
subtitle: `${formatValue(properties.type)} · 关联 ${formatValue(properties.associated_element_id)}` subtitle: `${formatValue(properties.device_type)} · 关联 ${formatValue(associatedElementId)}`
};
}
if (sourceId === "pumps") {
return {
title: `水泵 ${id || "未命名"}`,
subtitle: `${formatValue(properties.start_node_id)}${formatValue(properties.end_node_id)}`
};
}
if (sourceId === "tanks") {
return {
title: `水箱 ${id || "未命名"}`,
subtitle: `初始水位 ${formatValue(properties.initial_level)} · 高程 ${formatValue(properties.elevation)} m`
}; };
} }
return { return {
title: `节点 ${id || "未命名"}`, title: `节点 ${id || "未命名"}`,
subtitle: `需水量 ${formatValue(properties.demand)} · 高程 ${formatValue(properties.elevation)} m` subtitle: `基础需水量 ${formatValue(properties.base_demand)} · 高程 ${formatValue(properties.elevation)} m`
}; };
} }
@@ -27,12 +27,12 @@ describe("flow overlay", () => {
expect(layers.get(FLOW_LINE_LAYER_ID)).toMatchObject({ expect(layers.get(FLOW_LINE_LAYER_ID)).toMatchObject({
source: "pipes", source: "pipes",
"source-layer": "geo_pipes_mat", "source-layer": "pipes",
paint: { "line-pattern": "workbench-network-flow-pattern-forward" } paint: { "line-pattern": "workbench-network-flow-pattern-forward" }
}); });
expect(layers.get(FLOW_REVERSE_LINE_LAYER_ID)).toMatchObject({ expect(layers.get(FLOW_REVERSE_LINE_LAYER_ID)).toMatchObject({
source: "pipes", source: "pipes",
"source-layer": "geo_pipes_mat", "source-layer": "pipes",
paint: { "line-pattern": "workbench-network-flow-pattern-reverse" } paint: { "line-pattern": "workbench-network-flow-pattern-reverse" }
}); });
expect([...layers.keys()].some((id) => id.includes("arrows"))).toBe(false); expect([...layers.keys()].some((id) => id.includes("arrows"))).toBe(false);
@@ -56,7 +56,7 @@ describe("flow overlay", () => {
expect(map.setFeatureState).toHaveBeenNthCalledWith(1, { expect(map.setFeatureState).toHaveBeenNthCalledWith(1, {
source: "pipes", source: "pipes",
sourceLayer: "geo_pipes_mat", sourceLayer: "pipes",
id: "P-1" id: "P-1"
}, { }, {
flowDirection: -1 flowDirection: -1
+9 -1
View File
@@ -34,6 +34,14 @@ describe("supply network layer styling", () => {
minzoom: 8.5, minzoom: 8.5,
layout: { "icon-image": SUPPLY_ASSET_IMAGE_IDS.reservoir } layout: { "icon-image": SUPPLY_ASSET_IMAGE_IDS.reservoir }
}); });
expect(getLayer("pumps-symbol")).toMatchObject({
minzoom: 11,
layout: { "icon-image": SUPPLY_ASSET_IMAGE_IDS.pump }
});
expect(getLayer("tanks-symbol")).toMatchObject({
minzoom: 11,
layout: { "icon-image": SUPPLY_ASSET_IMAGE_IDS.tank }
});
expect(getLayer("junctions")).toMatchObject({ minzoom: 15 }); expect(getLayer("junctions")).toMatchObject({ minzoom: 15 });
const valvePaint = getLayer("valves")?.paint as Record<string, unknown> | undefined; const valvePaint = getLayer("valves")?.paint as Record<string, unknown> | undefined;
@@ -47,7 +55,7 @@ describe("supply network layer styling", () => {
const ids = waterNetworkLayers.map((layer) => layer.id); const ids = waterNetworkLayers.map((layer) => layer.id);
expect(ids.indexOf("pipes-flow")).toBeLessThan(ids.indexOf("pipes-hover")); expect(ids.indexOf("pipes-flow")).toBeLessThan(ids.indexOf("pipes-hover"));
expect(ids.indexOf("pipes-hover")).toBeLessThan(ids.indexOf("pipes-hit")); expect(ids.indexOf("pipes-hover")).toBeLessThan(ids.indexOf("pipes-hit"));
expect(ids.at(-1)).toBe("reservoirs-hit"); expect(ids.at(-1)).toBe("tanks-hit");
}); });
}); });
+61 -1
View File
@@ -22,6 +22,8 @@ export const INTERACTIVE_HIT_LAYER_IDS = [
"junctions-hit", "junctions-hit",
"valves-hit", "valves-hit",
"reservoirs-hit", "reservoirs-hit",
"pumps-hit",
"tanks-hit",
SCADA_HIT_LAYER_ID SCADA_HIT_LAYER_ID
] as const; ] as const;
@@ -208,6 +210,38 @@ layers.push(
paint: { paint: {
"icon-opacity": supplyIconOpacityExpression "icon-opacity": supplyIconOpacityExpression
} }
},
{
id: "pumps-symbol",
type: "symbol",
source: "pumps",
"source-layer": SOURCE_LAYERS.pumps,
minzoom: SUPPLY_LAYER_VISUALS.pumps.minzoom,
layout: {
"icon-image": SUPPLY_ASSET_IMAGE_IDS.pump,
"icon-size": SUPPLY_LAYER_VISUALS.pumps.iconSize,
"icon-allow-overlap": true,
"icon-ignore-placement": true
},
paint: {
"icon-opacity": supplyIconOpacityExpression
}
},
{
id: "tanks-symbol",
type: "symbol",
source: "tanks",
"source-layer": SOURCE_LAYERS.tanks,
minzoom: SUPPLY_LAYER_VISUALS.tanks.minzoom,
layout: {
"icon-image": SUPPLY_ASSET_IMAGE_IDS.tank,
"icon-size": SUPPLY_LAYER_VISUALS.tanks.iconSize,
"icon-allow-overlap": true,
"icon-ignore-placement": true
},
paint: {
"icon-opacity": supplyIconOpacityExpression
}
} }
); );
@@ -295,7 +329,9 @@ lineInteractions.forEach(([prefix, source, sourceLayer, visual, color, dash]) =>
const pointInteractions = [ const pointInteractions = [
["junctions", "junctions", SOURCE_LAYERS.junctions, SUPPLY_LAYER_VISUALS.junctions, MAP_STYLE_TOKENS.supply.junction], ["junctions", "junctions", SOURCE_LAYERS.junctions, SUPPLY_LAYER_VISUALS.junctions, MAP_STYLE_TOKENS.supply.junction],
["valves", "valves", SOURCE_LAYERS.valves, SUPPLY_LAYER_VISUALS.valves, MAP_STYLE_TOKENS.supply.valve], ["valves", "valves", SOURCE_LAYERS.valves, SUPPLY_LAYER_VISUALS.valves, MAP_STYLE_TOKENS.supply.valve],
["reservoirs", "reservoirs", SOURCE_LAYERS.reservoirs, SUPPLY_LAYER_VISUALS.reservoirs, MAP_STYLE_TOKENS.supply.reservoir] ["reservoirs", "reservoirs", SOURCE_LAYERS.reservoirs, SUPPLY_LAYER_VISUALS.reservoirs, MAP_STYLE_TOKENS.supply.reservoir],
["pumps", "pumps", SOURCE_LAYERS.pumps, SUPPLY_LAYER_VISUALS.pumps, MAP_STYLE_TOKENS.supply.pump],
["tanks", "tanks", SOURCE_LAYERS.tanks, SUPPLY_LAYER_VISUALS.tanks, MAP_STYLE_TOKENS.supply.tank]
] as const; ] as const;
pointInteractions.forEach(([prefix, source, sourceLayer, visual, color]) => { pointInteractions.forEach(([prefix, source, sourceLayer, visual, color]) => {
@@ -403,6 +439,30 @@ layers.push(
"circle-radius": SUPPLY_LAYER_VISUALS.reservoirs.hitRadius, "circle-radius": SUPPLY_LAYER_VISUALS.reservoirs.hitRadius,
"circle-opacity": 0 "circle-opacity": 0
} }
},
{
id: "pumps-hit",
type: "circle",
source: "pumps",
"source-layer": SOURCE_LAYERS.pumps,
minzoom: SUPPLY_LAYER_VISUALS.pumps.minzoom,
paint: {
"circle-color": MAP_STYLE_TOKENS.canvas.transparent,
"circle-radius": SUPPLY_LAYER_VISUALS.pumps.hitRadius,
"circle-opacity": 0
}
},
{
id: "tanks-hit",
type: "circle",
source: "tanks",
"source-layer": SOURCE_LAYERS.tanks,
minzoom: SUPPLY_LAYER_VISUALS.tanks.minzoom,
paint: {
"circle-color": MAP_STYLE_TOKENS.canvas.transparent,
"circle-radius": SUPPLY_LAYER_VISUALS.tanks.hitRadius,
"circle-opacity": 0
}
} }
); );
+2
View File
@@ -14,6 +14,8 @@ export const MAP_STYLE_TOKENS = {
junction: "#47787B", junction: "#47787B",
valve: "#6B7280", valve: "#6B7280",
reservoir: "#195F84", reservoir: "#195F84",
pump: "#326B87",
tank: "#4F6F7A",
scadaFlow: "#007A9E", scadaFlow: "#007A9E",
scadaPressure: "#9A6700" scadaPressure: "#9A6700"
}, },
@@ -19,16 +19,18 @@ describe("workbench supply layer controls", () => {
"valves", "valves",
"reservoirs", "reservoirs",
"scada", "scada",
"pumps",
"tanks",
"simulation" "simulation"
]); ]);
expect(items.map((item) => item.id)).not.toContain("pumps"); expect(items.slice(0, 7).map((item) => item.description)).toEqual([
expect(items.map((item) => item.id)).not.toContain("tanks"); "tjwater_next:pipes",
expect(items.slice(0, 5).map((item) => item.description)).toEqual([ "tjwater_next:junctions",
"tjwater:geo_pipes_mat", "tjwater_next:valves",
"tjwater:geo_junctions_mat", "tjwater_next:reservoirs",
"tjwater:geo_valves", "后端业务 API",
"tjwater:geo_reservoirs", "tjwater_next:pumps",
"tjwater:geo_scada" "tjwater_next:tanks"
]); ]);
}); });
@@ -41,6 +43,8 @@ describe("workbench supply layer controls", () => {
"junction", "junction",
"valve", "valve",
"reservoir", "reservoir",
"pump",
"tank",
"scada-flow", "scada-flow",
"scada-pressure", "scada-pressure",
"impact-area" "impact-area"
@@ -50,6 +54,8 @@ describe("workbench supply layer controls", () => {
label: "水库", label: "水库",
imageSrc: "/map/reservoir.png" imageSrc: "/map/reservoir.png"
}); });
expect(MAP_LEGEND_ITEMS.find((item) => item.id === "pump")).toMatchObject({ imageSrc: "/map/pump.png" });
expect(MAP_LEGEND_ITEMS.find((item) => item.id === "tank")).toMatchObject({ imageSrc: "/map/tank.png" });
expect(MAP_LEGEND_ITEMS.find((item) => item.id === "scada-flow")).toMatchObject({ imageSrc: "/map/scada-flow.png" }); expect(MAP_LEGEND_ITEMS.find((item) => item.id === "scada-flow")).toMatchObject({ imageSrc: "/map/scada-flow.png" });
expect(MAP_LEGEND_ITEMS.find((item) => item.id === "scada-pressure")).toMatchObject({ imageSrc: "/map/scada-pressure.png" }); expect(MAP_LEGEND_ITEMS.find((item) => item.id === "scada-pressure")).toMatchObject({ imageSrc: "/map/scada-pressure.png" });
expect(MAP_LEGEND_ITEMS.find((item) => item.id === "inactive-pipe")).toMatchObject({ expect(MAP_LEGEND_ITEMS.find((item) => item.id === "inactive-pipe")).toMatchObject({
@@ -82,5 +88,12 @@ describe("workbench supply layer controls", () => {
"reservoirs-selected", "reservoirs-selected",
"reservoirs-hit" "reservoirs-hit"
]); ]);
expect(getWorkbenchLayerIds(map, "pumps")).toEqual([
"pumps-symbol",
"pumps-hover",
"pumps-selected-outer",
"pumps-selected",
"pumps-hit"
]);
}); });
}); });
@@ -35,7 +35,9 @@ const SOURCE_CONTROL_LABELS = Object.fromEntries(
layer.id, layer.id,
{ {
label: layer.label, label: layer.label,
description: `${GEOSERVER_WORKSPACE}:${layer.sourceLayer}` description: layer.id === "scada"
? "后端业务 API"
: `${GEOSERVER_WORKSPACE}:${layer.sourceLayer}`
} }
]) ])
) as Record<WaterNetworkSourceId, { label: string; description: string }>; ) as Record<WaterNetworkSourceId, { label: string; description: string }>;
@@ -48,6 +50,8 @@ export const MAP_LEGEND_ITEMS: MapLegendItem[] = [
{ id: "junction", label: "节点", color: MAP_STYLE_TOKENS.supply.junction, shape: "dot" }, { id: "junction", label: "节点", color: MAP_STYLE_TOKENS.supply.junction, shape: "dot" },
{ id: "valve", label: "阀门", color: MAP_STYLE_TOKENS.supply.valve, imageSrc: SUPPLY_ASSET_ICON_PATHS.valve }, { id: "valve", label: "阀门", color: MAP_STYLE_TOKENS.supply.valve, imageSrc: SUPPLY_ASSET_ICON_PATHS.valve },
{ id: "reservoir", label: "水库", color: MAP_STYLE_TOKENS.supply.reservoir, imageSrc: SUPPLY_ASSET_ICON_PATHS.reservoir }, { id: "reservoir", label: "水库", color: MAP_STYLE_TOKENS.supply.reservoir, imageSrc: SUPPLY_ASSET_ICON_PATHS.reservoir },
{ id: "pump", label: "水泵", color: MAP_STYLE_TOKENS.supply.pump, imageSrc: SUPPLY_ASSET_ICON_PATHS.pump },
{ id: "tank", label: "水箱", color: MAP_STYLE_TOKENS.supply.tank, imageSrc: SUPPLY_ASSET_ICON_PATHS.tank },
{ id: "scada-flow", label: "SCADA 流量", color: MAP_STYLE_TOKENS.supply.scadaFlow, imageSrc: SCADA_ICON_PATHS.flow }, { id: "scada-flow", label: "SCADA 流量", color: MAP_STYLE_TOKENS.supply.scadaFlow, imageSrc: SCADA_ICON_PATHS.flow },
{ id: "scada-pressure", label: "SCADA 压力", color: MAP_STYLE_TOKENS.supply.scadaPressure, imageSrc: SCADA_ICON_PATHS.pressure }, { id: "scada-pressure", label: "SCADA 压力", color: MAP_STYLE_TOKENS.supply.scadaPressure, imageSrc: SCADA_ICON_PATHS.pressure },
{ id: "impact-area", label: "影响范围", color: MAP_STYLE_TOKENS.state.incident, shape: "square" } { id: "impact-area", label: "影响范围", color: MAP_STYLE_TOKENS.state.incident, shape: "square" }
@@ -15,7 +15,6 @@ describe("map feature WFS query", () => {
junctions: "id", junctions: "id",
valves: "id", valves: "id",
reservoirs: "id", reservoirs: "id",
scada: "id",
pumps: "id", pumps: "id",
tanks: "id" tanks: "id"
}); });
@@ -23,18 +22,21 @@ describe("map feature WFS query", () => {
.toBe("\"id\" IN ('J-1')"); .toBe("\"id\" IN ('J-1')");
}); });
it("escapes CQL literals and uses materialized WFS layers", () => { it("escapes CQL literals and uses the new materialized WFS layers", () => {
expect(escapeCqlLiteral("a'b")).toBe("'a''b'"); expect(escapeCqlLiteral("a'b")).toBe("'a''b'");
const url = createMapFeatureWfsUrl({ sourceId: "pipes", featureIds: ["a'b"] }); const url = createMapFeatureWfsUrl({ sourceId: "pipes", featureIds: ["a'b"] });
expect(url.searchParams.get("typeNames")).toContain("geo_pipes_mat"); expect(url.searchParams.get("typeNames")).toContain("tjwater_next:pipes");
expect(url.searchParams.get("srsName")).toBe("EPSG:4326"); expect(url.searchParams.get("srsName")).toBe("EPSG:4326");
expect(url.searchParams.get("cql_filter")).toBe("\"id\" IN ('a''b')"); expect(url.searchParams.get("cql_filter")).toBe("\"id\" IN ('a''b')");
}); });
it("uses the authoritative supply asset WFS layers", () => { it("uses WFS only for authoritative supply asset layers", () => {
expect(createMapFeatureWfsUrl({ sourceId: "valves", featureIds: ["V-1"] }).searchParams.get("typeNames")).toContain("geo_valves"); expect(createMapFeatureWfsUrl({ sourceId: "valves", featureIds: ["V-1"] }).searchParams.get("typeNames")).toContain("tjwater_next:valves");
expect(createMapFeatureWfsUrl({ sourceId: "reservoirs", featureIds: ["R-1"] }).searchParams.get("typeNames")).toContain("geo_reservoirs"); expect(createMapFeatureWfsUrl({ sourceId: "reservoirs", featureIds: ["R-1"] }).searchParams.get("typeNames")).toContain("tjwater_next:reservoirs");
expect(createMapFeatureWfsUrl({ sourceId: "scada", featureIds: ["S-1"] }).searchParams.get("typeNames")).toContain("geo_scada"); expect(parseMapFeatureQuery({ sourceId: "scada", featureIds: ["S-1"] })).toMatchObject({
ok: false,
code: "UNSUPPORTED_SOURCE"
});
}); });
it("limits ids and permits full-network requests only for pipes", () => { it("limits ids and permits full-network requests only for pipes", () => {
@@ -42,8 +44,8 @@ describe("map feature WFS query", () => {
expect(createMapFeatureWfsUrl({ sourceId: "pipes" }).searchParams.get("count")).toBe(String(MAX_PIPE_FEATURES)); expect(createMapFeatureWfsUrl({ sourceId: "pipes" }).searchParams.get("count")).toBe(String(MAX_PIPE_FEATURES));
expect(parseMapFeatureQuery({ sourceId: "junctions" })).toMatchObject({ ok: false, status: 400 }); expect(parseMapFeatureQuery({ sourceId: "junctions" })).toMatchObject({ ok: false, status: 400 });
expect(parseMapFeatureQuery({ sourceId: "unknown", featureIds: ["1"] })).toMatchObject({ ok: false, status: 422 }); expect(parseMapFeatureQuery({ sourceId: "unknown", featureIds: ["1"] })).toMatchObject({ ok: false, status: 422 });
expect(parseMapFeatureQuery({ sourceId: "pumps", featureIds: ["P-1"] })).toMatchObject({ ok: false, status: 422 }); expect(parseMapFeatureQuery({ sourceId: "pumps", featureIds: ["P-1"] })).toEqual({ ok: true, value: { sourceId: "pumps", featureIds: ["P-1"] } });
expect(parseMapFeatureQuery({ sourceId: "tanks", featureIds: ["T-1"] })).toMatchObject({ ok: false, status: 422 }); expect(parseMapFeatureQuery({ sourceId: "tanks", featureIds: ["T-1"] })).toEqual({ ok: true, value: { sourceId: "tanks", featureIds: ["T-1"] } });
expect(parseMapFeatureQuery({ sourceId: "pipes", featureIds: Array.from({ length: 101 }, (_, index) => String(index)) })) expect(parseMapFeatureQuery({ sourceId: "pipes", featureIds: Array.from({ length: 101 }, (_, index) => String(index)) }))
.toMatchObject({ ok: false, status: 400 }); .toMatchObject({ ok: false, status: 400 });
expect(parseMapFeatureQuery({ sourceId: "pipes", featureIds: ["x".repeat(129)] })) expect(parseMapFeatureQuery({ sourceId: "pipes", featureIds: ["x".repeat(129)] }))
@@ -2,16 +2,15 @@ import type { FeatureCollection } from "geojson";
import { GEOSERVER_WORKSPACE, MAP_URL } from "./geoserver-config"; import { GEOSERVER_WORKSPACE, MAP_URL } from "./geoserver-config";
import { import {
SOURCE_LAYERS, SOURCE_LAYERS,
isAvailableWaterNetworkSourceId, GEOSERVER_WATER_NETWORK_SOURCE_IDS,
type WaterNetworkSourceId type GeoServerWaterNetworkSourceId
} from "./sources"; } from "./sources";
export const MAP_FEATURE_ID_FIELDS: Record<WaterNetworkSourceId, "id"> = { export const MAP_FEATURE_ID_FIELDS: Record<GeoServerWaterNetworkSourceId, "id"> = {
pipes: "id", pipes: "id",
junctions: "id", junctions: "id",
valves: "id", valves: "id",
reservoirs: "id", reservoirs: "id",
scada: "id",
pumps: "id", pumps: "id",
tanks: "id" tanks: "id"
}; };
@@ -21,7 +20,7 @@ export const MAX_MAP_FEATURE_ID_LENGTH = 128;
export const MAX_PIPE_FEATURES = 5000; export const MAX_PIPE_FEATURES = 5000;
export type MapFeatureQuery = { export type MapFeatureQuery = {
sourceId: WaterNetworkSourceId; sourceId: GeoServerWaterNetworkSourceId;
featureIds?: string[]; featureIds?: string[];
}; };
@@ -35,7 +34,10 @@ export function parseMapFeatureQuery(body: unknown): MapFeatureQueryValidation {
} }
const { sourceId, featureIds } = body as { sourceId?: unknown; featureIds?: unknown }; const { sourceId, featureIds } = body as { sourceId?: unknown; featureIds?: unknown };
if (typeof sourceId !== "string" || !(sourceId in SOURCE_LAYERS) || !isAvailableWaterNetworkSourceId(sourceId)) { if (
typeof sourceId !== "string" ||
!(GEOSERVER_WATER_NETWORK_SOURCE_IDS as readonly string[]).includes(sourceId)
) {
return { ok: false, code: "UNSUPPORTED_SOURCE", status: 422 }; return { ok: false, code: "UNSUPPORTED_SOURCE", status: 422 };
} }
@@ -58,7 +60,7 @@ export function parseMapFeatureQuery(body: unknown): MapFeatureQueryValidation {
return { return {
ok: true, ok: true,
value: { sourceId: sourceId as WaterNetworkSourceId, featureIds: featureIds.map((id) => id.trim()) } value: { sourceId: sourceId as GeoServerWaterNetworkSourceId, featureIds: featureIds.map((id) => id.trim()) }
}; };
} }
@@ -56,19 +56,19 @@ describe("SCADA analysis overlay", () => {
}); });
it("validates one to one hundred unique IDs and fixed levels", () => { it("validates one to one hundred unique IDs and fixed levels", () => {
expect(parseScadaAnalysisItems([{ sensor_id: " MP01 ", level: "high" }])).toEqual([ expect(parseScadaAnalysisItems([{ device_id: " MP01 ", level: "high" }])).toEqual([
{ sensor_id: "MP01", level: "high" } { device_id: "MP01", level: "high" }
]); ]);
expect(parseScadaAnalysisItems([])).toBeNull(); expect(parseScadaAnalysisItems([])).toBeNull();
expect(parseScadaAnalysisItems([{ sensor_id: "MP01", level: "high" }, { sensor_id: "MP01", level: "low" }])).toBeNull(); expect(parseScadaAnalysisItems([{ device_id: "MP01", level: "high" }, { device_id: "MP01", level: "low" }])).toBeNull();
expect(parseScadaAnalysisItems([{ sensor_id: "MP01", level: "critical" }])).toBeNull(); expect(parseScadaAnalysisItems([{ device_id: "MP01", level: "critical" }])).toBeNull();
}); });
it("uses trusted WFS sensor IDs for localized labels and reports partial misses", () => { it("uses trusted API device IDs for localized labels and reports partial misses", () => {
const result = createScadaAnalysisCollection(scadaCollection, [ const result = createScadaAnalysisCollection(scadaCollection, [
{ sensor_id: "MP01", level: "high" }, { device_id: "MP01", level: "high" },
{ sensor_id: "MP02", level: "medium" }, { device_id: "MP02", level: "medium" },
{ sensor_id: "MP404", level: "low" } { device_id: "MP404", level: "low" }
]); ]);
expect(result.result).toEqual({ expect(result.result).toEqual({
rendered_ids: ["MP01", "MP02"], rendered_ids: ["MP01", "MP02"],
@@ -76,8 +76,8 @@ describe("SCADA analysis overlay", () => {
level_counts: { high: 1, medium: 1, low: 0, unrated: 0 } level_counts: { high: 1, medium: 1, low: 0, unrated: 0 }
}); });
expect(result.collection.features.map((feature) => feature.properties)).toEqual([ expect(result.collection.features.map((feature) => feature.properties)).toEqual([
expect.objectContaining({ sensor_id: "MP01", analysis_label: "MP01 · 高", sort_priority: 0 }), expect.objectContaining({ device_id: "MP01", analysis_label: "MP01 · 高", sort_priority: 0 }),
expect.objectContaining({ sensor_id: "MP02", analysis_label: "MP02 · 中", sort_priority: 1 }) expect.objectContaining({ device_id: "MP02", analysis_label: "MP02 · 中", sort_priority: 1 })
]); ]);
}); });
}); });
@@ -85,7 +85,7 @@ describe("SCADA analysis overlay", () => {
const scadaCollection: FeatureCollection = { const scadaCollection: FeatureCollection = {
type: "FeatureCollection", type: "FeatureCollection",
features: [ features: [
{ type: "Feature", geometry: { type: "Point", coordinates: [120.7, 28] }, properties: { sensor_id: "MP01" } }, { type: "Feature", geometry: { type: "Point", coordinates: [120.7, 28] }, properties: { device_id: "MP01" } },
{ type: "Feature", geometry: { type: "Point", coordinates: [120.72, 28.02] }, properties: { sensor_id: "MP02" } } { type: "Feature", geometry: { type: "Point", coordinates: [120.72, 28.02] }, properties: { device_id: "MP02" } }
] ]
}; };
+22 -22
View File
@@ -12,7 +12,7 @@ export const SCADA_ANALYSIS_LAYER_IDS = {
export const SCADA_ANALYSIS_LEVELS = ["high", "medium", "low", "unrated"] as const; export const SCADA_ANALYSIS_LEVELS = ["high", "medium", "low", "unrated"] as const;
export type ScadaAnalysisLevel = (typeof SCADA_ANALYSIS_LEVELS)[number]; export type ScadaAnalysisLevel = (typeof SCADA_ANALYSIS_LEVELS)[number];
export type ScadaAnalysisItem = { sensor_id: string; level: ScadaAnalysisLevel }; export type ScadaAnalysisItem = { device_id: string; level: ScadaAnalysisLevel };
export type ScadaAnalysisLevelCounts = Record<ScadaAnalysisLevel, number>; export type ScadaAnalysisLevelCounts = Record<ScadaAnalysisLevel, number>;
export type ScadaAnalysisRenderResult = { export type ScadaAnalysisRenderResult = {
rendered_ids: string[]; rendered_ids: string[];
@@ -145,17 +145,17 @@ export function parseScadaAnalysisItems(value: unknown): ScadaAnalysisItem[] | n
for (const entry of value) { for (const entry of value) {
if (!entry || typeof entry !== "object" || Array.isArray(entry)) return null; if (!entry || typeof entry !== "object" || Array.isArray(entry)) return null;
const keys = Object.keys(entry); const keys = Object.keys(entry);
if (keys.length !== 2 || !keys.includes("sensor_id") || !keys.includes("level")) return null; if (keys.length !== 2 || !keys.includes("device_id") || !keys.includes("level")) return null;
const { sensor_id: rawSensorId, level } = entry as Record<string, unknown>; const { device_id: rawDeviceId, level } = entry as Record<string, unknown>;
const sensorId = typeof rawSensorId === "string" ? rawSensorId.trim() : ""; const deviceId = typeof rawDeviceId === "string" ? rawDeviceId.trim() : "";
if ( if (
!sensorId || !deviceId ||
sensorId.length > 128 || deviceId.length > 128 ||
!SCADA_ANALYSIS_LEVELS.includes(level as ScadaAnalysisLevel) || !SCADA_ANALYSIS_LEVELS.includes(level as ScadaAnalysisLevel) ||
seen.has(sensorId) seen.has(deviceId)
) return null; ) return null;
seen.add(sensorId); seen.add(deviceId);
items.push({ sensor_id: sensorId, level: level as ScadaAnalysisLevel }); items.push({ device_id: deviceId, level: level as ScadaAnalysisLevel });
} }
return items; return items;
} }
@@ -164,13 +164,13 @@ export function createScadaAnalysisCollection(
collection: FeatureCollection, collection: FeatureCollection,
items: ScadaAnalysisItem[] items: ScadaAnalysisItem[]
): { collection: FeatureCollection<Point>; result: Omit<ScadaAnalysisRenderResult, "fitted"> } { ): { collection: FeatureCollection<Point>; result: Omit<ScadaAnalysisRenderResult, "fitted"> } {
const featuresBySensorId = new Map<string, Feature<Point>>(); const featuresByDeviceId = new Map<string, Feature<Point>>();
collection.features.forEach((feature) => { collection.features.forEach((feature) => {
const sensorId = typeof feature.properties?.sensor_id === "string" const deviceId = typeof feature.properties?.device_id === "string"
? feature.properties.sensor_id.trim() ? feature.properties.device_id.trim()
: ""; : "";
if (sensorId && feature.geometry?.type === "Point" && !featuresBySensorId.has(sensorId)) { if (deviceId && feature.geometry?.type === "Point" && !featuresByDeviceId.has(deviceId)) {
featuresBySensorId.set(sensorId, feature as Feature<Point>); featuresByDeviceId.set(deviceId, feature as Feature<Point>);
} }
}); });
@@ -179,26 +179,26 @@ export function createScadaAnalysisCollection(
const missingIds: string[] = []; const missingIds: string[] = [];
const features: Array<Feature<Point>> = []; const features: Array<Feature<Point>> = [];
items.forEach((item) => { items.forEach((item) => {
const feature = featuresBySensorId.get(item.sensor_id); const feature = featuresByDeviceId.get(item.device_id);
if (!feature) { if (!feature) {
missingIds.push(item.sensor_id); missingIds.push(item.device_id);
return; return;
} }
const trustedSensorId = String(feature.properties?.sensor_id ?? "").trim(); const trustedDeviceId = String(feature.properties?.device_id ?? "").trim();
if (!trustedSensorId) { if (!trustedDeviceId) {
missingIds.push(item.sensor_id); missingIds.push(item.device_id);
return; return;
} }
renderedIds.push(trustedSensorId); renderedIds.push(trustedDeviceId);
levelCounts[item.level] += 1; levelCounts[item.level] += 1;
features.push({ features.push({
...feature, ...feature,
properties: { properties: {
...feature.properties, ...feature.properties,
sensor_id: trustedSensorId, device_id: trustedDeviceId,
analysis_level: item.level, analysis_level: item.level,
analysis_level_label: SCADA_ANALYSIS_LEVEL_LABELS[item.level], analysis_level_label: SCADA_ANALYSIS_LEVEL_LABELS[item.level],
analysis_label: `${trustedSensorId} · ${SCADA_ANALYSIS_LEVEL_LABELS[item.level]}`, analysis_label: `${trustedDeviceId} · ${SCADA_ANALYSIS_LEVEL_LABELS[item.level]}`,
sort_priority: SCADA_ANALYSIS_SORT_PRIORITY[item.level] sort_priority: SCADA_ANALYSIS_SORT_PRIORITY[item.level]
} }
}); });
+8 -1
View File
@@ -16,7 +16,7 @@ import { SUPPLY_LAYER_VISUALS } from "./map-layer-visuals";
describe("SCADA map presentation", () => { describe("SCADA map presentation", () => {
it("maps the authoritative device types and falls back to unknown", () => { it("maps the authoritative device types and falls back to unknown", () => {
expect(SCADA_ICON_EXPRESSION).toEqual([ expect(SCADA_ICON_EXPRESSION).toEqual([
"match", ["downcase", ["to-string", ["coalesce", ["get", "type"], ""]]], "match", ["downcase", ["to-string", ["coalesce", ["get", "device_type"], ""]]],
"pipe_flow", SCADA_IMAGE_IDS.flow, "pipe_flow", SCADA_IMAGE_IDS.flow,
"pressure", SCADA_IMAGE_IDS.pressure, "pressure", SCADA_IMAGE_IDS.pressure,
SCADA_IMAGE_IDS.pressure SCADA_IMAGE_IDS.pressure
@@ -28,6 +28,13 @@ describe("SCADA map presentation", () => {
expect(scadaFallbackLayers.at(-1)?.id).toBe(SCADA_HIT_LAYER_ID); expect(scadaFallbackLayers.at(-1)?.id).toBe(SCADA_HIT_LAYER_ID);
}); });
it("renders every SCADA layer from GeoJSON without a GeoServer source-layer", () => {
[...scadaLayers, ...scadaFallbackLayers].forEach((layer) => {
expect("source" in layer ? layer.source : undefined).toBe("scada");
expect(layer).not.toHaveProperty("source-layer");
});
});
it("keeps the icon and selected ring consistent with junction selection", () => { it("keeps the icon and selected ring consistent with junction selection", () => {
const symbolLayer = scadaLayers.find((layer) => layer.id === "scada-symbol"); const symbolLayer = scadaLayers.find((layer) => layer.id === "scada-symbol");
const outerLayer = scadaLayers.find((layer) => layer.id === "scada-selected-outer"); const outerLayer = scadaLayers.find((layer) => layer.id === "scada-selected-outer");
+10 -11
View File
@@ -8,7 +8,6 @@ import {
} from "./supply-style-expressions"; } from "./supply-style-expressions";
export const SCADA_SOURCE_ID = "scada"; export const SCADA_SOURCE_ID = "scada";
export const SCADA_SOURCE_LAYER = "geo_scada";
export const SCADA_HIT_LAYER_ID = "scada-hit"; export const SCADA_HIT_LAYER_ID = "scada-hit";
export const SCADA_ICON_PATHS = { export const SCADA_ICON_PATHS = {
@@ -24,7 +23,7 @@ export const SCADA_IMAGE_IDS = {
} as const; } as const;
export const SCADA_ICON_EXPRESSION: ExpressionSpecification = [ export const SCADA_ICON_EXPRESSION: ExpressionSpecification = [
"match", ["downcase", ["to-string", ["coalesce", ["get", "type"], ""]]], "match", ["downcase", ["to-string", ["coalesce", ["get", "device_type"], ""]]],
"pipe_flow", SCADA_IMAGE_IDS.flow, "pipe_flow", SCADA_IMAGE_IDS.flow,
"pressure", SCADA_IMAGE_IDS.pressure, "pressure", SCADA_IMAGE_IDS.pressure,
SCADA_IMAGE_IDS.pressure SCADA_IMAGE_IDS.pressure
@@ -40,7 +39,7 @@ const stateOpacity = (state: "hovered" | "selected"): ExpressionSpecification =>
]; ];
const scadaColor: ExpressionSpecification = [ const scadaColor: ExpressionSpecification = [
"match", ["downcase", ["to-string", ["coalesce", ["get", "type"], ""]]], "match", ["downcase", ["to-string", ["coalesce", ["get", "device_type"], ""]]],
"pipe_flow", MAP_STYLE_TOKENS.supply.scadaFlow, "pipe_flow", MAP_STYLE_TOKENS.supply.scadaFlow,
MAP_STYLE_TOKENS.supply.scadaPressure MAP_STYLE_TOKENS.supply.scadaPressure
]; ];
@@ -53,7 +52,7 @@ export type ScadaImageRegistrationResult = {
}; };
const scadaCasingLayer: Layer = { const scadaCasingLayer: Layer = {
id: "scada-casing", type: "circle", source: SCADA_SOURCE_ID, "source-layer": SCADA_SOURCE_LAYER, minzoom: SUPPLY_LAYER_VISUALS.scada.minzoom, id: "scada-casing", type: "circle", source: SCADA_SOURCE_ID, minzoom: SUPPLY_LAYER_VISUALS.scada.minzoom,
paint: { paint: {
"circle-radius": SUPPLY_LAYER_VISUALS.scada.haloRadius, "circle-radius": SUPPLY_LAYER_VISUALS.scada.haloRadius,
"circle-color": MAP_STYLE_TOKENS.canvas.casing, "circle-color": MAP_STYLE_TOKENS.canvas.casing,
@@ -62,7 +61,7 @@ const scadaCasingLayer: Layer = {
}; };
const scadaFillLayer: Layer = { const scadaFillLayer: Layer = {
id: "scada-fill", type: "circle", source: SCADA_SOURCE_ID, "source-layer": SCADA_SOURCE_LAYER, minzoom: SUPPLY_LAYER_VISUALS.scada.minzoom, id: "scada-fill", type: "circle", source: SCADA_SOURCE_ID, minzoom: SUPPLY_LAYER_VISUALS.scada.minzoom,
paint: { paint: {
"circle-radius": SUPPLY_LAYER_VISUALS.scada.radius, "circle-radius": SUPPLY_LAYER_VISUALS.scada.radius,
"circle-color": createSupplyStateColor(scadaColor), "circle-color": createSupplyStateColor(scadaColor),
@@ -71,13 +70,13 @@ const scadaFillLayer: Layer = {
}; };
const scadaSymbolLayer: Layer = { const scadaSymbolLayer: Layer = {
id: "scada-symbol", type: "symbol", source: SCADA_SOURCE_ID, "source-layer": SCADA_SOURCE_LAYER, minzoom: SUPPLY_LAYER_VISUALS.scada.minzoom, id: "scada-symbol", type: "symbol", source: SCADA_SOURCE_ID, minzoom: SUPPLY_LAYER_VISUALS.scada.minzoom,
layout: { "icon-image": SCADA_ICON_EXPRESSION, "icon-size": SUPPLY_LAYER_VISUALS.scada.iconSize, "icon-allow-overlap": true, "icon-ignore-placement": true }, layout: { "icon-image": SCADA_ICON_EXPRESSION, "icon-size": SUPPLY_LAYER_VISUALS.scada.iconSize, "icon-allow-overlap": true, "icon-ignore-placement": true },
paint: { "icon-opacity": supplyIconOpacityExpression } paint: { "icon-opacity": supplyIconOpacityExpression }
}; };
const scadaFallbackLayer: Layer = { const scadaFallbackLayer: Layer = {
id: "scada-fallback", type: "circle", source: SCADA_SOURCE_ID, "source-layer": SCADA_SOURCE_LAYER, minzoom: SUPPLY_LAYER_VISUALS.scada.minzoom, id: "scada-fallback", type: "circle", source: SCADA_SOURCE_ID, minzoom: SUPPLY_LAYER_VISUALS.scada.minzoom,
paint: { paint: {
"circle-radius": ["interpolate", ["linear"], ["zoom"], 9, 1.7, 12, 2.1, 16, 2.7, 20, 3.4, 24, 4.2], "circle-radius": ["interpolate", ["linear"], ["zoom"], 9, 1.7, 12, 2.1, 16, 2.7, 20, 3.4, 24, 4.2],
"circle-color": MAP_STYLE_TOKENS.canvas.casing, "circle-color": MAP_STYLE_TOKENS.canvas.casing,
@@ -86,13 +85,13 @@ const scadaFallbackLayer: Layer = {
}; };
export const scadaInteractionLayers: Layer[] = [ export const scadaInteractionLayers: Layer[] = [
{ id: "scada-hover", type: "circle", source: SCADA_SOURCE_ID, "source-layer": SCADA_SOURCE_LAYER, minzoom: SUPPLY_LAYER_VISUALS.scada.minzoom, paint: { "circle-radius": SUPPLY_LAYER_VISUALS.scada.hoverRadius, "circle-color": MAP_STYLE_TOKENS.canvas.transparent, "circle-stroke-color": scadaColor, "circle-stroke-width": 2, "circle-opacity": stateOpacity("hovered"), "circle-stroke-opacity": stateOpacity("hovered") } }, { id: "scada-hover", type: "circle", source: SCADA_SOURCE_ID, minzoom: SUPPLY_LAYER_VISUALS.scada.minzoom, paint: { "circle-radius": SUPPLY_LAYER_VISUALS.scada.hoverRadius, "circle-color": MAP_STYLE_TOKENS.canvas.transparent, "circle-stroke-color": scadaColor, "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: SUPPLY_LAYER_VISUALS.scada.minzoom, paint: { "circle-radius": SUPPLY_LAYER_VISUALS.scada.selectedRadius, "circle-color": MAP_STYLE_TOKENS.canvas.transparent, "circle-stroke-color": MAP_STYLE_TOKENS.canvas.casing, "circle-stroke-width": 5, "circle-opacity": stateOpacity("selected"), "circle-stroke-opacity": stateOpacity("selected") } }, { id: "scada-selected-outer", type: "circle", source: SCADA_SOURCE_ID, minzoom: SUPPLY_LAYER_VISUALS.scada.minzoom, paint: { "circle-radius": SUPPLY_LAYER_VISUALS.scada.selectedRadius, "circle-color": MAP_STYLE_TOKENS.canvas.transparent, "circle-stroke-color": MAP_STYLE_TOKENS.canvas.casing, "circle-stroke-width": 5, "circle-opacity": stateOpacity("selected"), "circle-stroke-opacity": stateOpacity("selected") } },
{ id: "scada-selected", type: "circle", source: SCADA_SOURCE_ID, "source-layer": SCADA_SOURCE_LAYER, minzoom: SUPPLY_LAYER_VISUALS.scada.minzoom, paint: { "circle-radius": SUPPLY_LAYER_VISUALS.scada.selectedRadius, "circle-color": MAP_STYLE_TOKENS.canvas.transparent, "circle-stroke-color": MAP_STYLE_TOKENS.state.selected, "circle-stroke-width": 2.5, "circle-opacity": stateOpacity("selected"), "circle-stroke-opacity": stateOpacity("selected") } } { id: "scada-selected", type: "circle", source: SCADA_SOURCE_ID, minzoom: SUPPLY_LAYER_VISUALS.scada.minzoom, paint: { "circle-radius": SUPPLY_LAYER_VISUALS.scada.selectedRadius, "circle-color": MAP_STYLE_TOKENS.canvas.transparent, "circle-stroke-color": MAP_STYLE_TOKENS.state.selected, "circle-stroke-width": 2.5, "circle-opacity": stateOpacity("selected"), "circle-stroke-opacity": stateOpacity("selected") } }
]; ];
const scadaHitLayer: Layer = { const scadaHitLayer: Layer = {
id: SCADA_HIT_LAYER_ID, type: "circle", source: SCADA_SOURCE_ID, "source-layer": SCADA_SOURCE_LAYER, minzoom: SUPPLY_LAYER_VISUALS.scada.minzoom, id: SCADA_HIT_LAYER_ID, type: "circle", source: SCADA_SOURCE_ID, minzoom: SUPPLY_LAYER_VISUALS.scada.minzoom,
paint: { "circle-radius": SUPPLY_LAYER_VISUALS.scada.hitRadius, "circle-color": MAP_STYLE_TOKENS.canvas.transparent, "circle-opacity": 0 } paint: { "circle-radius": SUPPLY_LAYER_VISUALS.scada.hitRadius, "circle-color": MAP_STYLE_TOKENS.canvas.transparent, "circle-opacity": 0 }
}; };
+31 -16
View File
@@ -1,7 +1,9 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { import {
AVAILABLE_WATER_NETWORK_SOURCE_IDS, AVAILABLE_WATER_NETWORK_SOURCE_IDS,
GEOSERVER_WATER_NETWORK_SOURCE_IDS,
SUPPLY_LAYER_CATALOG, SUPPLY_LAYER_CATALOG,
createBaseStyle,
createWaterNetworkSources, createWaterNetworkSources,
SOURCE_LAYERS, SOURCE_LAYERS,
WATER_NETWORK_SOURCE_IDS WATER_NETWORK_SOURCE_IDS
@@ -10,13 +12,12 @@ import {
describe("createWaterNetworkSources", () => { describe("createWaterNetworkSources", () => {
it("catalogs published and pre-registered supply system layers", () => { it("catalogs published and pre-registered supply system layers", () => {
expect(SOURCE_LAYERS).toEqual({ expect(SOURCE_LAYERS).toEqual({
pipes: "geo_pipes_mat", pipes: "pipes",
junctions: "geo_junctions_mat", junctions: "junctions",
valves: "geo_valves", valves: "valves",
reservoirs: "geo_reservoirs", reservoirs: "reservoirs",
scada: "geo_scada", pumps: "pumps",
pumps: "geo_pumps", tanks: "tanks"
tanks: "geo_tanks"
}); });
expect(WATER_NETWORK_SOURCE_IDS).toEqual(["pipes", "junctions", "valves", "reservoirs", "scada", "pumps", "tanks"]); expect(WATER_NETWORK_SOURCE_IDS).toEqual(["pipes", "junctions", "valves", "reservoirs", "scada", "pumps", "tanks"]);
expect(SUPPLY_LAYER_CATALOG.map((layer) => [layer.id, layer.available])).toEqual([ expect(SUPPLY_LAYER_CATALOG.map((layer) => [layer.id, layer.available])).toEqual([
@@ -25,31 +26,45 @@ describe("createWaterNetworkSources", () => {
["valves", true], ["valves", true],
["reservoirs", true], ["reservoirs", true],
["scada", true], ["scada", true],
["pumps", false], ["pumps", true],
["tanks", false] ["tanks", true]
]); ]);
expect(SUPPLY_LAYER_CATALOG.find((layer) => layer.id === "valves")).toMatchObject({ expect(SUPPLY_LAYER_CATALOG.find((layer) => layer.id === "valves")).toMatchObject({
geometry: "point", geometry: "point",
sourceLayer: "geo_valves" sourceLayer: "valves"
}); });
}); });
it("loads only available supply system layers from GeoServer WMTS", () => { it("loads only available supply system layers from GeoServer WMTS", () => {
expect(AVAILABLE_WATER_NETWORK_SOURCE_IDS).toEqual(["pipes", "junctions", "valves", "reservoirs", "scada"]); expect(AVAILABLE_WATER_NETWORK_SOURCE_IDS).toEqual(["pipes", "junctions", "valves", "reservoirs", "scada", "pumps", "tanks"]);
expect(SUPPLY_LAYER_CATALOG.find((layer) => layer.id === "reservoirs")).toMatchObject({ expect(SUPPLY_LAYER_CATALOG.find((layer) => layer.id === "reservoirs")).toMatchObject({
label: "水库", label: "水库",
icon: "reservoir" icon: "reservoir"
}); });
const sources = createWaterNetworkSources(); const sources = createWaterNetworkSources();
expect(Object.keys(sources)).toEqual(AVAILABLE_WATER_NETWORK_SOURCE_IDS); expect(Object.keys(sources)).toEqual(GEOSERVER_WATER_NETWORK_SOURCE_IDS);
AVAILABLE_WATER_NETWORK_SOURCE_IDS.forEach((sourceId) => { GEOSERVER_WATER_NETWORK_SOURCE_IDS.forEach((sourceId) => {
expect(sources[sourceId].tiles).toEqual([ expect(sources[sourceId].tiles).toEqual([
expect.stringContaining(`/tjwater:${SOURCE_LAYERS[sourceId]}/WebMercatorQuad/`) expect.stringContaining(`/tjwater_next:${SOURCE_LAYERS[sourceId]}/WebMercatorQuad/`)
]); ]);
expect(sources[sourceId].promoteId).toBe("id"); expect(sources[sourceId].promoteId).toBe("id");
expect(sources[sourceId].bounds).toEqual([
121.35162408429075,
30.81049932304578,
121.77248479490075,
31.007207809222017
]);
});
expect("pumps" in sources).toBe(true);
expect("tanks" in sources).toBe(true);
expect("scada" in sources).toBe(false);
});
it("uses device_id as the MapLibre feature ID for the API-backed SCADA source", () => {
expect(createBaseStyle().sources.scada).toMatchObject({
type: "geojson",
promoteId: "device_id"
}); });
expect("pumps" in sources).toBe(false);
expect("tanks" in sources).toBe(false);
}); });
}); });
+32 -22
View File
@@ -6,19 +6,20 @@ import {
SCADA_ANALYSIS_SOURCE_ID, SCADA_ANALYSIS_SOURCE_ID,
createEmptyScadaAnalysisCollection createEmptyScadaAnalysisCollection
} from "./scada-analysis"; } from "./scada-analysis";
import { SCADA_SOURCE_ID } from "./scada";
export const WATER_NETWORK_GLOBAL_VIEW = { export const WATER_NETWORK_GLOBAL_VIEW = {
bbox3857: [13508801.930066336, 3608163.3499832638, 13555650.638648884, 3633685.137885742] bbox3857: [13508801.930066336, 3608163.3499832638, 13555650.638648884, 3633685.137885742],
bbox4326: [121.35162408429075, 30.81049932304578, 121.77248479490075, 31.007207809222017]
} as const; } as const;
export const SOURCE_LAYERS = { export const SOURCE_LAYERS = {
pipes: "geo_pipes_mat", pipes: "pipes",
junctions: "geo_junctions_mat", junctions: "junctions",
valves: "geo_valves", valves: "valves",
reservoirs: "geo_reservoirs", reservoirs: "reservoirs",
scada: "geo_scada", pumps: "pumps",
pumps: "geo_pumps", tanks: "tanks"
tanks: "geo_tanks"
} as const; } as const;
export const WATER_NETWORK_SOURCE_IDS = [ export const WATER_NETWORK_SOURCE_IDS = [
@@ -35,7 +36,7 @@ export type WaterNetworkSourceId = (typeof WATER_NETWORK_SOURCE_IDS)[number];
export type SupplyLayerCatalogItem = { export type SupplyLayerCatalogItem = {
id: WaterNetworkSourceId; id: WaterNetworkSourceId;
sourceLayer: (typeof SOURCE_LAYERS)[WaterNetworkSourceId]; sourceLayer?: (typeof SOURCE_LAYERS)[keyof typeof SOURCE_LAYERS];
geometry: "line" | "point"; geometry: "line" | "point";
available: boolean; available: boolean;
label: string; label: string;
@@ -77,7 +78,7 @@ export const SUPPLY_LAYER_CATALOG = [
}, },
{ {
id: "scada", id: "scada",
sourceLayer: SOURCE_LAYERS.scada, sourceLayer: undefined,
geometry: "point", geometry: "point",
available: true, available: true,
label: "SCADA", label: "SCADA",
@@ -87,7 +88,7 @@ export const SUPPLY_LAYER_CATALOG = [
id: "pumps", id: "pumps",
sourceLayer: SOURCE_LAYERS.pumps, sourceLayer: SOURCE_LAYERS.pumps,
geometry: "point", geometry: "point",
available: false, available: true,
label: "水泵", label: "水泵",
icon: "pump" icon: "pump"
}, },
@@ -95,7 +96,7 @@ export const SUPPLY_LAYER_CATALOG = [
id: "tanks", id: "tanks",
sourceLayer: SOURCE_LAYERS.tanks, sourceLayer: SOURCE_LAYERS.tanks,
geometry: "point", geometry: "point",
available: false, available: true,
label: "水箱", label: "水箱",
icon: "tank" icon: "tank"
} }
@@ -103,12 +104,14 @@ export const SUPPLY_LAYER_CATALOG = [
export const AVAILABLE_WATER_NETWORK_SOURCE_IDS = SUPPLY_LAYER_CATALOG.filter( export const AVAILABLE_WATER_NETWORK_SOURCE_IDS = SUPPLY_LAYER_CATALOG.filter(
(layer) => layer.available (layer) => layer.available
).map((layer) => layer.id) as Extract< ).map((layer) => layer.id) as WaterNetworkSourceId[];
WaterNetworkSourceId,
"pipes" | "junctions" | "valves" | "reservoirs" | "scada"
>[];
export type AvailableWaterNetworkSourceId = (typeof AVAILABLE_WATER_NETWORK_SOURCE_IDS)[number]; export type AvailableWaterNetworkSourceId = (typeof AVAILABLE_WATER_NETWORK_SOURCE_IDS)[number];
export type GeoServerWaterNetworkSourceId = Exclude<AvailableWaterNetworkSourceId, "scada">;
export const GEOSERVER_WATER_NETWORK_SOURCE_IDS = AVAILABLE_WATER_NETWORK_SOURCE_IDS.filter(
(sourceId): sourceId is GeoServerWaterNetworkSourceId => sourceId !== "scada"
);
export function isAvailableWaterNetworkSourceId( export function isAvailableWaterNetworkSourceId(
value: string value: string
@@ -118,11 +121,12 @@ export function isAvailableWaterNetworkSourceId(
export function createWaterNetworkSources() { export function createWaterNetworkSources() {
return Object.fromEntries( return Object.fromEntries(
SUPPLY_LAYER_CATALOG.filter((layer) => layer.available).map((layer) => [ SUPPLY_LAYER_CATALOG.flatMap((layer) =>
layer.id, layer.available && layer.sourceLayer !== undefined
createGeoServerVectorSource(layer.sourceLayer) ? [[layer.id, createGeoServerVectorSource(layer.sourceLayer)] as const]
]) : []
) as Record<AvailableWaterNetworkSourceId, VectorSourceSpecification>; )
) as Record<GeoServerWaterNetworkSourceId, VectorSourceSpecification>;
} }
function createGeoServerVectorSource( function createGeoServerVectorSource(
@@ -135,12 +139,18 @@ function createGeoServerVectorSource(
`${MAP_URL}/gwc/service/wmts/rest/${GEOSERVER_WORKSPACE}:${sourceLayer}/WebMercatorQuad/{z}/{y}/{x}?format=application/vnd.mapbox-vector-tile` `${MAP_URL}/gwc/service/wmts/rest/${GEOSERVER_WORKSPACE}:${sourceLayer}/WebMercatorQuad/{z}/{y}/{x}?format=application/vnd.mapbox-vector-tile`
], ],
minzoom: 0, minzoom: 0,
maxzoom: 24 maxzoom: 24,
bounds: [...WATER_NETWORK_GLOBAL_VIEW.bbox4326]
}; };
} }
export function createBaseStyle(mapboxToken?: string): StyleSpecification { export function createBaseStyle(mapboxToken?: string): StyleSpecification {
const sources: StyleSpecification["sources"] = { const sources: StyleSpecification["sources"] = {
[SCADA_SOURCE_ID]: {
type: "geojson",
promoteId: "device_id",
data: { type: "FeatureCollection", features: [] }
},
[SCADA_ANALYSIS_SOURCE_ID]: { [SCADA_ANALYSIS_SOURCE_ID]: {
type: "geojson", type: "geojson",
data: createEmptyScadaAnalysisCollection() data: createEmptyScadaAnalysisCollection()
@@ -56,7 +56,7 @@ describe("WorkbenchMapController", () => {
await controller.highlight({ sourceId: "junctions", featureId: "J-1" }); await controller.highlight({ sourceId: "junctions", featureId: "J-1" });
const requestUrl = new URL(String(fetchMock.mock.calls[0]?.[0])); const requestUrl = new URL(String(fetchMock.mock.calls[0]?.[0]));
expect(requestUrl.pathname).toBe("/geoserver/tjwater/ows"); expect(requestUrl.pathname).toBe("/geoserver/tjwater_next/ows");
expect(requestUrl.searchParams.get("request")).toBe("GetFeature"); expect(requestUrl.searchParams.get("request")).toBe("GetFeature");
expect(requestUrl.searchParams.get("cql_filter")).toBe("\"id\" IN ('J-1')"); expect(requestUrl.searchParams.get("cql_filter")).toBe("\"id\" IN ('J-1')");
expect(map.setFeatureState).toHaveBeenCalled(); expect(map.setFeatureState).toHaveBeenCalled();
@@ -81,7 +81,7 @@ describe("WorkbenchMapController", () => {
expect(layers.get(FLOW_LINE_LAYER_ID)).toMatchObject({ expect(layers.get(FLOW_LINE_LAYER_ID)).toMatchObject({
source: "pipes", source: "pipes",
"source-layer": "geo_pipes_mat" "source-layer": "pipes"
}); });
expect([...layers.keys()].some((id) => id.includes("arrows"))).toBe(false); expect([...layers.keys()].some((id) => id.includes("arrows"))).toBe(false);
vi.unstubAllGlobals(); vi.unstubAllGlobals();
@@ -96,7 +96,7 @@ describe("WorkbenchMapController", () => {
expect(map.setFeatureState).toHaveBeenCalledWith( expect(map.setFeatureState).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
source: "pipes", source: "pipes",
sourceLayer: "geo_pipes_mat", sourceLayer: "pipes",
id: "P-1" id: "P-1"
}), }),
{ flowDirection: -1 } { flowDirection: -1 }
@@ -251,13 +251,13 @@ describe("WorkbenchMapController", () => {
{ {
type: "Feature", type: "Feature",
geometry: { type: "Point", coordinates: [120.7, 28] }, geometry: { type: "Point", coordinates: [120.7, 28] },
properties: { sensor_id: "MP01" } properties: { device_id: "MP01" }
} }
] ]
}); });
await expect( await expect(
controller.renderScadaAnalysis([{ sensor_id: "MP01", level: "high" }]) controller.renderScadaAnalysis([{ device_id: "MP01", level: "high" }])
).resolves.toMatchObject({ ).resolves.toMatchObject({
rendered_ids: ["MP01"], rendered_ids: ["MP01"],
fitted: true fitted: true
@@ -47,6 +47,7 @@ export type WorkbenchMapErrorCode =
| "MAP_NOT_READY" | "MAP_NOT_READY"
| "FEATURE_NOT_FOUND" | "FEATURE_NOT_FOUND"
| "WFS_UNAVAILABLE" | "WFS_UNAVAILABLE"
| "SCADA_API_UNAVAILABLE"
| "SCADA_FEATURES_NOT_FOUND" | "SCADA_FEATURES_NOT_FOUND"
| "ACTION_SUPERSEDED" | "ACTION_SUPERSEDED"
| "FLOW_UNAVAILABLE" | "FLOW_UNAVAILABLE"
@@ -88,6 +89,7 @@ export type WorkbenchMapControllerOptions = {
isReady: () => boolean; isReady: () => boolean;
getPadding: () => PaddingOptions; getPadding: () => PaddingOptions;
fetchFeatures?: (sourceId: WaterNetworkSourceId, featureIds?: string[], signal?: AbortSignal) => Promise<FeatureCollection>; fetchFeatures?: (sourceId: WaterNetworkSourceId, featureIds?: string[], signal?: AbortSignal) => Promise<FeatureCollection>;
getScadaFeatures?: (deviceIds?: string[], signal?: AbortSignal) => Promise<FeatureCollection>;
reducedMotion?: () => boolean; reducedMotion?: () => boolean;
}; };
@@ -320,16 +322,16 @@ export class WorkbenchMapController implements WorkbenchMapCommands {
try { try {
sourceCollection = await this.fetchFeatures( sourceCollection = await this.fetchFeatures(
"scada", "scada",
items.map((item) => item.sensor_id), items.map((item) => item.device_id),
signal signal
); );
} catch { } catch {
if (revision === this.scadaAnalysisRevision) { if (revision === this.scadaAnalysisRevision) {
this.patchState({ pending: false, errorCode: "WFS_UNAVAILABLE" }); this.patchState({ pending: false, errorCode: "SCADA_API_UNAVAILABLE" });
} }
throw new Error( throw new Error(
revision === this.scadaAnalysisRevision revision === this.scadaAnalysisRevision
? "WFS_UNAVAILABLE" ? "SCADA_API_UNAVAILABLE"
: "ACTION_SUPERSEDED" : "ACTION_SUPERSEDED"
); );
} }
@@ -463,13 +465,20 @@ export class WorkbenchMapController implements WorkbenchMapCommands {
this.patchState({ pending: false }); this.patchState({ pending: false });
return feature; return feature;
} catch { } catch {
this.patchState({ pending: false, errorCode: "WFS_UNAVAILABLE" }); this.patchState({
pending: false,
errorCode: target.sourceId === "scada" ? "SCADA_API_UNAVAILABLE" : "WFS_UNAVAILABLE"
});
return null; return null;
} }
} }
private async fetchFeatures(sourceId: WaterNetworkSourceId, featureIds?: string[], signal?: AbortSignal) { private async fetchFeatures(sourceId: WaterNetworkSourceId, featureIds?: string[], signal?: AbortSignal) {
if (this.options.fetchFeatures) return this.options.fetchFeatures(sourceId, featureIds, signal); if (this.options.fetchFeatures) return this.options.fetchFeatures(sourceId, featureIds, signal);
if (sourceId === "scada") {
if (!this.options.getScadaFeatures) throw new Error("SCADA_API_UNAVAILABLE");
return this.options.getScadaFeatures(featureIds, signal);
}
const parsed = parseMapFeatureQuery({ sourceId, featureIds }); const parsed = parseMapFeatureQuery({ sourceId, featureIds });
if (!parsed.ok) throw new Error(parsed.code); if (!parsed.ok) throw new Error(parsed.code);
const response = await fetch(createMapFeatureWfsUrl(parsed.value), { const response = await fetch(createMapFeatureWfsUrl(parsed.value), {
@@ -516,7 +525,9 @@ function createEmptyPipeFlowSummary(): PipeFlowSummary {
} }
function toFeatureStateTarget(target: FeatureTarget) { function toFeatureStateTarget(target: FeatureTarget) {
return { source: target.sourceId, sourceLayer: SOURCE_LAYERS[target.sourceId], id: target.featureId }; return target.sourceId === "scada"
? { source: target.sourceId, id: target.featureId }
: { source: target.sourceId, sourceLayer: SOURCE_LAYERS[target.sourceId], id: target.featureId };
} }
function collectCoordinates(geometry: Geometry | null): Position[] { function collectCoordinates(geometry: Geometry | null): Position[] {
@@ -8,8 +8,8 @@ import {
describe("feature panel properties", () => { describe("feature panel properties", () => {
it.each([ it.each([
["pipes", ["id", "diameter", "length", "material"]], ["pipes", ["id", "start_node_id", "end_node_id", "diameter", "length", "roughness", "status"]],
["junctions", ["id", "elevation", "demand", "pressure"]] ["junctions", ["id", "elevation", "base_demand"]]
] as const)("shows the configured %s fields", (layer, expectedKeys) => { ] as const)("shows the configured %s fields", (layer, expectedKeys) => {
const entries = getFeaturePanelProperties(layer, {}, "feature-id"); const entries = getFeaturePanelProperties(layer, {}, "feature-id");
@@ -20,16 +20,22 @@ describe("feature panel properties", () => {
it("formats pipe diameter from GeoServer DN millimeter values", () => { it("formats pipe diameter from GeoServer DN millimeter values", () => {
const entries = getFeaturePanelProperties("pipes", { const entries = getFeaturePanelProperties("pipes", {
id: "P-1", id: "P-1",
start_node_id: "J-1",
end_node_id: "J-2",
diameter: 600, diameter: 600,
length: 42.5, length: 42.5,
material: "DI" roughness: 120,
status: "OPEN"
}); });
expect(entries.map(({ label, value }) => [label, value])).toEqual([ expect(entries.map(({ label, value }) => [label, value])).toEqual([
["编号", "P-1"], ["编号", "P-1"],
["起点节点", "J-1"],
["终点节点", "J-2"],
["管径", "600 mm"], ["管径", "600 mm"],
["长度", "42.50 m"], ["长度", "42.50 m"],
["材质", "球墨铸铁"] ["粗糙系数", "120"],
["状态", "开启"]
]); ]);
}); });
@@ -37,14 +43,14 @@ describe("feature panel properties", () => {
const metrics = getFeatureInsightMetrics("pipes", { const metrics = getFeatureInsightMetrics("pipes", {
diameter: 600, diameter: 600,
length: 42.5, length: 42.5,
material: "DI", roughness: 120,
status: "OPEN" status: "OPEN"
}); });
expect(metrics).toEqual([ expect(metrics).toEqual([
{ label: "管径", value: "600 mm" }, { label: "管径", value: "600 mm" },
{ label: "长度", value: "42.50 m" }, { label: "长度", value: "42.50 m" },
{ label: "材质", value: "球墨铸铁" }, { label: "粗糙系数", value: "120" },
{ label: "状态", value: "开启" } { label: "状态", value: "开启" }
]); ]);
}); });
@@ -52,56 +58,62 @@ describe("feature panel properties", () => {
it("localizes common enumerated property values in the panel", () => { it("localizes common enumerated property values in the panel", () => {
expect(getFeaturePanelProperties("valves", { expect(getFeaturePanelProperties("valves", {
id: "V-1", id: "V-1",
node1: "J-1", start_node_id: "J-1",
node2: "J-2", end_node_id: "J-2",
diameter: 300, diameter: 300,
v_type: "PRV", valve_type: "PRV",
setting: "OPEN" setting: "OPEN",
minor_loss: 0.2
}).map(({ label, value }) => [label, value])).toEqual([ }).map(({ label, value }) => [label, value])).toEqual([
["编号", "V-1"], ["编号", "V-1"],
["起点节点", "J-1"], ["起点节点", "J-1"],
["终点节点", "J-2"], ["终点节点", "J-2"],
["管径", "300 mm"], ["管径", "300 mm"],
["阀门类型", "减压阀"], ["阀门类型", "减压阀"],
["阀门设定", "开启"] ["阀门设定", "开启"],
["局部损失系数", "0.20"]
]); ]);
expect(getFeaturePanelProperties("scada", { expect(getFeaturePanelProperties("scada", {
id: "S-1", device_id: "S-1",
type: "pressure", device_type: "pressure",
associated_element_id: "J-1", node_id: "J-1",
link_id: null,
api_query_id: "query-1",
transmission_mode: "non_realtime", transmission_mode: "non_realtime",
transmission_frequency: "15min", transmission_frequency: "15min",
reliability: "high" reliability: "high"
}).map(({ label, value }) => [label, value])).toEqual([ }).map(({ label, value }) => [label, value])).toEqual([
["编号", "S-1"], ["设备 ID", "S-1"],
["类型", "压力监测"], ["设备类型", "压力监测"],
["关联资产", "J-1"], ["关联节点", "J-1"],
["关联连线", "暂无"],
["采集接口", "query-1"],
["传输方式", "非实时"], ["传输方式", "非实时"],
["传输频率", "15min"], ["传输频率", "15min"],
["可靠性", "高"] ["可靠性", "高"]
]); ]);
expect(getFeaturePanelProperties("scada", { expect(getFeaturePanelProperties("scada", {
id: "S-2", device_id: "S-2",
type: "pipe_flow" device_type: "pipe_flow"
}).find((entry) => entry.key === "type")?.value).toBe("流量监测"); }).find((entry) => entry.key === "device_type")?.value).toBe("流量监测");
}); });
it("uses the map feature id when the properties omit it", () => { it("uses the map feature id when the properties omit it", () => {
const model = getFeaturePanelModel("junctions", { demand: 3.4 }, "map-feature-id"); const model = getFeaturePanelModel("junctions", { base_demand: 3.4 }, "map-feature-id");
expect(model.id).toBe("map-feature-id"); expect(model.id).toBe("map-feature-id");
expect(model.badge).toBeUndefined(); expect(model.badge).toBeUndefined();
expect(model.attributes.map((entry) => entry.key)).toEqual(["elevation", "demand", "pressure"]); expect(model.attributes.map((entry) => entry.key)).toEqual(["elevation", "base_demand"]);
}); });
it.each([ it.each([
["valves", ["id", "node1", "node2", "diameter", "v_type", "setting"]], ["valves", ["id", "start_node_id", "end_node_id", "diameter", "valve_type", "setting", "minor_loss"]],
["reservoirs", ["id", "head", "pattern"]], ["reservoirs", ["id", "head", "pattern_id"]],
["scada", ["id", "type", "associated_element_id", "transmission_mode", "transmission_frequency", "reliability"]], ["scada", ["device_id", "device_type", "node_id", "link_id", "api_query_id", "transmission_mode", "transmission_frequency", "reliability"]],
["pumps", ["id", "node1", "node2", "status"]], ["pumps", ["id", "start_node_id", "end_node_id", "power", "head_curve_id", "speed", "pattern_id"]],
["tanks", ["id", "elevation", "init_level", "min_level", "max_level"]] ["tanks", ["id", "elevation", "initial_level", "minimum_level", "maximum_level", "diameter", "minimum_volume", "volume_curve_id", "overflow"]]
] as const)("localizes configured %s panel field labels", (layer, keys) => { ] as const)("localizes configured %s panel field labels", (layer, keys) => {
const entries = getFeaturePanelProperties(layer, {}, "feature-id"); const entries = getFeaturePanelProperties(layer, {}, "feature-id");
@@ -112,7 +124,7 @@ describe("feature panel properties", () => {
label: getFeaturePropertyLabel(entry.key) label: getFeaturePropertyLabel(entry.key)
})) }))
); );
expect(entries.filter((entry) => entry.key !== "id").map((entry) => entry.label)) expect(entries.filter((entry) => entry.key !== "id" && entry.key !== "device_id").map((entry) => entry.label))
.not.toContainEqual(expect.stringMatching(/^[a-z][a-z0-9_]*$/)); .not.toContainEqual(expect.stringMatching(/^[a-z][a-z0-9_]*$/));
}); });
}); });
@@ -29,8 +29,6 @@ const PROPERTY_LABELS: Record<string, string> = {
fid: "GeoServer 要素 ID", fid: "GeoServer 要素 ID",
id: "编号", id: "编号",
scada_id: "编号 UUID", scada_id: "编号 UUID",
sensor_id: "传感器 ID",
sensor_name: "传感器名称",
swmm_node: "SWMM 节点", swmm_node: "SWMM 节点",
topology_order: "拓扑序", topology_order: "拓扑序",
distance_to_wwtp_m: "距处理厂距离", distance_to_wwtp_m: "距处理厂距离",
@@ -48,8 +46,11 @@ const PROPERTY_LABELS: Record<string, string> = {
name: "名称", name: "名称",
code: "编码", code: "编码",
type: "类型", type: "类型",
device_type: "设备类型",
node1: "起点节点", node1: "起点节点",
node2: "终点节点", node2: "终点节点",
start_node_id: "起点节点",
end_node_id: "终点节点",
from_node: "起点节点", from_node: "起点节点",
to_node: "终点节点", to_node: "终点节点",
diameter: "管径", diameter: "管径",
@@ -60,6 +61,7 @@ const PROPERTY_LABELS: Record<string, string> = {
max_depth: "最大深度", max_depth: "最大深度",
invert_elevation: "井底高程", invert_elevation: "井底高程",
v_type: "阀门类型", v_type: "阀门类型",
valve_type: "阀门类型",
setting: "阀门设定", setting: "阀门设定",
minor_loss: "局部损失系数", minor_loss: "局部损失系数",
orifice_type: "孔口类型", orifice_type: "孔口类型",
@@ -76,12 +78,24 @@ const PROPERTY_LABELS: Record<string, string> = {
status: "状态", status: "状态",
head: "水头", head: "水头",
pattern: "模式", pattern: "模式",
pattern_id: "模式",
head_curve_id: "扬程曲线",
volume_curve_id: "容积曲线",
init_level: "初始水位", init_level: "初始水位",
min_level: "最低水位", min_level: "最低水位",
max_level: "最高水位", max_level: "最高水位",
initial_level: "初始水位",
minimum_level: "最低水位",
maximum_level: "最高水位",
minimum_volume: "最小容积",
overflow: "允许溢流",
power: "功率",
speed: "转速倍率",
material: "材质", material: "材质",
elevation: "高程", elevation: "高程",
demand: "需水量", demand: "需水量",
base_demand: "基础需水量",
demands: "需水配置",
pressure: "压力", pressure: "压力",
flow: "流量", flow: "流量",
velocity: "流速", velocity: "流速",
@@ -93,6 +107,9 @@ const PROPERTY_LABELS: Record<string, string> = {
point_external_id: "测点编号", point_external_id: "测点编号",
point_name: "测点名称", point_name: "测点名称",
associated_element_id: "关联资产", associated_element_id: "关联资产",
node_id: "关联节点",
link_id: "关联连线",
api_query_id: "采集接口",
transmission_mode: "传输方式", transmission_mode: "传输方式",
transmission_frequency: "传输频率", transmission_frequency: "传输频率",
reliability: "可靠性", reliability: "可靠性",
@@ -114,8 +131,6 @@ const PROPERTY_ORDER: Record<string, number> = {
fid: 9, fid: 9,
id: 10, id: 10,
scada_id: 10, scada_id: 10,
sensor_id: 10,
sensor_name: 12,
swmm_node: 13, swmm_node: 13,
topology_order: 14, topology_order: 14,
distance_to_wwtp_m: 15, distance_to_wwtp_m: 15,
@@ -143,10 +158,13 @@ const PROPERTY_ORDER: Record<string, number> = {
material: 23, material: 23,
node1: 24, node1: 24,
from_node: 24, from_node: 24,
start_node_id: 24,
node2: 25, node2: 25,
to_node: 25, to_node: 25,
end_node_id: 25,
elevation: 30, elevation: 30,
demand: 31, demand: 31,
base_demand: 31,
pressure: 32, pressure: 32,
flow: 33, flow: 33,
velocity: 34, velocity: 34,
@@ -275,13 +293,13 @@ const PROPERTY_UNITS: Record<string, string> = {
}; };
const FEATURE_PANEL_PROPERTY_KEYS: Record<WaterNetworkSourceId, readonly string[]> = { const FEATURE_PANEL_PROPERTY_KEYS: Record<WaterNetworkSourceId, readonly string[]> = {
pipes: ["id", "diameter", "length", "material"], pipes: ["id", "start_node_id", "end_node_id", "diameter", "length", "roughness", "status"],
junctions: ["id", "elevation", "demand", "pressure"], junctions: ["id", "elevation", "base_demand"],
valves: ["id", "node1", "node2", "diameter", "v_type", "setting"], valves: ["id", "start_node_id", "end_node_id", "diameter", "valve_type", "setting", "minor_loss"],
reservoirs: ["id", "head", "pattern"], reservoirs: ["id", "head", "pattern_id"],
scada: ["id", "type", "associated_element_id", "transmission_mode", "transmission_frequency", "reliability"], scada: ["device_id", "device_type", "node_id", "link_id", "api_query_id", "transmission_mode", "transmission_frequency", "reliability"],
pumps: ["id", "node1", "node2", "status"], pumps: ["id", "start_node_id", "end_node_id", "power", "head_curve_id", "speed", "pattern_id"],
tanks: ["id", "elevation", "init_level", "min_level", "max_level"] tanks: ["id", "elevation", "initial_level", "minimum_level", "maximum_level", "diameter", "minimum_volume", "volume_curve_id", "overflow"]
}; };
const FEATURE_PANEL_BADGE_KEYS: Partial<Record<WaterNetworkSourceId, string>> = { const FEATURE_PANEL_BADGE_KEYS: Partial<Record<WaterNetworkSourceId, string>> = {
@@ -299,42 +317,44 @@ const FEATURE_INSIGHT_METRICS: Record<WaterNetworkSourceId, readonly FeatureInsi
pipes: [ pipes: [
{ label: "管径", key: "diameter" }, { label: "管径", key: "diameter" },
{ label: "长度", key: "length" }, { label: "长度", key: "length" },
{ label: "材质", key: "material" }, { label: "粗糙系数", key: "roughness" },
{ label: "状态", key: "status" } { label: "状态", key: "status" }
], ],
junctions: [ junctions: [
{ label: "需水量", key: "demand" }, { label: "基础需水量", key: "base_demand" },
{ label: "高程", key: "elevation" }, { label: "高程", key: "elevation" },
{ label: "压力", key: "pressure" }, { label: "X 坐标", key: "x" },
{ label: "状态", value: "在线" } { label: "Y 坐标", key: "y" }
], ],
valves: [ valves: [
{ label: "管径", key: "diameter" }, { label: "管径", key: "diameter" },
{ label: "类型", key: "v_type" }, { label: "类型", key: "valve_type" },
{ label: "设定", key: "setting" }, { label: "设定", key: "setting" },
{ label: "局部损失", key: "minor_loss" } { label: "局部损失", key: "minor_loss" }
], ],
reservoirs: [ reservoirs: [
{ label: "水头", key: "head" }, { label: "水头", key: "head" },
{ label: "模式", key: "pattern" }, { label: "模式", key: "pattern_id" },
{ label: "状态", value: "在线" } { label: "X 坐标", key: "x" },
{ label: "Y 坐标", key: "y" }
], ],
scada: [ scada: [
{ label: "类型", key: "type" }, { label: "类型", key: "device_type" },
{ label: "关联资产", key: "associated_element_id" }, { label: "关联节点", key: "node_id" },
{ label: "传输方式", key: "transmission_mode" }, { label: "关联连线", key: "link_id" },
{ label: "可靠性", key: "reliability" } { label: "传输方式", key: "transmission_mode" }
], ],
pumps: [ pumps: [
{ label: "起点", key: "node1" }, { label: "功率", key: "power" },
{ label: "终点", key: "node2" }, { label: "扬程曲线", key: "head_curve_id" },
{ label: "状态", key: "status" } { label: "转速倍率", key: "speed" },
{ label: "模式", key: "pattern_id" }
], ],
tanks: [ tanks: [
{ label: "高程", key: "elevation" }, { label: "高程", key: "elevation" },
{ label: "初始水位", key: "init_level" }, { label: "初始水位", key: "initial_level" },
{ label: "最水位", key: "max_level" }, { label: "最水位", key: "minimum_level" },
{ label: "状态", value: "在线" } { label: "最高水位", key: "maximum_level" }
] ]
}; };
@@ -353,7 +373,7 @@ export function getFeaturePanelProperties(
): LocalizedFeatureProperty[] { ): LocalizedFeatureProperty[] {
return FEATURE_PANEL_PROPERTY_KEYS[layer].map((key) => { return FEATURE_PANEL_PROPERTY_KEYS[layer].map((key) => {
const propertyValue = getPropertyValue(properties, key); const propertyValue = getPropertyValue(properties, key);
const value = key === "id" || key === "scada_id" || key === "sensor_id" ? propertyValue ?? featureId : propertyValue; const value = key === "id" || key === "scada_id" || key === "device_id" ? propertyValue ?? featureId : propertyValue;
return { return {
key, key,
@@ -391,7 +411,7 @@ export function getFeaturePanelModel(
featureId?: string featureId?: string
): FeaturePanelModel { ): FeaturePanelModel {
const entries = getFeaturePanelProperties(layer, properties, featureId); const entries = getFeaturePanelProperties(layer, properties, featureId);
const idKey = "id"; const idKey = layer === "scada" ? "device_id" : "id";
const id = entries.find((entry) => entry.key === idKey)?.value ?? "暂无"; 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;
@@ -420,7 +440,7 @@ export function formatFeaturePropertyValue(key: string, value: unknown) {
return OUTFALL_TYPE_LABELS[value.toLowerCase()] ?? value; return OUTFALL_TYPE_LABELS[value.toLowerCase()] ?? value;
} }
if (normalizedKey === "gated" && value !== null && value !== undefined && value !== "") { if (["gated", "overflow"].includes(normalizedKey) && value !== null && value !== undefined && value !== "") {
return GATED_LABELS[String(value).toLowerCase()] ?? formatValue(value); return GATED_LABELS[String(value).toLowerCase()] ?? formatValue(value);
} }
@@ -452,7 +472,7 @@ export function formatFeaturePropertyValue(key: string, value: unknown) {
: formatValue(value); : formatValue(value);
} }
if (["length", "elevation", "invert_elevation", "max_depth", "startup_depth", "shutoff_depth", "distance_to_wwtp_m"].includes(normalizedKey) if (["length", "elevation", "invert_elevation", "max_depth", "startup_depth", "shutoff_depth", "distance_to_wwtp_m", "initial_level", "minimum_level", "maximum_level"].includes(normalizedKey)
&& value !== null && value !== undefined && value !== "") { && value !== null && value !== undefined && value !== "") {
return `${formatValue(value)} m`; return `${formatValue(value)} m`;
} }
@@ -485,7 +505,7 @@ function getMappedPropertyValue(normalizedKey: string, value: string) {
return STATUS_LABELS[normalizedValue]; return STATUS_LABELS[normalizedValue];
} }
if (normalizedKey === "v_type") { if (normalizedKey === "v_type" || normalizedKey === "valve_type") {
return VALVE_TYPE_LABELS[normalizedValue]; return VALVE_TYPE_LABELS[normalizedValue];
} }
@@ -493,7 +513,7 @@ function getMappedPropertyValue(normalizedKey: string, value: string) {
return MATERIAL_LABELS[normalizedValue]; return MATERIAL_LABELS[normalizedValue];
} }
if (normalizedKey === "type") { if (normalizedKey === "type" || normalizedKey === "device_type") {
return ASSET_TYPE_LABELS[normalizedValue]; return ASSET_TYPE_LABELS[normalizedValue];
} }
@@ -505,7 +525,7 @@ function getMappedPropertyValue(normalizedKey: string, value: string) {
return RELIABILITY_LABELS[normalizedValue]; return RELIABILITY_LABELS[normalizedValue];
} }
if (normalizedKey === "pattern") { if (normalizedKey === "pattern" || normalizedKey === "pattern_id") {
return PATTERN_LABELS[normalizedValue]; return PATTERN_LABELS[normalizedValue];
} }
+2
View File
@@ -12,6 +12,7 @@ describe("runtime frontend configuration", () => {
TJWATER_MAPBOX_ACCESS_TOKEN: "token", TJWATER_MAPBOX_ACCESS_TOKEN: "token",
TJWATER_MAP_URL: "https://maps.example.test/geoserver", TJWATER_MAP_URL: "https://maps.example.test/geoserver",
TJWATER_GEOSERVER_WORKSPACE: "project-a", TJWATER_GEOSERVER_WORKSPACE: "project-a",
TJWATER_SERVER_API_BASE_URL: "https://server.example.test",
TJWATER_AGENT_API_BASE_URL: "https://agent.example.test", TJWATER_AGENT_API_BASE_URL: "https://agent.example.test",
TJWATER_ENABLE_DEV_PANEL: "true", TJWATER_ENABLE_DEV_PANEL: "true",
TJWATER_ENABLE_MSW: "false" TJWATER_ENABLE_MSW: "false"
@@ -23,6 +24,7 @@ describe("runtime frontend configuration", () => {
TJWATER_MAPBOX_ACCESS_TOKEN: "token", TJWATER_MAPBOX_ACCESS_TOKEN: "token",
TJWATER_MAP_URL: "https://maps.example.test/geoserver", TJWATER_MAP_URL: "https://maps.example.test/geoserver",
TJWATER_GEOSERVER_WORKSPACE: "project-a", TJWATER_GEOSERVER_WORKSPACE: "project-a",
TJWATER_SERVER_API_BASE_URL: "https://server.example.test",
TJWATER_AGENT_API_BASE_URL: "https://agent.example.test", TJWATER_AGENT_API_BASE_URL: "https://agent.example.test",
TJWATER_ENABLE_DEV_PANEL: true, TJWATER_ENABLE_DEV_PANEL: true,
TJWATER_ENABLE_MSW: false TJWATER_ENABLE_MSW: false
+2 -1
View File
@@ -47,7 +47,8 @@ const runtimeConfigSchema = z
TJWATER_KEYCLOAK_CLIENT_ID: z.string().trim().default("next-tjwater"), TJWATER_KEYCLOAK_CLIENT_ID: z.string().trim().default("next-tjwater"),
TJWATER_MAPBOX_ACCESS_TOKEN: z.string().default(""), TJWATER_MAPBOX_ACCESS_TOKEN: z.string().default(""),
TJWATER_MAP_URL: browserHttpUrl.default("https://geoserver.waternetwork.cn/geoserver"), TJWATER_MAP_URL: browserHttpUrl.default("https://geoserver.waternetwork.cn/geoserver"),
TJWATER_GEOSERVER_WORKSPACE: z.string().trim().min(1).default("tjwater"), TJWATER_GEOSERVER_WORKSPACE: z.string().trim().min(1).default("tjwater_next"),
TJWATER_SERVER_API_BASE_URL: browserHttpUrl.default("http://127.0.0.1:8000"),
TJWATER_AGENT_API_BASE_URL: browserHttpUrl.default("http://127.0.0.1:8787"), TJWATER_AGENT_API_BASE_URL: browserHttpUrl.default("http://127.0.0.1:8787"),
TJWATER_ENABLE_DEV_PANEL: runtimeBoolean(false), TJWATER_ENABLE_DEV_PANEL: runtimeBoolean(false),
TJWATER_ENABLE_MSW: runtimeBoolean(false) TJWATER_ENABLE_MSW: runtimeBoolean(false)
+63 -1
View File
@@ -1,4 +1,5 @@
import { expect, test, type Page } from "@playwright/test"; import { expect, test, type Page } from "@playwright/test";
import { mockScadaApi } from "./support/mock-scada-api";
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
await prepareWorkbench(page); await prepareWorkbench(page);
@@ -113,6 +114,65 @@ test("mobile launchers and map dock keep non-overlapping touch targets", async (
expect(rectangles[1]!.x + rectangles[1]!.width).toBeLessThan(rectangles[2]!.x); expect(rectangles[1]!.x + rectangles[1]!.width).toBeLessThan(rectangles[2]!.x);
}); });
test("mobile timeline only offers collapse after opening its detail sheet", async ({ page }) => {
await page.setViewportSize({ width: 375, height: 812 });
await page.goto("/", { waitUntil: "domcontentloaded" });
const timelineSummary = page.getByRole("region", { name: "移动端运行时间轴摘要" });
await expect(timelineSummary).toBeVisible();
await expect(timelineSummary.getByRole("button", { name: "隐藏运行时间轴" })).toHaveCount(0);
await timelineSummary.getByRole("button", { name: "打开完整运行时间轴" }).click();
const timelineSheet = page.getByRole("region", { name: "运行时间轴抽屉" });
await expect(timelineSheet).toBeVisible();
await timelineSheet.getByRole("button", { name: "收起时间轴" }).click();
await expect(timelineSheet).toBeHidden();
await expect(timelineSummary).toBeVisible();
});
test("mobile map tool panel stays above the bottom dock", async ({ page }) => {
await page.setViewportSize({ width: 375, height: 812 });
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: "更多:更多地图操作" }).click();
const toolPanel = page.getByRole("region", { name: "更多" });
const mapDock = page.locator('nav[aria-label="地图工具"]:visible');
await expect(toolPanel).toBeVisible();
await expect
.poll(async () => {
const [panelBox, dockBox] = await Promise.all([
toolPanel.boundingBox(),
mapDock.boundingBox()
]);
if (!panelBox || !dockBox) return Number.POSITIVE_INFINITY;
return Math.round(panelBox.y + panelBox.height - dockBox.y);
})
.toBeLessThanOrEqual(-8);
});
test("mobile map zoom stays above the bottom dock", async ({ page }) => {
await page.setViewportSize({ width: 375, height: 812 });
await page.goto("/", { waitUntil: "domcontentloaded" });
const mapZoom = page.getByRole("group", { name: "地图缩放" });
const mapDock = page.locator('nav[aria-label="地图工具"]:visible');
await expect(mapZoom).toBeVisible();
await expect
.poll(async () => {
const [zoomBox, dockBox] = await Promise.all([
mapZoom.boundingBox(),
mapDock.boundingBox()
]);
if (!zoomBox || !dockBox) return Number.POSITIVE_INFINITY;
return Math.round(zoomBox.y + zoomBox.height - dockBox.y);
})
.toBeLessThanOrEqual(-8);
const zoomBox = await mapZoom.boundingBox();
expect(Math.round(375 - zoomBox!.x - zoomBox!.width)).toBe(12);
});
test("Agent content buttons do not gain a border while pressed", async ({ page }) => { test("Agent content buttons do not gain a border while pressed", async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 900 }); await page.setViewportSize({ width: 1440, height: 900 });
await page.goto("/", { waitUntil: "domcontentloaded" }); await page.goto("/", { waitUntil: "domcontentloaded" });
@@ -154,7 +214,8 @@ async function prepareWorkbench(page: Page) {
TJWATER_AUTH_MODE: "disabled", TJWATER_AUTH_MODE: "disabled",
TJWATER_MAPBOX_ACCESS_TOKEN: "", TJWATER_MAPBOX_ACCESS_TOKEN: "",
TJWATER_MAP_URL: "https://button-system.invalid/geoserver", TJWATER_MAP_URL: "https://button-system.invalid/geoserver",
TJWATER_GEOSERVER_WORKSPACE: "tjwater", TJWATER_GEOSERVER_WORKSPACE: "tjwater_next",
TJWATER_SERVER_API_BASE_URL: "https://tjwater-api.invalid",
TJWATER_AGENT_API_BASE_URL: "http://127.0.0.1:8787", TJWATER_AGENT_API_BASE_URL: "http://127.0.0.1:8787",
TJWATER_ENABLE_DEV_PANEL: "false", TJWATER_ENABLE_DEV_PANEL: "false",
TJWATER_ENABLE_MSW: "false" TJWATER_ENABLE_MSW: "false"
@@ -170,4 +231,5 @@ async function prepareWorkbench(page: Page) {
await page.route("**/*.riv", async (route) => { await page.route("**/*.riv", async (route) => {
await route.abort(); await route.abort();
}); });
await mockScadaApi(page);
} }
+53
View File
@@ -0,0 +1,53 @@
import { expect, test } from "@playwright/test";
import { mockScadaApi } from "./support/mock-scada-api";
test("loads SCADA from the backend API and aligns map features by device_id", async ({ page }) => {
await page.route("**/runtime-config.js", async (route) => {
await route.fulfill({
contentType: "application/javascript",
body: `globalThis.__TJWATER_CONFIG__ = {
TJWATER_AUTH_MODE: "disabled",
TJWATER_MAPBOX_ACCESS_TOKEN: "",
TJWATER_MAP_URL: "https://scada-map.invalid/geoserver",
TJWATER_GEOSERVER_WORKSPACE: "tjwater_next",
TJWATER_SERVER_API_BASE_URL: "https://tjwater-api.invalid",
TJWATER_AGENT_API_BASE_URL: "http://127.0.0.1:8787",
TJWATER_ENABLE_DEV_PANEL: "false",
TJWATER_ENABLE_MSW: "false"
};`
});
});
await page.route("https://scada-map.invalid/**", async (route) => {
await route.fulfill({ status: 204, body: "" });
});
await mockScadaApi(page, [{
device_id: "SCADA-1",
device_type: "pressure",
node_id: "J-1",
link_id: null,
api_query_id: "query-1",
transmission_mode: "realtime",
transmission_frequency: "2s",
reliability: 100,
x: 1,
y: 2,
longitude: 121.5,
latitude: 30.9
}]);
const deviceRequestPromise = page.waitForRequest((request) =>
request.url().includes("/api/v1/scada-devices")
);
await page.goto("/", { waitUntil: "domcontentloaded" });
const deviceRequest = await deviceRequestPromise;
expect(deviceRequest.headers()["x-project-id"]).toBe("project-1");
await expect.poll(async () => page.evaluate(() => {
const map = globalThis.__waterNetworkMap;
if (!map) return [];
return map.querySourceFeatures("scada").map((feature) => ({
id: String(feature.id),
deviceId: String(feature.properties?.device_id)
}));
})).toContainEqual({ id: "SCADA-1", deviceId: "SCADA-1" });
});
+27
View File
@@ -0,0 +1,27 @@
import type { Page } from "@playwright/test";
export async function mockScadaApi(page: Page, devices: unknown[] = []) {
await page.route("https://tjwater-api.invalid/**", async (route) => {
const url = new URL(route.request().url());
if (url.pathname === "/api/v1/projects") {
await route.fulfill({
contentType: "application/json",
body: JSON.stringify({
items: [{ project_id: "project-1", gs_workspace: "tjwater_next", status: "active" }],
total: 1,
limit: 1000,
offset: 0
})
});
return;
}
if (url.pathname === "/api/v1/scada-devices") {
await route.fulfill({
contentType: "application/json",
body: JSON.stringify({ items: devices, total: devices.length, limit: 1000, offset: 0 })
});
return;
}
await route.fulfill({ status: 404, contentType: "application/json", body: "{}" });
});
}
+4 -1
View File
@@ -1,5 +1,6 @@
import { expect, test, type Page } from "@playwright/test"; import { expect, test, type Page } from "@playwright/test";
import { mockAgentApi } from "./support/mock-agent-api"; import { mockAgentApi } from "./support/mock-agent-api";
import { mockScadaApi } from "./support/mock-scada-api";
const EMPTY_RASTER_TILE = Buffer.from( const EMPTY_RASTER_TILE = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=", "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
@@ -120,7 +121,8 @@ async function prepareDeterministicWorkbench(page: Page) {
TJWATER_AUTH_MODE: "disabled", TJWATER_AUTH_MODE: "disabled",
TJWATER_MAPBOX_ACCESS_TOKEN: "visual-test-token", TJWATER_MAPBOX_ACCESS_TOKEN: "visual-test-token",
TJWATER_MAP_URL: "https://visual-map.invalid/geoserver", TJWATER_MAP_URL: "https://visual-map.invalid/geoserver",
TJWATER_GEOSERVER_WORKSPACE: "tjwater", TJWATER_GEOSERVER_WORKSPACE: "tjwater_next",
TJWATER_SERVER_API_BASE_URL: "https://tjwater-api.invalid",
TJWATER_AGENT_API_BASE_URL: "http://127.0.0.1:8787", TJWATER_AGENT_API_BASE_URL: "http://127.0.0.1:8787",
TJWATER_ENABLE_DEV_PANEL: "false", TJWATER_ENABLE_DEV_PANEL: "false",
TJWATER_ENABLE_MSW: "false" TJWATER_ENABLE_MSW: "false"
@@ -142,6 +144,7 @@ async function prepareDeterministicWorkbench(page: Page) {
await page.route("**/*.riv", async (route) => { await page.route("**/*.riv", async (route) => {
await route.abort(); await route.abort();
}); });
await mockScadaApi(page);
} }
async function openWorkbench(page: Page) { async function openWorkbench(page: Page) {
+4 -1
View File
@@ -1,5 +1,6 @@
import { expect, test, type Page } from "@playwright/test"; import { expect, test, type Page } from "@playwright/test";
import { mockAgentApi } from "./support/mock-agent-api"; import { mockAgentApi } from "./support/mock-agent-api";
import { mockScadaApi } from "./support/mock-scada-api";
test("the default workspace docks Agent beside the persistent map", async ({ page }) => { test("the default workspace docks Agent beside the persistent map", async ({ page }) => {
await page.setViewportSize({ width: 1600, height: 900 }); await page.setViewportSize({ width: 1600, height: 900 });
@@ -138,7 +139,8 @@ async function prepareMainFrame(page: Page) {
TJWATER_AUTH_MODE: "disabled", TJWATER_AUTH_MODE: "disabled",
TJWATER_MAPBOX_ACCESS_TOKEN: "", TJWATER_MAPBOX_ACCESS_TOKEN: "",
TJWATER_MAP_URL: "https://workspace-map.invalid/geoserver", TJWATER_MAP_URL: "https://workspace-map.invalid/geoserver",
TJWATER_GEOSERVER_WORKSPACE: "tjwater", TJWATER_GEOSERVER_WORKSPACE: "tjwater_next",
TJWATER_SERVER_API_BASE_URL: "https://tjwater-api.invalid",
TJWATER_AGENT_API_BASE_URL: "http://127.0.0.1:8787", TJWATER_AGENT_API_BASE_URL: "http://127.0.0.1:8787",
TJWATER_ENABLE_DEV_PANEL: "true", TJWATER_ENABLE_DEV_PANEL: "true",
TJWATER_ENABLE_MSW: "false" TJWATER_ENABLE_MSW: "false"
@@ -148,4 +150,5 @@ async function prepareMainFrame(page: Page) {
await page.route("https://workspace-map.invalid/**", async (route) => { await page.route("https://workspace-map.invalid/**", async (route) => {
await route.fulfill({ status: 204 }); await route.fulfill({ status: 204 });
}); });
await mockScadaApi(page);
} }
+2 -1
View File
@@ -13,7 +13,8 @@ function renderRuntimeConfig(values: Record<string, string>) {
TJWATER_KEYCLOAK_CLIENT_ID: values.TJWATER_KEYCLOAK_CLIENT_ID || "next-tjwater", TJWATER_KEYCLOAK_CLIENT_ID: values.TJWATER_KEYCLOAK_CLIENT_ID || "next-tjwater",
TJWATER_MAPBOX_ACCESS_TOKEN: values.TJWATER_MAPBOX_ACCESS_TOKEN || "", TJWATER_MAPBOX_ACCESS_TOKEN: values.TJWATER_MAPBOX_ACCESS_TOKEN || "",
TJWATER_MAP_URL: values.TJWATER_MAP_URL || "https://geoserver.waternetwork.cn/geoserver", TJWATER_MAP_URL: values.TJWATER_MAP_URL || "https://geoserver.waternetwork.cn/geoserver",
TJWATER_GEOSERVER_WORKSPACE: values.TJWATER_GEOSERVER_WORKSPACE || "tjwater", TJWATER_GEOSERVER_WORKSPACE: values.TJWATER_GEOSERVER_WORKSPACE || "tjwater_next",
TJWATER_SERVER_API_BASE_URL: values.TJWATER_SERVER_API_BASE_URL || "http://127.0.0.1:8000",
TJWATER_AGENT_API_BASE_URL: values.TJWATER_AGENT_API_BASE_URL || "http://127.0.0.1:8787", TJWATER_AGENT_API_BASE_URL: values.TJWATER_AGENT_API_BASE_URL || "http://127.0.0.1:8787",
TJWATER_ENABLE_DEV_PANEL: values.TJWATER_ENABLE_DEV_PANEL || "false", TJWATER_ENABLE_DEV_PANEL: values.TJWATER_ENABLE_DEV_PANEL || "false",
TJWATER_ENABLE_MSW: values.TJWATER_ENABLE_MSW || "false" TJWATER_ENABLE_MSW: values.TJWATER_ENABLE_MSW || "false"