17 Commits
Author SHA1 Message Date
jiang bf44cc0f25 fix: synchronize dynamic GeoServer layer lifecycle
Generic Container CI/CD / test-build-publish (push) Successful in 59s
Frontend CI/CD / build-test-publish-and-deploy (push) Successful in 59s
Optional sources became dynamic while dependent labels and controller state remained static, allowing stale requests to restore removed layers. Register dependent layers through the same lifecycle and invalidate target request generations before release.
2026-09-11 18:25:41 +08:00
jiang 31acd33a2e fix: probe all non-core GeoServer layers
Extend availability checks beyond pumps and tanks so unpublished valve and reservoir layers cannot leave stale controls.
2026-09-11 17:00:54 +08:00
jiang cf699d6207 fix: hide unavailable GeoServer layers 2026-09-11 16:40:24 +08:00
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
jiang abef9ffabf fix: bypass blocked Keycloak session iframe
Generic Container CI/CD / test-build-publish (push) Successful in 1m1s
Frontend CI/CD / build-test-publish-and-deploy (push) Successful in 1m31s
2026-08-19 18:12:21 +08:00
jiang db696eb4f1 test: align collapsed agent camera padding
Generic Container CI/CD / test-build-publish (push) Successful in 2m9s
Frontend CI/CD / build-test-publish-and-deploy (push) Successful in 2m40s
2026-08-19 18:00:40 +08:00
jiang 229cb0def2 fix: animate analysis workspace transitions 2026-08-19 17:54:37 +08:00
jiang 1fb950806a fix: unify analysis workspace controls 2026-08-19 17:43:14 +08:00
jiang 073832034a fix: restyle analysis return control 2026-08-19 17:33:19 +08:00
jiang 57c494e8bd refactor: simplify evidence workspace presentation 2026-08-19 17:31:21 +08:00
jiang 4a589d7a43 fix: refine evidence detail presentation 2026-08-19 17:28:21 +08:00
jiang d39e58196f fix: tighten timeline lane spacing 2026-08-19 17:21:41 +08:00
jiang b93999646c fix: simplify timeline legend controls 2026-08-19 17:15:48 +08:00
jiang b91be09f5c fix: distinguish timeline attention events 2026-08-19 17:12:13 +08:00
jiang 602b39d60a feat: add interactive timeline legend 2026-08-19 17:07:22 +08:00
jiang 5e7101bfee fix: default Agent panel to collapsed 2026-08-19 17:00:43 +08:00
60 changed files with 2803 additions and 597 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_MAPBOX_ACCESS_TOKEN=
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_ENABLE_DEV_PANEL=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_MAPBOX_ACCESS_TOKEN` | 空 | Mapbox 底图访问令牌 |
| `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_ENABLE_DEV_PANEL` | `false` | 是否显示开发面板 |
| `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 只配置实际使用的前端地址。
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 mapboxAccessToken "${TJWATER_MAPBOX_ACCESS_TOKEN:-}" \
--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 enableDevPanel "${TJWATER_ENABLE_DEV_PANEL:-false}" \
--arg enableMsw "${TJWATER_ENABLE_MSW:-false}" \
@@ -18,6 +19,7 @@ runtime_config=$(jq -cn \
TJWATER_MAPBOX_ACCESS_TOKEN: $mapboxAccessToken,
TJWATER_MAP_URL: $mapUrl,
TJWATER_GEOSERVER_WORKSPACE: $geoserverWorkspace,
TJWATER_SERVER_API_BASE_URL: $serverApiBaseUrl,
TJWATER_AGENT_API_BASE_URL: $agentApiBaseUrl,
TJWATER_ENABLE_DEV_PANEL: $enableDevPanel,
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__ = {
TJWATER_AUTH_MODE: "required",
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({
TJWATER_MAP_URL: 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)
});
expect(Object.keys(runtimeConfig as Record<string, unknown>)).not.toContainEqual(
@@ -62,7 +62,7 @@ function createRequest(name: string) {
toolCallId: "call-1",
sessionId: "session-1",
name,
params: { items: [{ sensor_id: "MP01", level: "high" }] },
params: { items: [{ device_id: "MP01", level: "high" }] },
issuedAt: Date.now(),
expiresAt: Date.now() + 15_000
};
@@ -74,7 +74,7 @@ describe("toTrustedMapAction", () => {
expect(toTrustedMapAction("locate_pipes", { pipe_ids: "P1,P2" })).toEqual({
type: "locate_features",
featureIds: ["P1", "P2"],
layer: "geo_pipes_mat",
layer: "pipes",
fallbackText: undefined
});
});
@@ -83,7 +83,7 @@ describe("toTrustedMapAction", () => {
expect(toTrustedMapAction("locate_junctions", { junction_id: 42 })).toMatchObject({
type: "locate_features",
featureIds: ["42"],
layer: "geo_junctions_mat"
layer: "junctions"
});
});
@@ -97,7 +97,7 @@ describe("toTrustedMapAction", () => {
expect(toTrustedMapAction("locate_scada", { scada_id: "S-1" })).toMatchObject({
type: "locate_features",
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> = {
locate_junctions: "geo_junctions_mat",
locate_pipes: "geo_pipes_mat",
locate_valves: "geo_valves",
locate_reservoirs: "geo_reservoirs",
locate_scada: "geo_scada",
locate_pumps: "geo_pumps",
locate_tanks: "geo_tanks"
locate_junctions: "junctions",
locate_pipes: "pipes",
locate_valves: "valves",
locate_reservoirs: "reservoirs",
locate_scada: "scada",
locate_pumps: "pumps",
locate_tanks: "tanks"
};
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_GROUP_IDS = new Set([...SUPPLY_SOURCE_IDS, "simulation"]);
const ALLOWED_LOCATE_LAYERS = new Set([
...SUPPLY_SOURCE_IDS,
"pumps",
"tanks",
"geo_junctions_mat",
"geo_pipes_mat",
"geo_valves",
"geo_reservoirs",
"geo_scada",
"geo_pumps",
"geo_tanks"
...SUPPLY_SOURCE_IDS
]);
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: "节点",
valves: "阀门",
reservoirs: "水库",
scada: "SCADA"
scada: "SCADA",
pumps: "水泵",
tanks: "水箱"
};
const STYLE_GROUP_IDS = Object.keys(LAYER_GROUP_GEOMETRIES) as LayerGroupId[];
@@ -63,7 +65,7 @@ const LABEL_PRESETS: Record<
unit: "m",
options: [
{ value: "elevation", label: "高程 elevation" },
{ value: "demand", label: "需水量 demand" }
{ value: "base_demand", label: "基础需水量 base_demand" }
]
},
valves: {
@@ -88,6 +90,9 @@ const ERROR_LABELS: Record<string, string> = {
MAP_NOT_READY: "地图尚未就绪",
FEATURE_NOT_FOUND: "目标要素不存在",
WFS_UNAVAILABLE: "WFS 查询暂不可用",
SCADA_API_UNAVAILABLE: "SCADA API 查询暂不可用",
SCADA_FEATURES_NOT_FOUND: "未找到对应的 SCADA 设备",
ACTION_SUPERSEDED: "SCADA 地图动作已被新请求替代",
FLOW_UNAVAILABLE: "水流图层初始化失败",
INVALID_FLOW_CALCULATION: "管道 ID 或有符号流速无效",
INVALID_STYLE_PATCH: "参数不符合受控范围"
@@ -7,7 +7,7 @@ import {
import { INTERACTIVE_HIT_LAYER_IDS } from "../map/layers";
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", () => {
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() };
setMapFeatureInteractionState(map, feature, { selected: true, hovered: false });
expect(map.setFeatureState).toHaveBeenCalledWith(
{ source: "junctions", sourceLayer: "geo_junctions_mat", id: "junction-7" },
{ source: "junctions", sourceLayer: "junctions", id: "junction-7" },
{ selected: true, hovered: false }
);
});
@@ -27,7 +27,7 @@ describe("map feature interaction state", () => {
const map = { setFeatureState: vi.fn(), removeFeatureState: vi.fn() };
clearMapFeatureInteractionState(map, feature, "selected");
expect(map.removeFeatureState).toHaveBeenCalledWith(
{ source: "junctions", sourceLayer: "geo_junctions_mat", id: "junction-7" },
{ source: "junctions", sourceLayer: "junctions", id: "junction-7" },
"selected"
);
});
@@ -38,6 +38,8 @@ describe("map feature interaction state", () => {
"junctions-hit",
"valves-hit",
"reservoirs-hit",
"pumps-hit",
"tanks-hit",
"scada-hit"
]);
});
@@ -12,7 +12,7 @@ import { SOURCE_LAYERS, type WaterNetworkSourceId } from "../map/sources";
export type MapFeatureInteractionState = {
source: WaterNetworkSourceId;
sourceLayer: string;
sourceLayer?: string;
id: string;
hovered: boolean;
selected: boolean;
@@ -25,7 +25,9 @@ export function toMapFeatureReference(
feature: Pick<DetailFeature, "id" | "layer"> | null
): FeatureReference | 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(
@@ -33,10 +35,7 @@ export function setMapFeatureInteractionState(
feature: FeatureReference,
state: Partial<Pick<MapFeatureInteractionState, "hovered" | "selected">>
) {
map.setFeatureState(
{ source: feature.source, sourceLayer: feature.sourceLayer, id: feature.id },
state
);
map.setFeatureState(toFeatureStateTarget(feature), state);
}
export function clearMapFeatureInteractionState(
@@ -44,13 +43,11 @@ export function clearMapFeatureInteractionState(
feature: FeatureReference,
key?: "hovered" | "selected"
) {
map.removeFeatureState(
{ source: feature.source, sourceLayer: feature.sourceLayer, id: feature.id },
key
);
map.removeFeatureState(toFeatureStateTarget(feature), key);
}
type UseMapInteractionsOptions = {
availableSourceIds: readonly WaterNetworkSourceId[];
mapRef: RefObject<MapLibreMap | null>;
mapReady: boolean;
onSelectFeature: (feature: DetailFeature) => void;
@@ -58,6 +55,7 @@ type UseMapInteractionsOptions = {
};
export function useMapInteractions({
availableSourceIds,
mapRef,
mapReady,
onSelectFeature,
@@ -81,7 +79,9 @@ export function useMapInteractions({
const id = feature && getFeatureId(feature);
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) {
clearHoveredFeature();
return;
@@ -104,7 +104,7 @@ export function useMapInteractions({
if (feature) onSelectFeature(toDetailFeature(feature));
};
const hitLayerIds = [...INTERACTIVE_HIT_LAYER_IDS];
const hitLayerIds = INTERACTIVE_HIT_LAYER_IDS.filter((layerId) => map.getLayer(layerId));
map.on("mousemove", hitLayerIds, handleMouseMove);
map.on("mouseleave", hitLayerIds, handleMouseLeave);
map.on("click", hitLayerIds, handleClick);
@@ -115,15 +115,25 @@ export function useMapInteractions({
map.off("mouseleave", hitLayerIds, handleMouseLeave);
map.off("click", hitLayerIds, handleClick);
};
}, [mapRef, mapReady, onSelectFeature, selectedFeature]);
}, [availableSourceIds, mapRef, mapReady, onSelectFeature, selectedFeature]);
useEffect(() => {
const map = mapRef.current;
if (!mapReady || !map) return;
const next = toMapFeatureReference(selectedFeature);
if (next) setMapFeatureInteractionState(map, next, { selected: true, hovered: false });
if (next && map.getSource(next.source)) {
setMapFeatureInteractionState(map, next, { selected: true, hovered: false });
}
return () => {
if (next) clearMapFeatureInteractionState(map, next, "selected");
if (next && map.getSource(next.source)) {
clearMapFeatureInteractionState(map, next, "selected");
}
};
}, [mapReady, mapRef, selectedFeature]);
}, [availableSourceIds, 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 };
}
@@ -69,7 +69,7 @@ export function useWorkbenchAgent({
const frontendActionEnabledRef = useRef(false);
const onFrontendActionRef = useRef(onFrontendAction);
const processedEnvelopeIdsRef = useRef(new Set<string>());
const [panelOpen, setPanelOpen] = useState(true);
const [panelOpen, setPanelOpen] = useState(false);
const [panelCollapsing, setPanelCollapsing] = useState(false);
const [mobileOpen, setMobileOpen] = useState(false);
const [mobilePanelCollapsing, setMobilePanelCollapsing] = useState(false);
@@ -1,6 +1,9 @@
import type { FeatureCollection, Point } from "geojson";
import type { Map as MapLibreMap } from "maplibre-gl";
import type { ScadaDevice } from "../api/scada-client";
import { useEffect, useRef, useSyncExternalStore, type RefObject } from "react";
import { getResponsiveWorkbenchPadding } from "../map/camera";
import type { WaterNetworkSourceId } from "../map/sources";
import { WorkbenchMapController } from "../map/workbench-map-controller";
export function useWorkbenchMapController({
@@ -9,7 +12,9 @@ export function useWorkbenchMapController({
leftPanelOpen,
rightPanelOpen,
conditionPanelExpanded = false,
agentPanelWidth
agentPanelWidth,
getScadaFeatures,
availableSourceIds
}: {
mapRef: RefObject<MapLibreMap | null>;
mapReady: boolean;
@@ -17,6 +22,8 @@ export function useWorkbenchMapController({
rightPanelOpen: boolean;
conditionPanelExpanded?: boolean;
agentPanelWidth?: number;
getScadaFeatures?: (deviceIds?: string[], signal?: AbortSignal) => Promise<FeatureCollection<Point, ScadaDevice>>;
availableSourceIds?: readonly WaterNetworkSourceId[];
}) {
const valuesRef = useRef({
mapReady,
@@ -48,7 +55,8 @@ export function useWorkbenchMapController({
valuesRef.current.agentPanelWidth
)
: { top: 48, right: 48, bottom: 48, left: 48 };
}
},
getScadaFeatures
});
}
@@ -58,6 +66,9 @@ export function useWorkbenchMapController({
controller.getSnapshot,
controller.getSnapshot
);
useEffect(() => {
if (availableSourceIds) controller.syncAvailableSources(availableSourceIds);
}, [availableSourceIds, controller]);
useEffect(() => () => controller.destroy(), [controller]);
return { controller, state };
}
+302 -13
View File
@@ -1,8 +1,16 @@
import "maplibre-gl/dist/maplibre-gl.css";
import maplibregl, { type Map as MapLibreMap, type MapSourceDataEvent } from "maplibre-gl";
import { useEffect, useRef, useState, type RefObject } from "react";
import type { FeatureCollection, Point } from "geojson";
import maplibregl, {
type GeoJSONSource,
type Map as MapLibreMap,
type MapSourceDataEvent,
type StyleSpecification
} 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 { fetchScadaDeviceCollection, type ScadaDevice } from "../api/scada-client";
import type { DetailFeature } from "../types";
import {
SIMULATION_SOURCE_IDS,
@@ -11,20 +19,30 @@ import {
} from "../map/annotation-layers";
import { fitNetworkBounds } from "../map/camera";
import {
filterWaterNetworkLayersBySourceIds,
waterNetworkBusinessLayers,
waterNetworkHitLayers,
waterNetworkInteractionLayers
} from "../map/layers";
import {
DATA_DEPENDENT_GEOSERVER_SOURCE_IDS,
REQUIRED_GEOSERVER_SOURCE_IDS,
resolveGeoServerLayerAvailability
} from "../map/geoserver-layer-availability";
import { MAP_MAX_ZOOM } from "../map/map-layer-visuals";
import { createValueLabelLayers } from "../map/value-label";
import { setSimulationLayersVisibility } from "../map/simulation-layers";
import {
AVAILABLE_WATER_NETWORK_SOURCE_IDS,
GEOSERVER_WATER_NETWORK_SOURCE_IDS,
createBaseStyle,
createWaterNetworkSources,
WATER_NETWORK_GLOBAL_VIEW
WATER_NETWORK_GLOBAL_VIEW,
type GeoServerWaterNetworkSourceId,
type WaterNetworkSourceId
} from "../map/sources";
import {
SCADA_SOURCE_ID,
registerScadaImages,
scadaBusinessLayers,
scadaFallbackBusinessLayers,
@@ -44,8 +62,11 @@ declare global {
type UseWorkbenchMapOptions = {
containerRef: RefObject<HTMLDivElement | null>;
impactVisible: boolean;
layerVisibility: Record<string, boolean>;
onClearSelection: () => void;
onSelectFeature: (feature: DetailFeature) => void;
selectedFeature: DetailFeature | null;
getAccessToken?: AccessTokenProvider;
};
type UseWorkbenchMapResult = {
@@ -53,6 +74,9 @@ type UseWorkbenchMapResult = {
mapReady: boolean;
mapError: string | null;
sourceStatuses: WorkbenchSourceStatus[];
availableSourceIds: WaterNetworkSourceId[];
getScadaFeatures: (deviceIds?: string[], signal?: AbortSignal) => Promise<FeatureCollection<Point, ScadaDevice>>;
refreshGeoServerLayers: () => boolean;
fitNetworkBounds: () => void;
};
@@ -68,6 +92,8 @@ export type WorkbenchSourceStatus = {
const SOURCE_STATUS_LABELS: Record<string, string> = {
"mapbox-base": "Mapbox 底图",
"geoserver-mvt": "GeoServer MVT",
"geoserver-catalog": "GeoServer 图层目录",
"scada-api": "SCADA API",
"scada-icons": "SCADA 图标",
"annotation-source": "业务标注源"
};
@@ -76,34 +102,134 @@ const SOURCE_GROUP_BY_ID: Record<string, string> = {
"mapbox-light": "mapbox-base",
"mapbox-satellite": "mapbox-base",
...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.annotations]: "annotation-source"
};
const GEOSERVER_LAYER_PROBE_TIMEOUT_MS = 8_000;
export function useWorkbenchMap({
containerRef,
impactVisible,
layerVisibility,
onClearSelection,
onSelectFeature,
selectedFeature
selectedFeature,
getAccessToken
}: UseWorkbenchMapOptions): UseWorkbenchMapResult {
const mapRef = useRef<MapLibreMap | null>(null);
const impactVisibleRef = useRef(impactVisible);
const [mapReady, setMapReady] = useState(false);
const [mapError, setMapError] = useState<string | null>(null);
const [sourceStatuses, setSourceStatuses] = useState<Record<string, WorkbenchSourceStatus>>({});
const [availableGeoServerSourceIds, setAvailableGeoServerSourceIds] = useState<
GeoServerWaterNetworkSourceId[]
>(() => [...REQUIRED_GEOSERVER_SOURCE_IDS]);
const [geoServerLayerRefreshRevision, setGeoServerLayerRefreshRevision] = useState(0);
const geoServerLayerProbeInFlightRef = useRef(false);
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)))
};
}, []);
const refreshGeoServerLayers = useCallback(() => {
if (geoServerLayerProbeInFlightRef.current) return false;
geoServerLayerProbeInFlightRef.current = true;
setGeoServerLayerRefreshRevision((revision) => revision + 1);
return true;
}, []);
useEffect(() => {
const lifecycleAbortController = new AbortController();
const requestAbortController = new AbortController();
const probeTimeout = window.setTimeout(
() => requestAbortController.abort(),
GEOSERVER_LAYER_PROBE_TIMEOUT_MS
);
geoServerLayerProbeInFlightRef.current = true;
void resolveGeoServerLayerAvailability(requestAbortController.signal)
.then((availability) => {
if (lifecycleAbortController.signal.aborted) return;
setAvailableGeoServerSourceIds((current) =>
current?.join("|") === availability.availableSourceIds.join("|")
? current
: availability.availableSourceIds
);
if (availability.failedSourceIds.length > 0) {
updateSourceStatus(
setSourceStatuses,
"geoserver-catalog",
"degraded",
`无法确认 ${availability.failedSourceIds.join("、")} 图层是否有数据,已隐藏对应入口。`
);
} else {
clearSourceStatus(setSourceStatuses, "geoserver-catalog");
}
})
.finally(() => {
window.clearTimeout(probeTimeout);
if (!lifecycleAbortController.signal.aborted) {
geoServerLayerProbeInFlightRef.current = false;
}
});
return () => {
lifecycleAbortController.abort();
requestAbortController.abort();
geoServerLayerProbeInFlightRef.current = false;
window.clearTimeout(probeTimeout);
};
}, [geoServerLayerRefreshRevision]);
useEffect(() => {
impactVisibleRef.current = impactVisible;
}, [impactVisible]);
useEffect(() => {
if (
selectedFeature &&
DATA_DEPENDENT_GEOSERVER_SOURCE_IDS.includes(
selectedFeature.layer as (typeof DATA_DEPENDENT_GEOSERVER_SOURCE_IDS)[number]
) &&
!availableGeoServerSourceIds.includes(selectedFeature.layer as GeoServerWaterNetworkSourceId)
) {
onClearSelection();
}
}, [availableGeoServerSourceIds, onClearSelection, selectedFeature]);
useEffect(() => {
if (!containerRef.current || mapRef.current) {
return;
}
setMapReady(false);
setMapError(null);
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({
container: containerRef.current,
style: createBaseStyle(mapboxToken),
@@ -118,10 +244,14 @@ export function useWorkbenchMap({
map.on("load", async () => {
map.resize();
const sources = createWaterNetworkSources();
AVAILABLE_WATER_NETWORK_SOURCE_IDS.forEach((sourceId) =>
map.addSource(sourceId, sources[sourceId])
(map.getSource(SCADA_SOURCE_ID) as GeoJSONSource | undefined)?.setData(
scadaCollectionRef.current
);
const sources = createWaterNetworkSources(REQUIRED_GEOSERVER_SOURCE_IDS);
REQUIRED_GEOSERVER_SOURCE_IDS.forEach((sourceId) => {
const source = sources[sourceId];
if (source) map.addSource(sourceId, source);
});
map.addSource(SIMULATION_SOURCE_IDS.impactArea, simulationSources.impactArea);
map.addSource(SIMULATION_SOURCE_IDS.annotations, simulationSources.annotations);
await registerSupplyAssetImages(map);
@@ -133,18 +263,30 @@ export function useWorkbenchMap({
simulationAnnotationLayers
.filter((layer) => layer.id === "simulation-impact-fill")
.forEach((layer) => map.addLayer(layer));
waterNetworkBusinessLayers.forEach((layer) => map.addLayer(layer));
filterWaterNetworkLayersBySourceIds(
waterNetworkBusinessLayers,
REQUIRED_GEOSERVER_SOURCE_IDS
).forEach((layer) => map.addLayer(layer));
activeScadaBusinessLayers.forEach((layer) => map.addLayer(layer));
simulationAnnotationLayers
.filter((layer) => layer.type !== "symbol" && layer.id !== "simulation-impact-fill")
.forEach((layer) => map.addLayer(layer));
waterNetworkInteractionLayers.forEach((layer) => map.addLayer(layer));
filterWaterNetworkLayersBySourceIds(
waterNetworkInteractionLayers,
REQUIRED_GEOSERVER_SOURCE_IDS
).forEach((layer) => map.addLayer(layer));
scadaInteractionLayers.forEach((layer) => map.addLayer(layer));
createValueLabelLayers().forEach((layer) => map.addLayer(layer));
filterWaterNetworkLayersBySourceIds(
createValueLabelLayers(),
REQUIRED_GEOSERVER_SOURCE_IDS
).forEach((layer) => map.addLayer(layer));
simulationAnnotationLayers
.filter((layer) => layer.type === "symbol")
.forEach((layer) => map.addLayer(layer));
waterNetworkHitLayers.forEach((layer) => map.addLayer(layer));
filterWaterNetworkLayersBySourceIds(
waterNetworkHitLayers,
REQUIRED_GEOSERVER_SOURCE_IDS
).forEach((layer) => map.addLayer(layer));
scadaHitLayers.forEach((layer) => map.addLayer(layer));
setSimulationLayersVisibility(map, impactVisibleRef.current);
setMapReady(true);
@@ -166,6 +308,25 @@ export function useWorkbenchMap({
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;
const resizeMap = () => map.resize();
const resizeObserver = new ResizeObserver(() => {
@@ -213,13 +374,21 @@ export function useWorkbenchMap({
window.cancelAnimationFrame(resizeFrame);
}
resizeObserver.disconnect();
scadaAbortController.abort();
map.remove();
mapRef.current = null;
delete window.__waterNetworkMap;
};
}, [containerRef]);
}, [containerRef, getAccessToken]);
useEffect(() => {
const map = mapRef.current;
if (!mapReady || !map) return;
syncDataDependentGeoServerLayers(map, availableGeoServerSourceIds, layerVisibility);
}, [availableGeoServerSourceIds, layerVisibility, mapReady]);
useMapInteractions({
availableSourceIds: availableGeoServerSourceIds,
mapRef,
mapReady,
onSelectFeature,
@@ -244,10 +413,104 @@ export function useWorkbenchMap({
mapReady,
mapError,
sourceStatuses: Object.values(sourceStatuses),
availableSourceIds: AVAILABLE_WATER_NETWORK_SOURCE_IDS.filter(
(sourceId) =>
sourceId === "scada" ||
availableGeoServerSourceIds.includes(sourceId as GeoServerWaterNetworkSourceId)
),
getScadaFeatures,
refreshGeoServerLayers,
fitNetworkBounds: fitToNetworkBounds
};
}
function syncDataDependentGeoServerLayers(
map: MapLibreMap,
availableSourceIds: readonly GeoServerWaterNetworkSourceId[],
layerVisibility: Record<string, boolean>
) {
const availableSourceIdSet = new Set<GeoServerWaterNetworkSourceId>(availableSourceIds);
DATA_DEPENDENT_GEOSERVER_SOURCE_IDS.forEach((sourceId) => {
if (availableSourceIdSet.has(sourceId)) return;
map.getStyle().layers
.filter((layer) => "source" in layer && layer.source === sourceId)
.reverse()
.forEach((layer) => map.removeLayer(layer.id));
if (map.getSource(sourceId)) map.removeSource(sourceId);
});
const requestedOptionalSourceIds = DATA_DEPENDENT_GEOSERVER_SOURCE_IDS.filter((sourceId) =>
availableSourceIdSet.has(sourceId)
);
const sources = createWaterNetworkSources(requestedOptionalSourceIds);
requestedOptionalSourceIds.forEach((sourceId) => {
if (!map.getSource(sourceId) && sources[sourceId]) {
map.addSource(sourceId, sources[sourceId]);
}
});
addMissingDataDependentLayers(
map,
waterNetworkBusinessLayers,
[...scadaBusinessLayers, ...scadaFallbackBusinessLayers].map((layer) => layer.id),
availableSourceIdSet,
layerVisibility
);
addMissingDataDependentLayers(
map,
waterNetworkInteractionLayers,
scadaInteractionLayers.map((layer) => layer.id),
availableSourceIdSet,
layerVisibility
);
addMissingDataDependentLayers(
map,
createValueLabelLayers(),
[
...simulationAnnotationLayers.filter((layer) => layer.type === "symbol"),
...waterNetworkHitLayers,
...scadaHitLayers
].map((layer) => layer.id),
availableSourceIdSet,
layerVisibility
);
addMissingDataDependentLayers(
map,
waterNetworkHitLayers,
scadaHitLayers.map((layer) => layer.id),
availableSourceIdSet,
layerVisibility
);
}
function addMissingDataDependentLayers(
map: MapLibreMap,
layers: StyleSpecification["layers"],
beforeLayerCandidates: readonly string[],
availableSourceIdSet: ReadonlySet<GeoServerWaterNetworkSourceId>,
layerVisibility: Record<string, boolean>
) {
const beforeLayerId = beforeLayerCandidates.find((layerId) => map.getLayer(layerId));
layers.forEach((layer) => {
if (!("source" in layer) || typeof layer.source !== "string") return;
const sourceId = layer.source as GeoServerWaterNetworkSourceId;
if (
!DATA_DEPENDENT_GEOSERVER_SOURCE_IDS.includes(
sourceId as (typeof DATA_DEPENDENT_GEOSERVER_SOURCE_IDS)[number]
) ||
!availableSourceIdSet.has(sourceId) ||
map.getLayer(layer.id)
) {
return;
}
map.addLayer(layer, beforeLayerId);
if (layerVisibility[sourceId] === false) {
map.setLayoutProperty(layer.id, "visibility", "none");
}
});
}
function updateStatusFromSourceEvent(
event: MapSourceDataEvent,
status: WorkbenchSourceStatusValue,
@@ -302,6 +565,22 @@ function updateSourceStatus(
});
}
function clearSourceStatus(
setSourceStatuses: (
updater: (
current: Record<string, WorkbenchSourceStatus>
) => Record<string, WorkbenchSourceStatus>
) => void,
sourceGroupId: string
) {
setSourceStatuses((current) => {
if (!current[sourceGroupId]) return current;
const next = { ...current };
delete next[sourceGroupId];
return next;
});
}
function getSourceGroupFromErrorEvent(event: { sourceId?: string; error?: { message?: string } }) {
if (event.sourceId && SOURCE_GROUP_BY_ID[event.sourceId]) {
return SOURCE_GROUP_BY_ID[event.sourceId];
@@ -338,6 +617,12 @@ function getSourceStatusMessage(sourceGroupId: string, status: WorkbenchSourceSt
: "业务标注源加载中断,模拟标注可能暂不可见。";
}
if (sourceGroupId === "scada-api") {
return status === "online"
? "SCADA设备已通过API加载。"
: "SCADA API请求中断,管网图层仍可继续使用。";
}
return status === "online" ? "地图数据源已恢复正常。" : "地图数据源请求中断。";
}
@@ -354,5 +639,9 @@ function getSourceErrorMessage(sourceGroupId: string, errorMessage: string) {
return `业务标注源请求失败:${errorMessage}`;
}
if (sourceGroupId === "scada-api") {
return `SCADA API请求失败:${errorMessage}`;
}
return errorMessage;
}
@@ -5,7 +5,8 @@ import {
getAgentPanelMaxWidth,
getWorkbenchBasemapTone,
getWorkbenchCameraPadding,
getWorkbenchViewportLayout
getWorkbenchViewportLayout,
resolveMobileWorkbenchOverlayLayout
} from "./workbench-layout";
describe("workbench basemap surface tone", () => {
@@ -20,6 +21,19 @@ describe("workbench basemap surface tone", () => {
});
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", () => {
expect(getWorkbenchViewportLayout(1440)).toMatchObject({
agentWidth: 500,
@@ -26,9 +26,29 @@ export const WORKBENCH_LAYOUT = {
mobileSheet: {
halfViewportRatio: 0.52,
fullTopGap: 68
},
mobileOverlay: {
edgeInset: 12,
gap: 8,
dockHeight: 44,
timelineSummaryHeight: 48
}
} 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(
width: number,
viewportWidth: number,
+66 -18
View File
@@ -78,7 +78,8 @@ import {
import {
WORKBENCH_LAYOUT,
getWorkbenchBasemapTone,
getWorkbenchLayoutCssVariables
getWorkbenchLayoutCssVariables,
resolveMobileWorkbenchOverlayLayout
} from "./layout/workbench-layout";
import { getResponsiveWorkbenchPadding } from "./map/camera";
import { exportMapViewImage } from "./map/export-view";
@@ -155,6 +156,9 @@ export function MapWorkbenchPage({
const handleSelectFeature = useCallback((feature: DetailFeature) => {
setDetailFeature(feature);
}, []);
const handleClearSelection = useCallback(() => {
setDetailFeature(null);
}, []);
const agent = useWorkbenchAgent({
onUiEnvelope: handleAgentUiEnvelope,
@@ -220,9 +224,10 @@ export function MapWorkbenchPage({
timelineMode,
timelineVisible
);
const mobileOverlayLayout = resolveMobileWorkbenchOverlayLayout(timelineVisible);
const timelineBottom = isLargeScreen
? timelineScaleLayout.timelineBottom
: 12;
: mobileOverlayLayout.timelineBottom;
useEffect(() => {
setTimelineMode((current) => {
@@ -257,11 +262,23 @@ export function MapWorkbenchPage({
});
}
const { mapRef, mapReady, mapError, sourceStatuses, fitNetworkBounds } = useWorkbenchMap({
const {
mapRef,
mapReady,
mapError,
sourceStatuses,
availableSourceIds,
fitNetworkBounds,
getScadaFeatures,
refreshGeoServerLayers
} = useWorkbenchMap({
containerRef: mapContainerRef,
impactVisible,
layerVisibility,
onClearSelection: handleClearSelection,
onSelectFeature: handleSelectFeature,
selectedFeature: detailFeature
selectedFeature: detailFeature,
getAccessToken
});
const { controller: mapController, state: mapControllerState } = useWorkbenchMapController({
mapRef,
@@ -269,7 +286,9 @@ export function MapWorkbenchPage({
leftPanelOpen: false,
rightPanelOpen,
conditionPanelExpanded: rightPanelExpanded,
agentPanelWidth
agentPanelWidth,
getScadaFeatures,
availableSourceIds
});
const activeAgentUiResults = useMemo(
@@ -422,8 +441,8 @@ export function MapWorkbenchPage({
);
const layerControlItems = useMemo(
() => createLayerControlItems(layerVisibility),
[layerVisibility]
() => createLayerControlItems(layerVisibility, availableSourceIds),
[availableSourceIds, layerVisibility]
);
function handleToggleLayer(layerControlId: string, visible: boolean) {
const map = mapRef.current;
@@ -488,8 +507,15 @@ export function MapWorkbenchPage({
return;
}
if (!refreshGeoServerLayers()) {
showMapNotice({ tone: "warning", message: "业务图层正在检查,请稍候。" });
return;
}
map.triggerRepaint();
showMapNotice({ tone: "success", message: "已请求地图重新渲染,业务瓦片将在视图变化时刷新。" });
showMapNotice({
tone: "success",
message: "已开始重新检查业务图层,并请求地图重新渲染。"
});
}
function handleShowDataStatus() {
@@ -987,18 +1013,30 @@ export function MapWorkbenchPage({
onReturnNow={handleReturnOperationalNow}
/>
</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
type="button"
aria-label="打开完整运行时间轴"
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" />
<span className="font-semibold"></span>
<Clock3 size={15} className="shrink-0 text-blue-600" aria-hidden="true" />
<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.active}</span>
<span className="text-amber-700"> {operationalSummary.exceptions}</span>
<span className="ml-auto text-slate-500"></span>
<span className="text-amber-700"> {operationalSummary.attention}</span>
<span className="text-rose-700"> {operationalSummary.exceptions}</span>
</span>
<span className="ml-auto hidden shrink-0 text-slate-500 min-[360px]:inline">
</span>
</button>
</div>
</> : null
)}
mapContent={(
@@ -1018,10 +1056,13 @@ export function MapWorkbenchPage({
<ToolbarPanel {...toolbarPanelProps} />
</div>
<div
className="absolute bottom-0 right-0 flex flex-col items-end gap-2"
style={{ bottom: timelineScaleLayout.scaleBottom }}
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={{
"--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} />
</div>
<MapScaleLine
@@ -1076,7 +1117,10 @@ export function MapWorkbenchPage({
) : null}
{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
{...toolbarPanelProps}
panelWidthClassName="w-full max-h-[52dvh] overflow-y-auto"
@@ -1107,7 +1151,10 @@ export function MapWorkbenchPage({
) : null}
</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 ? (
<div className="flex items-center justify-center gap-2">
<button
@@ -1166,6 +1213,7 @@ export function MapWorkbenchPage({
events={operationalEvents}
mode="expanded"
selectedEventId={workspaceContext.operational.selectedEventId}
onModeChange={closeMobileSheet}
onSelectEvent={handleSelectOperationalEvent}
onClearSelection={handleClearOperationalSelection}
onReturnNow={handleReturnOperationalNow}
+2 -2
View File
@@ -18,7 +18,7 @@ describe("workbench camera padding", () => {
{
agentOpen: false,
conditionOpen: false,
expected: { top: 72, right: 72, bottom: 32, left: 96 }
expected: { top: 72, right: 72, bottom: 32, left: 24 }
},
{
agentOpen: true,
@@ -28,7 +28,7 @@ describe("workbench camera padding", () => {
{
agentOpen: false,
conditionOpen: true,
expected: { top: 72, right: 504, bottom: 32, left: 96 }
expected: { top: 72, right: 504, bottom: 32, left: 24 }
},
{
agentOpen: true,
+3 -1
View File
@@ -51,7 +51,9 @@ export async function exportMapViewImage(map: MapLibreMap, { scale, targetLongEd
});
if (selectedFeature) {
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 }
);
}
+26 -7
View File
@@ -1,15 +1,19 @@
import type { MapGeoJSONFeature } from "maplibre-gl";
import type { DetailFeature } from "../types";
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) {
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);
}
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) {
return sourceId;
}
@@ -43,26 +47,41 @@ function getFeatureLabels(sourceId: WaterNetworkSourceId, id: string, properties
if (sourceId === "valves") {
return {
title: `阀门 ${id || "未命名"}`,
subtitle: `DN${formatValue(properties.diameter)} · ${formatValue(properties.v_type)}`
subtitle: `DN${formatValue(properties.diameter)} · ${formatValue(properties.valve_type)}`
};
}
if (sourceId === "reservoirs") {
return {
title: `水库 ${id || "未命名"}`,
subtitle: `水头 ${formatValue(properties.head)} · 模式 ${formatValue(properties.pattern)}`
subtitle: `水头 ${formatValue(properties.head)} · 模式 ${formatValue(properties.pattern_id)}`
};
}
if (sourceId === "scada") {
const associatedElementId = properties.node_id ?? properties.link_id;
return {
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 {
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({
source: "pipes",
"source-layer": "geo_pipes_mat",
"source-layer": "pipes",
paint: { "line-pattern": "workbench-network-flow-pattern-forward" }
});
expect(layers.get(FLOW_REVERSE_LINE_LAYER_ID)).toMatchObject({
source: "pipes",
"source-layer": "geo_pipes_mat",
"source-layer": "pipes",
paint: { "line-pattern": "workbench-network-flow-pattern-reverse" }
});
expect([...layers.keys()].some((id) => id.includes("arrows"))).toBe(false);
@@ -56,7 +56,7 @@ describe("flow overlay", () => {
expect(map.setFeatureState).toHaveBeenNthCalledWith(1, {
source: "pipes",
sourceLayer: "geo_pipes_mat",
sourceLayer: "pipes",
id: "P-1"
}, {
flowDirection: -1
@@ -0,0 +1,80 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
createGeoServerLayerHitsUrl,
fetchGeoServerLayerFeatureCount,
resolveGeoServerLayerAvailability
} from "./geoserver-layer-availability";
describe("GeoServer data-dependent layer availability", () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it("builds a WFS hits request for the canonical workspace layer", () => {
const url = createGeoServerLayerHitsUrl("pumps");
expect(url.pathname).toBe("/geoserver/tjwater_next/ows");
expect(url.searchParams.get("service")).toBe("WFS");
expect(url.searchParams.get("typeNames")).toBe("tjwater_next:pumps");
expect(url.searchParams.get("resultType")).toBe("hits");
});
it("reads numberMatched without downloading layer features", async () => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(
'<wfs:FeatureCollection numberMatched="12" numberReturned="0" />',
{ status: 200 }
)));
await expect(fetchGeoServerLayerFeatureCount("pumps")).resolves.toBe(12);
});
it("treats a published WFS layer with an unknown count as available", async () => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(
'<wfs:FeatureCollection numberMatched="unknown" numberReturned="0" />',
{ status: 200 }
)));
await expect(fetchGeoServerLayerFeatureCount("pumps")).resolves.toBe(1);
});
it("keeps only optional layers that contain features", async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(new Response('<wfs:FeatureCollection numberMatched="0" />'))
.mockResolvedValueOnce(new Response('<wfs:FeatureCollection numberMatched="0" />'))
.mockResolvedValueOnce(new Response('<wfs:FeatureCollection numberMatched="3" />'))
.mockResolvedValueOnce(new Response('<wfs:FeatureCollection numberMatched="0" />'));
vi.stubGlobal("fetch", fetchMock);
await expect(resolveGeoServerLayerAvailability()).resolves.toEqual({
availableSourceIds: ["pipes", "junctions", "pumps"],
emptySourceIds: ["valves", "reservoirs", "tanks"],
failedSourceIds: []
});
});
it("treats an unpublished GeoServer feature type as empty", async () => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(
"<ows:ExceptionText>Feature type tjwater_next:pumps unknown</ows:ExceptionText>",
{ status: 400 }
)));
await expect(fetchGeoServerLayerFeatureCount("pumps")).resolves.toBe(0);
});
it("omits invalid optional layers without blocking required layers", async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(new Response("missing", { status: 503 }))
.mockResolvedValueOnce(new Response("<ServiceException />"))
.mockResolvedValueOnce(new Response("missing", { status: 503 }))
.mockResolvedValueOnce(new Response("<ServiceException />"));
vi.stubGlobal("fetch", fetchMock);
await expect(resolveGeoServerLayerAvailability()).resolves.toEqual({
availableSourceIds: ["pipes", "junctions"],
emptySourceIds: [],
failedSourceIds: ["valves", "reservoirs", "pumps", "tanks"]
});
});
});
@@ -0,0 +1,111 @@
import { GEOSERVER_WORKSPACE, MAP_URL } from "./geoserver-config";
import {
GEOSERVER_WATER_NETWORK_SOURCE_IDS,
SOURCE_LAYERS,
SUPPLY_LAYER_CATALOG,
type GeoServerWaterNetworkSourceId
} from "./sources";
type GeoServerSupplyLayerCatalogItem = Extract<
(typeof SUPPLY_LAYER_CATALOG)[number],
{ sourceLayer: string }
>;
export type DataDependentGeoServerSourceId = Extract<
GeoServerSupplyLayerCatalogItem,
{ availability: "probe" }
>["id"];
export const DATA_DEPENDENT_GEOSERVER_SOURCE_IDS: DataDependentGeoServerSourceId[] = SUPPLY_LAYER_CATALOG.filter(
(layer): layer is Extract<GeoServerSupplyLayerCatalogItem, { availability: "probe" }> =>
layer.sourceLayer !== undefined && layer.availability === "probe"
).map((layer) => layer.id);
export type GeoServerLayerAvailability = {
availableSourceIds: GeoServerWaterNetworkSourceId[];
emptySourceIds: DataDependentGeoServerSourceId[];
failedSourceIds: DataDependentGeoServerSourceId[];
};
export const REQUIRED_GEOSERVER_SOURCE_IDS: GeoServerWaterNetworkSourceId[] = SUPPLY_LAYER_CATALOG.filter(
(layer): layer is Extract<GeoServerSupplyLayerCatalogItem, { availability: "required" }> =>
layer.sourceLayer !== undefined && layer.availability === "required"
).map((layer) => layer.id);
export function createGeoServerLayerHitsUrl(sourceId: DataDependentGeoServerSourceId) {
const params = new URLSearchParams({
service: "WFS",
version: "2.0.0",
request: "GetFeature",
typeNames: `${GEOSERVER_WORKSPACE}:${SOURCE_LAYERS[sourceId]}`,
resultType: "hits"
});
return new URL(`${MAP_URL}/${GEOSERVER_WORKSPACE}/ows?${params.toString()}`);
}
export async function fetchGeoServerLayerFeatureCount(
sourceId: DataDependentGeoServerSourceId,
signal?: AbortSignal
) {
const response = await fetch(createGeoServerLayerHitsUrl(sourceId), {
headers: { Accept: "application/xml, text/xml" },
signal
});
const payload = await response.text();
if (!response.ok) {
if (/Feature type\s+\S+\s+unknown/i.test(payload)) return 0;
throw new Error(`GeoServer ${sourceId} 图层探测失败:HTTP ${response.status}`);
}
const match = payload.match(/\bnumberMatched\s*=\s*["'](\d+|unknown)["']/i);
if (!match) {
throw new Error(`GeoServer ${sourceId} 图层探测响应缺少 numberMatched`);
}
if (match[1].toLowerCase() === "unknown") return 1;
const count = Number(match[1]);
if (!Number.isSafeInteger(count) || count < 0) {
throw new Error(`GeoServer ${sourceId} 图层探测返回无效数量`);
}
return count;
}
export async function resolveGeoServerLayerAvailability(
signal?: AbortSignal
): Promise<GeoServerLayerAvailability> {
const results = await Promise.allSettled(
DATA_DEPENDENT_GEOSERVER_SOURCE_IDS.map(async (sourceId) => ({
sourceId,
count: await fetchGeoServerLayerFeatureCount(sourceId, signal)
}))
);
const availableOptionalSourceIds: DataDependentGeoServerSourceId[] = [];
const emptySourceIds: DataDependentGeoServerSourceId[] = [];
const failedSourceIds: DataDependentGeoServerSourceId[] = [];
results.forEach((result, index) => {
const sourceId = DATA_DEPENDENT_GEOSERVER_SOURCE_IDS[index];
if (result.status === "rejected") {
failedSourceIds.push(sourceId);
} else if (result.value.count > 0) {
availableOptionalSourceIds.push(sourceId);
} else {
emptySourceIds.push(sourceId);
}
});
const availableSourceIdSet = new Set<GeoServerWaterNetworkSourceId>([
...REQUIRED_GEOSERVER_SOURCE_IDS,
...availableOptionalSourceIds
]);
return {
availableSourceIds: GEOSERVER_WATER_NETWORK_SOURCE_IDS.filter((sourceId) =>
availableSourceIdSet.has(sourceId)
),
emptySourceIds,
failedSourceIds
};
}
+27 -2
View File
@@ -1,6 +1,10 @@
import { describe, expect, it } from "vitest";
import { waterNetworkBusinessLayers, waterNetworkLayers } from "./layers";
import {
filterWaterNetworkLayersBySourceIds,
waterNetworkBusinessLayers,
waterNetworkLayers
} from "./layers";
import { SUPPLY_LAYER_VISUALS } from "./map-layer-visuals";
import { SUPPLY_ASSET_IMAGE_IDS } from "./supply-icons";
import {
@@ -34,6 +38,14 @@ describe("supply network layer styling", () => {
minzoom: 8.5,
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 });
const valvePaint = getLayer("valves")?.paint as Record<string, unknown> | undefined;
@@ -47,7 +59,20 @@ describe("supply network layer styling", () => {
const ids = waterNetworkLayers.map((layer) => layer.id);
expect(ids.indexOf("pipes-flow")).toBeLessThan(ids.indexOf("pipes-hover"));
expect(ids.indexOf("pipes-hover")).toBeLessThan(ids.indexOf("pipes-hit"));
expect(ids.at(-1)).toBe("reservoirs-hit");
expect(ids.at(-1)).toBe("tanks-hit");
});
it("removes every layer backed by an unavailable optional source", () => {
const filtered = filterWaterNetworkLayersBySourceIds(waterNetworkLayers, [
"pipes",
"junctions",
"valves",
"reservoirs"
]);
expect(filtered.some((layer) => "source" in layer && layer.source === "pumps")).toBe(false);
expect(filtered.some((layer) => "source" in layer && layer.source === "tanks")).toBe(false);
expect(filtered.some((layer) => "source" in layer && layer.source === "pipes")).toBe(true);
});
});
+75 -2
View File
@@ -2,7 +2,10 @@ import type { ExpressionSpecification, StyleSpecification } from "maplibre-gl";
import { MAP_STYLE_TOKENS } from "./map-colors";
import { SUPPLY_LAYER_VISUALS } from "./map-layer-visuals";
import { SCADA_HIT_LAYER_ID } from "./scada";
import { SOURCE_LAYERS } from "./sources";
import {
SOURCE_LAYERS,
type GeoServerWaterNetworkSourceId
} from "./sources";
import { SUPPLY_ASSET_IMAGE_IDS } from "./supply-icons";
import {
createSupplyActiveOpacity,
@@ -22,6 +25,8 @@ export const INTERACTIVE_HIT_LAYER_IDS = [
"junctions-hit",
"valves-hit",
"reservoirs-hit",
"pumps-hit",
"tanks-hit",
SCADA_HIT_LAYER_ID
] as const;
@@ -208,6 +213,38 @@ layers.push(
paint: {
"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 +332,9 @@ lineInteractions.forEach(([prefix, source, sourceLayer, visual, color, dash]) =>
const pointInteractions = [
["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],
["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;
pointInteractions.forEach(([prefix, source, sourceLayer, visual, color]) => {
@@ -403,6 +442,30 @@ layers.push(
"circle-radius": SUPPLY_LAYER_VISUALS.reservoirs.hitRadius,
"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
}
}
);
@@ -415,3 +478,13 @@ export const waterNetworkBusinessLayers = layers.slice(0, interactionIndex) as L
export const waterNetworkInteractionLayers = layers.slice(interactionIndex, hitIndex) as Layer[];
export const waterNetworkHitLayers = layers.slice(hitIndex) as Layer[];
export const WORKBENCH_INTERACTION_BEFORE_ID = "pipes-hover-outline";
export function filterWaterNetworkLayersBySourceIds(
sourceLayers: StyleSpecification["layers"],
sourceIds: readonly GeoServerWaterNetworkSourceId[]
) {
const sourceIdSet = new Set<string>(sourceIds);
return sourceLayers.filter(
(layer) => !("source" in layer) || typeof layer.source !== "string" || sourceIdSet.has(layer.source)
);
}
+2
View File
@@ -14,6 +14,8 @@ export const MAP_STYLE_TOKENS = {
junction: "#47787B",
valve: "#6B7280",
reservoir: "#195F84",
pump: "#326B87",
tank: "#4F6F7A",
scadaFlow: "#007A9E",
scadaPressure: "#9A6700"
},
@@ -19,16 +19,37 @@ describe("workbench supply layer controls", () => {
"valves",
"reservoirs",
"scada",
"pumps",
"tanks",
"simulation"
]);
expect(items.map((item) => item.id)).not.toContain("pumps");
expect(items.map((item) => item.id)).not.toContain("tanks");
expect(items.slice(0, 5).map((item) => item.description)).toEqual([
"tjwater:geo_pipes_mat",
"tjwater:geo_junctions_mat",
"tjwater:geo_valves",
"tjwater:geo_reservoirs",
"tjwater:geo_scada"
expect(items.slice(0, 7).map((item) => item.description)).toEqual([
"tjwater_next:pipes",
"tjwater_next:junctions",
"tjwater_next:valves",
"tjwater_next:reservoirs",
"后端业务 API",
"tjwater_next:pumps",
"tjwater_next:tanks"
]);
});
it("omits data-dependent controls when their GeoServer layers are empty", () => {
const items = createLayerControlItems(INITIAL_LAYER_VISIBILITY, [
"pipes",
"junctions",
"valves",
"reservoirs",
"scada"
]);
expect(items.map((item) => item.id)).toEqual([
"pipes",
"junctions",
"valves",
"reservoirs",
"scada",
"simulation"
]);
});
@@ -41,6 +62,8 @@ describe("workbench supply layer controls", () => {
"junction",
"valve",
"reservoir",
"pump",
"tank",
"scada-flow",
"scada-pressure",
"impact-area"
@@ -50,6 +73,8 @@ describe("workbench supply layer controls", () => {
label: "水库",
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-pressure")).toMatchObject({ imageSrc: "/map/scada-pressure.png" });
expect(MAP_LEGEND_ITEMS.find((item) => item.id === "inactive-pipe")).toMatchObject({
@@ -82,5 +107,12 @@ describe("workbench supply layer controls", () => {
"reservoirs-selected",
"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,
{
label: layer.label,
description: `${GEOSERVER_WORKSPACE}:${layer.sourceLayer}`
description: layer.id === "scada"
? "后端业务 API"
: `${GEOSERVER_WORKSPACE}:${layer.sourceLayer}`
}
])
) 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: "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: "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-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" }
@@ -88,9 +92,12 @@ export const BASE_LAYER_OPTIONS: BaseLayerOption[] = [
}
];
export function createLayerControlItems(layerVisibility: Record<string, boolean>): MapLayerControlItem[] {
export function createLayerControlItems(
layerVisibility: Record<string, boolean>,
availableSourceIds: readonly WaterNetworkSourceId[] = AVAILABLE_WATER_NETWORK_SOURCE_IDS
): MapLayerControlItem[] {
return [
...AVAILABLE_WATER_NETWORK_SOURCE_IDS.map((sourceId) => ({
...availableSourceIds.map((sourceId) => ({
id: sourceId,
...SOURCE_CONTROL_LABELS[sourceId],
visible: layerVisibility[sourceId]
@@ -15,7 +15,6 @@ describe("map feature WFS query", () => {
junctions: "id",
valves: "id",
reservoirs: "id",
scada: "id",
pumps: "id",
tanks: "id"
});
@@ -23,18 +22,21 @@ describe("map feature WFS query", () => {
.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'");
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("cql_filter")).toBe("\"id\" IN ('a''b')");
});
it("uses the authoritative supply asset WFS layers", () => {
expect(createMapFeatureWfsUrl({ sourceId: "valves", featureIds: ["V-1"] }).searchParams.get("typeNames")).toContain("geo_valves");
expect(createMapFeatureWfsUrl({ sourceId: "reservoirs", featureIds: ["R-1"] }).searchParams.get("typeNames")).toContain("geo_reservoirs");
expect(createMapFeatureWfsUrl({ sourceId: "scada", featureIds: ["S-1"] }).searchParams.get("typeNames")).toContain("geo_scada");
it("uses WFS only for authoritative supply asset layers", () => {
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("tjwater_next:reservoirs");
expect(parseMapFeatureQuery({ sourceId: "scada", featureIds: ["S-1"] })).toMatchObject({
ok: false,
code: "UNSUPPORTED_SOURCE"
});
});
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(parseMapFeatureQuery({ sourceId: "junctions" })).toMatchObject({ ok: false, status: 400 });
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: "tanks", featureIds: ["T-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"] })).toEqual({ ok: true, value: { sourceId: "tanks", featureIds: ["T-1"] } });
expect(parseMapFeatureQuery({ sourceId: "pipes", featureIds: Array.from({ length: 101 }, (_, index) => String(index)) }))
.toMatchObject({ ok: false, status: 400 });
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 {
SOURCE_LAYERS,
isAvailableWaterNetworkSourceId,
type WaterNetworkSourceId
GEOSERVER_WATER_NETWORK_SOURCE_IDS,
type GeoServerWaterNetworkSourceId
} from "./sources";
export const MAP_FEATURE_ID_FIELDS: Record<WaterNetworkSourceId, "id"> = {
export const MAP_FEATURE_ID_FIELDS: Record<GeoServerWaterNetworkSourceId, "id"> = {
pipes: "id",
junctions: "id",
valves: "id",
reservoirs: "id",
scada: "id",
pumps: "id",
tanks: "id"
};
@@ -21,7 +20,7 @@ export const MAX_MAP_FEATURE_ID_LENGTH = 128;
export const MAX_PIPE_FEATURES = 5000;
export type MapFeatureQuery = {
sourceId: WaterNetworkSourceId;
sourceId: GeoServerWaterNetworkSourceId;
featureIds?: string[];
};
@@ -35,7 +34,10 @@ export function parseMapFeatureQuery(body: unknown): MapFeatureQueryValidation {
}
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 };
}
@@ -58,7 +60,7 @@ export function parseMapFeatureQuery(body: unknown): MapFeatureQueryValidation {
return {
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", () => {
expect(parseScadaAnalysisItems([{ sensor_id: " MP01 ", level: "high" }])).toEqual([
{ sensor_id: "MP01", level: "high" }
expect(parseScadaAnalysisItems([{ device_id: " MP01 ", level: "high" }])).toEqual([
{ device_id: "MP01", level: "high" }
]);
expect(parseScadaAnalysisItems([])).toBeNull();
expect(parseScadaAnalysisItems([{ sensor_id: "MP01", level: "high" }, { sensor_id: "MP01", level: "low" }])).toBeNull();
expect(parseScadaAnalysisItems([{ sensor_id: "MP01", level: "critical" }])).toBeNull();
expect(parseScadaAnalysisItems([{ device_id: "MP01", level: "high" }, { device_id: "MP01", level: "low" }])).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, [
{ sensor_id: "MP01", level: "high" },
{ sensor_id: "MP02", level: "medium" },
{ sensor_id: "MP404", level: "low" }
{ device_id: "MP01", level: "high" },
{ device_id: "MP02", level: "medium" },
{ device_id: "MP404", level: "low" }
]);
expect(result.result).toEqual({
rendered_ids: ["MP01", "MP02"],
@@ -76,8 +76,8 @@ describe("SCADA analysis overlay", () => {
level_counts: { high: 1, medium: 1, low: 0, unrated: 0 }
});
expect(result.collection.features.map((feature) => feature.properties)).toEqual([
expect.objectContaining({ sensor_id: "MP01", analysis_label: "MP01 · 高", sort_priority: 0 }),
expect.objectContaining({ sensor_id: "MP02", analysis_label: "MP02 · 中", sort_priority: 1 })
expect.objectContaining({ device_id: "MP01", analysis_label: "MP01 · 高", sort_priority: 0 }),
expect.objectContaining({ device_id: "MP02", analysis_label: "MP02 · 中", sort_priority: 1 })
]);
});
});
@@ -85,7 +85,7 @@ describe("SCADA analysis overlay", () => {
const scadaCollection: FeatureCollection = {
type: "FeatureCollection",
features: [
{ type: "Feature", geometry: { type: "Point", coordinates: [120.7, 28] }, properties: { sensor_id: "MP01" } },
{ type: "Feature", geometry: { type: "Point", coordinates: [120.72, 28.02] }, properties: { sensor_id: "MP02" } }
{ type: "Feature", geometry: { type: "Point", coordinates: [120.7, 28] }, properties: { device_id: "MP01" } },
{ 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 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 ScadaAnalysisRenderResult = {
rendered_ids: string[];
@@ -145,17 +145,17 @@ export function parseScadaAnalysisItems(value: unknown): ScadaAnalysisItem[] | n
for (const entry of value) {
if (!entry || typeof entry !== "object" || Array.isArray(entry)) return null;
const keys = Object.keys(entry);
if (keys.length !== 2 || !keys.includes("sensor_id") || !keys.includes("level")) return null;
const { sensor_id: rawSensorId, level } = entry as Record<string, unknown>;
const sensorId = typeof rawSensorId === "string" ? rawSensorId.trim() : "";
if (keys.length !== 2 || !keys.includes("device_id") || !keys.includes("level")) return null;
const { device_id: rawDeviceId, level } = entry as Record<string, unknown>;
const deviceId = typeof rawDeviceId === "string" ? rawDeviceId.trim() : "";
if (
!sensorId ||
sensorId.length > 128 ||
!deviceId ||
deviceId.length > 128 ||
!SCADA_ANALYSIS_LEVELS.includes(level as ScadaAnalysisLevel) ||
seen.has(sensorId)
seen.has(deviceId)
) return null;
seen.add(sensorId);
items.push({ sensor_id: sensorId, level: level as ScadaAnalysisLevel });
seen.add(deviceId);
items.push({ device_id: deviceId, level: level as ScadaAnalysisLevel });
}
return items;
}
@@ -164,13 +164,13 @@ export function createScadaAnalysisCollection(
collection: FeatureCollection,
items: ScadaAnalysisItem[]
): { 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) => {
const sensorId = typeof feature.properties?.sensor_id === "string"
? feature.properties.sensor_id.trim()
const deviceId = typeof feature.properties?.device_id === "string"
? feature.properties.device_id.trim()
: "";
if (sensorId && feature.geometry?.type === "Point" && !featuresBySensorId.has(sensorId)) {
featuresBySensorId.set(sensorId, feature as Feature<Point>);
if (deviceId && feature.geometry?.type === "Point" && !featuresByDeviceId.has(deviceId)) {
featuresByDeviceId.set(deviceId, feature as Feature<Point>);
}
});
@@ -179,26 +179,26 @@ export function createScadaAnalysisCollection(
const missingIds: string[] = [];
const features: Array<Feature<Point>> = [];
items.forEach((item) => {
const feature = featuresBySensorId.get(item.sensor_id);
const feature = featuresByDeviceId.get(item.device_id);
if (!feature) {
missingIds.push(item.sensor_id);
missingIds.push(item.device_id);
return;
}
const trustedSensorId = String(feature.properties?.sensor_id ?? "").trim();
if (!trustedSensorId) {
missingIds.push(item.sensor_id);
const trustedDeviceId = String(feature.properties?.device_id ?? "").trim();
if (!trustedDeviceId) {
missingIds.push(item.device_id);
return;
}
renderedIds.push(trustedSensorId);
renderedIds.push(trustedDeviceId);
levelCounts[item.level] += 1;
features.push({
...feature,
properties: {
...feature.properties,
sensor_id: trustedSensorId,
device_id: trustedDeviceId,
analysis_level: 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]
}
});
+8 -1
View File
@@ -16,7 +16,7 @@ import { SUPPLY_LAYER_VISUALS } from "./map-layer-visuals";
describe("SCADA map presentation", () => {
it("maps the authoritative device types and falls back to unknown", () => {
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,
"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);
});
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", () => {
const symbolLayer = scadaLayers.find((layer) => layer.id === "scada-symbol");
const outerLayer = scadaLayers.find((layer) => layer.id === "scada-selected-outer");
+10 -11
View File
@@ -8,7 +8,6 @@ import {
} from "./supply-style-expressions";
export const SCADA_SOURCE_ID = "scada";
export const SCADA_SOURCE_LAYER = "geo_scada";
export const SCADA_HIT_LAYER_ID = "scada-hit";
export const SCADA_ICON_PATHS = {
@@ -24,7 +23,7 @@ export const SCADA_IMAGE_IDS = {
} as const;
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,
"pressure", SCADA_IMAGE_IDS.pressure,
SCADA_IMAGE_IDS.pressure
@@ -40,7 +39,7 @@ const stateOpacity = (state: "hovered" | "selected"): 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,
MAP_STYLE_TOKENS.supply.scadaPressure
];
@@ -53,7 +52,7 @@ export type ScadaImageRegistrationResult = {
};
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: {
"circle-radius": SUPPLY_LAYER_VISUALS.scada.haloRadius,
"circle-color": MAP_STYLE_TOKENS.canvas.casing,
@@ -62,7 +61,7 @@ const scadaCasingLayer: 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: {
"circle-radius": SUPPLY_LAYER_VISUALS.scada.radius,
"circle-color": createSupplyStateColor(scadaColor),
@@ -71,13 +70,13 @@ const scadaFillLayer: 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 },
paint: { "icon-opacity": supplyIconOpacityExpression }
};
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: {
"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,
@@ -86,13 +85,13 @@ const scadaFallbackLayer: 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-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", 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-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, 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, 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 = {
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 }
};
+43 -22
View File
@@ -1,7 +1,9 @@
import { describe, expect, it } from "vitest";
import {
AVAILABLE_WATER_NETWORK_SOURCE_IDS,
GEOSERVER_WATER_NETWORK_SOURCE_IDS,
SUPPLY_LAYER_CATALOG,
createBaseStyle,
createWaterNetworkSources,
SOURCE_LAYERS,
WATER_NETWORK_SOURCE_IDS
@@ -10,46 +12,65 @@ import {
describe("createWaterNetworkSources", () => {
it("catalogs published and pre-registered supply system layers", () => {
expect(SOURCE_LAYERS).toEqual({
pipes: "geo_pipes_mat",
junctions: "geo_junctions_mat",
valves: "geo_valves",
reservoirs: "geo_reservoirs",
scada: "geo_scada",
pumps: "geo_pumps",
tanks: "geo_tanks"
pipes: "pipes",
junctions: "junctions",
valves: "valves",
reservoirs: "reservoirs",
pumps: "pumps",
tanks: "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([
["pipes", true],
["junctions", true],
["valves", true],
["reservoirs", true],
["scada", true],
["pumps", false],
["tanks", false]
expect(SUPPLY_LAYER_CATALOG.map((layer) => [layer.id, layer.available, layer.availability])).toEqual([
["pipes", true, "required"],
["junctions", true, "required"],
["valves", true, "probe"],
["reservoirs", true, "probe"],
["scada", true, "required"],
["pumps", true, "probe"],
["tanks", true, "probe"]
]);
expect(SUPPLY_LAYER_CATALOG.find((layer) => layer.id === "valves")).toMatchObject({
geometry: "point",
sourceLayer: "geo_valves"
sourceLayer: "valves"
});
});
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({
label: "水库",
icon: "reservoir"
});
const sources = createWaterNetworkSources();
expect(Object.keys(sources)).toEqual(AVAILABLE_WATER_NETWORK_SOURCE_IDS);
AVAILABLE_WATER_NETWORK_SOURCE_IDS.forEach((sourceId) => {
expect(Object.keys(sources)).toEqual(GEOSERVER_WATER_NETWORK_SOURCE_IDS);
GEOSERVER_WATER_NETWORK_SOURCE_IDS.forEach((sourceId) => {
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].bounds).toEqual([
121.35162408429075,
30.81049932304578,
121.77248479490075,
31.007207809222017
]);
});
expect("pumps" in sources).toBe(false);
expect("tanks" in sources).toBe(false);
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"
});
});
it("creates only the requested GeoServer vector sources", () => {
const sources = createWaterNetworkSources(["pipes", "junctions"]);
expect(Object.keys(sources)).toEqual(["pipes", "junctions"]);
});
});
+49 -23
View File
@@ -6,19 +6,20 @@ import {
SCADA_ANALYSIS_SOURCE_ID,
createEmptyScadaAnalysisCollection
} from "./scada-analysis";
import { SCADA_SOURCE_ID } from "./scada";
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;
export const SOURCE_LAYERS = {
pipes: "geo_pipes_mat",
junctions: "geo_junctions_mat",
valves: "geo_valves",
reservoirs: "geo_reservoirs",
scada: "geo_scada",
pumps: "geo_pumps",
tanks: "geo_tanks"
pipes: "pipes",
junctions: "junctions",
valves: "valves",
reservoirs: "reservoirs",
pumps: "pumps",
tanks: "tanks"
} as const;
export const WATER_NETWORK_SOURCE_IDS = [
@@ -35,9 +36,10 @@ export type WaterNetworkSourceId = (typeof WATER_NETWORK_SOURCE_IDS)[number];
export type SupplyLayerCatalogItem = {
id: WaterNetworkSourceId;
sourceLayer: (typeof SOURCE_LAYERS)[WaterNetworkSourceId];
sourceLayer?: (typeof SOURCE_LAYERS)[keyof typeof SOURCE_LAYERS];
geometry: "line" | "point";
available: boolean;
availability: "required" | "probe";
label: string;
icon: string;
};
@@ -48,6 +50,7 @@ export const SUPPLY_LAYER_CATALOG = [
sourceLayer: SOURCE_LAYERS.pipes,
geometry: "line",
available: true,
availability: "required",
label: "管线",
icon: "pipe"
},
@@ -56,6 +59,7 @@ export const SUPPLY_LAYER_CATALOG = [
sourceLayer: SOURCE_LAYERS.junctions,
geometry: "point",
available: true,
availability: "required",
label: "节点",
icon: "junction"
},
@@ -64,6 +68,7 @@ export const SUPPLY_LAYER_CATALOG = [
sourceLayer: SOURCE_LAYERS.valves,
geometry: "point",
available: true,
availability: "probe",
label: "阀门",
icon: "valve"
},
@@ -72,14 +77,16 @@ export const SUPPLY_LAYER_CATALOG = [
sourceLayer: SOURCE_LAYERS.reservoirs,
geometry: "point",
available: true,
availability: "probe",
label: "水库",
icon: "reservoir"
},
{
id: "scada",
sourceLayer: SOURCE_LAYERS.scada,
sourceLayer: undefined,
geometry: "point",
available: true,
availability: "required",
label: "SCADA",
icon: "scada-pressure"
},
@@ -87,7 +94,8 @@ export const SUPPLY_LAYER_CATALOG = [
id: "pumps",
sourceLayer: SOURCE_LAYERS.pumps,
geometry: "point",
available: false,
available: true,
availability: "probe",
label: "水泵",
icon: "pump"
},
@@ -95,7 +103,8 @@ export const SUPPLY_LAYER_CATALOG = [
id: "tanks",
sourceLayer: SOURCE_LAYERS.tanks,
geometry: "point",
available: false,
available: true,
availability: "probe",
label: "水箱",
icon: "tank"
}
@@ -103,12 +112,14 @@ export const SUPPLY_LAYER_CATALOG = [
export const AVAILABLE_WATER_NETWORK_SOURCE_IDS = SUPPLY_LAYER_CATALOG.filter(
(layer) => layer.available
).map((layer) => layer.id) as Extract<
WaterNetworkSourceId,
"pipes" | "junctions" | "valves" | "reservoirs" | "scada"
>[];
).map((layer) => layer.id) as WaterNetworkSourceId[];
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(
value: string
@@ -116,13 +127,22 @@ export function isAvailableWaterNetworkSourceId(
return (AVAILABLE_WATER_NETWORK_SOURCE_IDS as readonly string[]).includes(value);
}
export function createWaterNetworkSources() {
export function createWaterNetworkSources(): Record<GeoServerWaterNetworkSourceId, VectorSourceSpecification>;
export function createWaterNetworkSources(
sourceIds: readonly GeoServerWaterNetworkSourceId[]
): Partial<Record<GeoServerWaterNetworkSourceId, VectorSourceSpecification>>;
export function createWaterNetworkSources(
sourceIds: readonly GeoServerWaterNetworkSourceId[] = GEOSERVER_WATER_NETWORK_SOURCE_IDS
) {
return Object.fromEntries(
SUPPLY_LAYER_CATALOG.filter((layer) => layer.available).map((layer) => [
layer.id,
createGeoServerVectorSource(layer.sourceLayer)
])
) as Record<AvailableWaterNetworkSourceId, VectorSourceSpecification>;
SUPPLY_LAYER_CATALOG.flatMap((layer) =>
layer.available &&
layer.sourceLayer !== undefined &&
sourceIds.includes(layer.id as GeoServerWaterNetworkSourceId)
? [[layer.id, createGeoServerVectorSource(layer.sourceLayer)] as const]
: []
)
) as Partial<Record<GeoServerWaterNetworkSourceId, VectorSourceSpecification>>;
}
function createGeoServerVectorSource(
@@ -135,12 +155,18 @@ function createGeoServerVectorSource(
`${MAP_URL}/gwc/service/wmts/rest/${GEOSERVER_WORKSPACE}:${sourceLayer}/WebMercatorQuad/{z}/{y}/{x}?format=application/vnd.mapbox-vector-tile`
],
minzoom: 0,
maxzoom: 24
maxzoom: 24,
bounds: [...WATER_NETWORK_GLOBAL_VIEW.bbox4326]
};
}
export function createBaseStyle(mapboxToken?: string): StyleSpecification {
const sources: StyleSpecification["sources"] = {
[SCADA_SOURCE_ID]: {
type: "geojson",
promoteId: "device_id",
data: { type: "FeatureCollection", features: [] }
},
[SCADA_ANALYSIS_SOURCE_ID]: {
type: "geojson",
data: createEmptyScadaAnalysisCollection()
@@ -21,6 +21,192 @@ describe("WorkbenchMapController", () => {
expect(map.removeFeatureState).not.toHaveBeenCalledWith(expect.anything(), "selected");
});
it("clears a stale highlight without touching a removed source", async () => {
const map = createMap();
const controller = createController(map, pointCollection);
await controller.highlight({ sourceId: "junctions", featureId: "J-1" });
map.getSource.mockReturnValue(undefined);
controller.clearHighlight();
expect(map.removeFeatureState).not.toHaveBeenCalled();
expect(controller.getSnapshot().target).toBeNull();
});
it("ignores a resolved highlight after its source has been removed", async () => {
const map = createMap();
map.getSource.mockReturnValue(undefined);
const controller = createController(map, pointCollection);
await controller.highlight({ sourceId: "junctions", featureId: "J-1" });
expect(map.setFeatureState).not.toHaveBeenCalled();
expect(controller.getSnapshot().target).toBeNull();
expect(controller.getSnapshot().errorCode).toBe("FEATURE_NOT_FOUND");
});
it("does not move to a cached feature after its source has been removed", async () => {
const map = createMap();
const controller = createController(map, pointCollection);
const target = { sourceId: "junctions" as const, featureId: "J-1" };
await controller.locateAndHighlight(target);
map.getSource.mockReturnValue(undefined);
await controller.locateAndHighlight(target);
expect(map.easeTo).toHaveBeenCalledTimes(1);
expect(controller.getSnapshot().errorCode).toBe("FEATURE_NOT_FOUND");
});
it("drops cached features and presentation state when a dynamic source disappears", async () => {
const map = createMap();
map.getLayer.mockReturnValue({} as never);
let collection = pointCollection;
const fetchFeatures = vi.fn(async () => collection);
const controller = new WorkbenchMapController({
getMap: () => map as unknown as MapLibreMap,
isReady: () => true,
getPadding: () => ({ top: 0, right: 0, bottom: 0, left: 0 }),
fetchFeatures
});
const target = { sourceId: "valves" as const, featureId: "V-1" };
await controller.locateAndHighlight(target);
controller.applyLayerGroupStyle("valves", { iconMultiplier: 1.5 });
await controller.showValueLabel({
sourceId: "valves",
property: "elevation",
precision: 1,
showValue: true,
showId: false,
textSize: 12,
textColor: "#0F172A"
});
controller.syncAvailableSources(["pipes", "junctions", "scada"]);
expect(controller.getSnapshot()).toMatchObject({
target: null,
valueLabels: {},
styledGroups: []
});
collection = {
...pointCollection,
features: [{
...pointCollection.features[0],
geometry: { type: "Point", coordinates: [121.9, 29.1] }
}]
};
map.easeTo.mockClear();
await controller.locateAndHighlight(target);
expect(fetchFeatures).toHaveBeenCalledTimes(2);
expect(map.easeTo).toHaveBeenCalledWith(expect.objectContaining({ center: [121.9, 29.1] }));
});
it("ignores an in-flight dynamic-source result from an earlier availability generation", async () => {
const map = createMap();
let resolveFeatures = (_collection: FeatureCollection) => {};
const featureResponse = new Promise<FeatureCollection>((resolve) => {
resolveFeatures = resolve;
});
const controller = new WorkbenchMapController({
getMap: () => map as unknown as MapLibreMap,
isReady: () => true,
getPadding: () => ({ top: 0, right: 0, bottom: 0, left: 0 }),
fetchFeatures: async () => featureResponse
});
controller.syncAvailableSources(["pipes", "junctions", "valves", "scada"]);
const locating = controller.locateAndHighlight({ sourceId: "valves", featureId: "V-1" });
controller.syncAvailableSources(["pipes", "junctions", "scada"]);
controller.syncAvailableSources(["pipes", "junctions", "valves", "scada"]);
resolveFeatures(pointCollection);
await locating;
expect(map.easeTo).not.toHaveBeenCalled();
expect(map.setFeatureState).not.toHaveBeenCalled();
expect(controller.getSnapshot().target).toBeNull();
});
it("keeps a newer cached target when an older dynamic-source request resolves", async () => {
const map = createMap();
let resolveDynamicFeatures = (_collection: FeatureCollection) => {};
const dynamicFeatureResponse = new Promise<FeatureCollection>((resolve) => {
resolveDynamicFeatures = resolve;
});
const fetchFeatures = vi.fn(async (sourceId: string) =>
sourceId === "valves" ? dynamicFeatureResponse : pointCollection
);
const controller = new WorkbenchMapController({
getMap: () => map as unknown as MapLibreMap,
isReady: () => true,
getPadding: () => ({ top: 0, right: 0, bottom: 0, left: 0 }),
fetchFeatures
});
const cachedTarget = { sourceId: "junctions" as const, featureId: "J-1" };
await controller.locateAndHighlight(cachedTarget);
const locatingDynamicTarget = controller.locateAndHighlight({ sourceId: "valves", featureId: "V-1" });
await controller.locateAndHighlight(cachedTarget);
expect(controller.getSnapshot()).toMatchObject({ target: cachedTarget, pending: false });
resolveDynamicFeatures(pointCollection);
await locatingDynamicTarget;
expect(map.easeTo).toHaveBeenCalledTimes(2);
expect(controller.getSnapshot()).toMatchObject({ target: cachedTarget, pending: false });
});
it("does not restore a target after its in-flight request is cleared", async () => {
const map = createMap();
let resolveFeatures = (_collection: FeatureCollection) => {};
const featureResponse = new Promise<FeatureCollection>((resolve) => {
resolveFeatures = resolve;
});
const controller = new WorkbenchMapController({
getMap: () => map as unknown as MapLibreMap,
isReady: () => true,
getPadding: () => ({ top: 0, right: 0, bottom: 0, left: 0 }),
fetchFeatures: async () => featureResponse
});
const locating = controller.locateAndHighlight({ sourceId: "valves", featureId: "V-1" });
controller.clearHighlight();
resolveFeatures(pointCollection);
await locating;
expect(map.easeTo).not.toHaveBeenCalled();
expect(map.setFeatureState).not.toHaveBeenCalled();
expect(controller.getSnapshot()).toMatchObject({ target: null, pending: false });
});
it("reports an unavailable target when its source disappears in flight", async () => {
const map = createMap();
let resolveFeatures = (_collection: FeatureCollection) => {};
const featureResponse = new Promise<FeatureCollection>((resolve) => {
resolveFeatures = resolve;
});
const controller = new WorkbenchMapController({
getMap: () => map as unknown as MapLibreMap,
isReady: () => true,
getPadding: () => ({ top: 0, right: 0, bottom: 0, left: 0 }),
fetchFeatures: async () => featureResponse
});
controller.syncAvailableSources(["pipes", "junctions", "valves", "scada"]);
const locating = controller.locateAndHighlight({ sourceId: "valves", featureId: "V-1" });
controller.syncAvailableSources(["pipes", "junctions", "scada"]);
resolveFeatures(pointCollection);
await locating;
expect(controller.getSnapshot()).toMatchObject({
target: null,
pending: false,
errorCode: "WFS_UNAVAILABLE"
});
});
it("fits line bounds and uses responsive workbench padding", async () => {
const map = createMap();
const padding = { top: 50, right: 420, bottom: 50, left: 320 };
@@ -56,7 +242,7 @@ describe("WorkbenchMapController", () => {
await controller.highlight({ sourceId: "junctions", featureId: "J-1" });
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("cql_filter")).toBe("\"id\" IN ('J-1')");
expect(map.setFeatureState).toHaveBeenCalled();
@@ -81,7 +267,7 @@ describe("WorkbenchMapController", () => {
expect(layers.get(FLOW_LINE_LAYER_ID)).toMatchObject({
source: "pipes",
"source-layer": "geo_pipes_mat"
"source-layer": "pipes"
});
expect([...layers.keys()].some((id) => id.includes("arrows"))).toBe(false);
vi.unstubAllGlobals();
@@ -96,7 +282,7 @@ describe("WorkbenchMapController", () => {
expect(map.setFeatureState).toHaveBeenCalledWith(
expect.objectContaining({
source: "pipes",
sourceLayer: "geo_pipes_mat",
sourceLayer: "pipes",
id: "P-1"
}),
{ flowDirection: -1 }
@@ -251,13 +437,13 @@ describe("WorkbenchMapController", () => {
{
type: "Feature",
geometry: { type: "Point", coordinates: [120.7, 28] },
properties: { sensor_id: "MP01" }
properties: { device_id: "MP01" }
}
]
});
await expect(
controller.renderScadaAnalysis([{ sensor_id: "MP01", level: "high" }])
controller.renderScadaAnalysis([{ device_id: "MP01", level: "high" }])
).resolves.toMatchObject({
rendered_ids: ["MP01"],
fitted: true
@@ -281,7 +467,7 @@ function createMap() {
const images = new Set<string>();
return {
easeTo: vi.fn(), fitBounds: vi.fn(), setFeatureState: vi.fn(), removeFeatureState: vi.fn(),
getSource: vi.fn(), getLayer: vi.fn(), addLayer: vi.fn(), setPaintProperty: vi.fn(),
getSource: vi.fn((_id: string): unknown => ({})), getLayer: vi.fn(), addLayer: vi.fn(), setPaintProperty: vi.fn(),
setLayoutProperty: vi.fn(), setFilter: vi.fn(), removeControl: vi.fn(), triggerRepaint: vi.fn(),
hasImage: vi.fn((id: string) => images.has(id)),
addImage: vi.fn((id: string) => {
@@ -27,6 +27,7 @@ import {
normalizeMapFeatureCollection,
parseMapFeatureQuery
} from "./map-feature-query";
import { DATA_DEPENDENT_GEOSERVER_SOURCE_IDS } from "./geoserver-layer-availability";
import {
applyValueLabelStyle,
clearValueLabels,
@@ -47,6 +48,7 @@ export type WorkbenchMapErrorCode =
| "MAP_NOT_READY"
| "FEATURE_NOT_FOUND"
| "WFS_UNAVAILABLE"
| "SCADA_API_UNAVAILABLE"
| "SCADA_FEATURES_NOT_FOUND"
| "ACTION_SUPERSEDED"
| "FLOW_UNAVAILABLE"
@@ -88,6 +90,7 @@ export type WorkbenchMapControllerOptions = {
isReady: () => boolean;
getPadding: () => PaddingOptions;
fetchFeatures?: (sourceId: WaterNetworkSourceId, featureIds?: string[], signal?: AbortSignal) => Promise<FeatureCollection>;
getScadaFeatures?: (deviceIds?: string[], signal?: AbortSignal) => Promise<FeatureCollection>;
reducedMotion?: () => boolean;
};
@@ -108,6 +111,10 @@ export class WorkbenchMapController implements WorkbenchMapCommands {
private flowState: FlowOverlayState = createFlowOverlayState();
private flowUpdateRevision = 0;
private scadaAnalysisRevision = 0;
private targetRequestRevision = 0;
private pendingTargetSourceId: WaterNetworkSourceId | null = null;
private availableSourceIds: ReadonlySet<WaterNetworkSourceId> | null = null;
private sourceAvailabilityGenerations = new Map<WaterNetworkSourceId, number>();
constructor(private readonly options: WorkbenchMapControllerOptions) {}
@@ -119,15 +126,21 @@ export class WorkbenchMapController implements WorkbenchMapCommands {
getSnapshot = () => this.state;
async zoomToFeature(target: FeatureTarget) {
const feature = await this.resolveTarget(target);
const map = this.requireMap();
const requestRevision = this.beginTargetCommand();
if (!this.requireTargetMap(target)) return;
const feature = await this.resolveTarget(target, requestRevision);
if (requestRevision !== this.targetRequestRevision) return;
const map = this.requireTargetMap(target);
if (!feature || !map) return;
this.moveCameraToFeature(map, feature);
}
async locateAndHighlight(target: FeatureTarget) {
const feature = await this.resolveTarget(target);
const map = this.requireMap();
const requestRevision = this.beginTargetCommand();
if (!this.requireTargetMap(target)) return;
const feature = await this.resolveTarget(target, requestRevision);
if (requestRevision !== this.targetRequestRevision) return;
const map = this.requireTargetMap(target);
if (!feature || !map) return;
if (!this.moveCameraToFeature(map, feature)) return;
this.setHighlight(target);
@@ -163,19 +176,90 @@ export class WorkbenchMapController implements WorkbenchMapCommands {
}
async highlight(target: FeatureTarget) {
const feature = await this.resolveTarget(target);
const requestRevision = this.beginTargetCommand();
if (!this.requireTargetMap(target)) return;
const feature = await this.resolveTarget(target, requestRevision);
if (requestRevision !== this.targetRequestRevision) return;
if (feature) this.setHighlight(target);
}
clearHighlight = () => {
this.beginTargetCommand();
const map = this.options.getMap();
if (map && this.highlightedTarget) {
if (map && this.highlightedTarget && map.getSource(this.highlightedTarget.sourceId)) {
map.removeFeatureState(toFeatureStateTarget(this.highlightedTarget), "highlighted");
}
this.highlightedTarget = null;
this.patchState({ target: null, errorCode: null });
};
syncAvailableSources(sourceIds: readonly WaterNetworkSourceId[]) {
const availableSourceIds = new Set<WaterNetworkSourceId>(sourceIds);
if (this.availableSourceIds) {
DATA_DEPENDENT_GEOSERVER_SOURCE_IDS.forEach((sourceId) => {
if (this.availableSourceIds?.has(sourceId) && !availableSourceIds.has(sourceId)) {
this.sourceAvailabilityGenerations.set(
sourceId,
(this.sourceAvailabilityGenerations.get(sourceId) ?? 0) + 1
);
}
});
}
this.availableSourceIds = availableSourceIds;
DATA_DEPENDENT_GEOSERVER_SOURCE_IDS.forEach((sourceId) => {
if (availableSourceIds.has(sourceId)) return;
const cachePrefix = `${sourceId}:`;
for (const key of this.cachedFeatures.keys()) {
if (key.startsWith(cachePrefix)) this.cachedFeatures.delete(key);
}
});
const targetUnavailable = Boolean(
this.highlightedTarget && !availableSourceIds.has(this.highlightedTarget.sourceId)
);
const pendingTargetUnavailable = Boolean(
this.pendingTargetSourceId && !availableSourceIds.has(this.pendingTargetSourceId)
);
if (pendingTargetUnavailable) {
this.targetRequestRevision += 1;
this.pendingTargetSourceId = null;
}
if (targetUnavailable && this.highlightedTarget) {
const map = this.options.getMap();
if (map?.getSource(this.highlightedTarget.sourceId)) {
map.removeFeatureState(toFeatureStateTarget(this.highlightedTarget), "highlighted");
}
this.highlightedTarget = null;
}
const valueLabelEntries = Object.entries(this.state.valueLabels);
const availableValueLabelEntries = valueLabelEntries.filter(([sourceId]) =>
availableSourceIds.has(sourceId as WaterNetworkSourceId)
);
const valueLabelsChanged = availableValueLabelEntries.length !== valueLabelEntries.length;
const styledGroups = this.state.styledGroups.filter((groupId) =>
availableSourceIds.has(groupId)
);
const styledGroupsChanged = styledGroups.length !== this.state.styledGroups.length;
if (
!targetUnavailable &&
!pendingTargetUnavailable &&
!valueLabelsChanged &&
!styledGroupsChanged
) return;
this.patchState({
target: targetUnavailable ? null : this.state.target,
pending: pendingTargetUnavailable ? false : this.state.pending,
errorCode: pendingTargetUnavailable ? "WFS_UNAVAILABLE" : this.state.errorCode,
valueLabels: valueLabelsChanged
? Object.fromEntries(availableValueLabelEntries) as WorkbenchMapControllerState["valueLabels"]
: this.state.valueLabels,
styledGroups
});
}
async setFlowVisible(visible: boolean) {
const map = this.requireMap();
if (!map) return;
@@ -320,16 +404,16 @@ export class WorkbenchMapController implements WorkbenchMapCommands {
try {
sourceCollection = await this.fetchFeatures(
"scada",
items.map((item) => item.sensor_id),
items.map((item) => item.device_id),
signal
);
} catch {
if (revision === this.scadaAnalysisRevision) {
this.patchState({ pending: false, errorCode: "WFS_UNAVAILABLE" });
this.patchState({ pending: false, errorCode: "SCADA_API_UNAVAILABLE" });
}
throw new Error(
revision === this.scadaAnalysisRevision
? "WFS_UNAVAILABLE"
? "SCADA_API_UNAVAILABLE"
: "ACTION_SUPERSEDED"
);
}
@@ -440,36 +524,66 @@ export class WorkbenchMapController implements WorkbenchMapCommands {
destroy() {
this.flowUpdateRevision += 1;
this.targetRequestRevision += 1;
this.pendingTargetSourceId = null;
const map = this.options.getMap();
if (map) hideFlowOverlay(map, this.flowState);
this.listeners.clear();
}
private async resolveTarget(target: FeatureTarget) {
private beginTargetCommand() {
const requestRevision = ++this.targetRequestRevision;
if (this.pendingTargetSourceId) {
this.pendingTargetSourceId = null;
this.patchState({ pending: false });
}
return requestRevision;
}
private async resolveTarget(target: FeatureTarget, requestRevision: number) {
const map = this.requireMap();
if (!map) return null;
const key = `${target.sourceId}:${target.featureId}`;
const cached = this.cachedFeatures.get(key);
const dataDependent = DATA_DEPENDENT_GEOSERVER_SOURCE_IDS.includes(
target.sourceId as (typeof DATA_DEPENDENT_GEOSERVER_SOURCE_IDS)[number]
);
const cached = dataDependent ? undefined : this.cachedFeatures.get(key);
if (cached) return cached;
const sourceAvailabilityGeneration = this.sourceAvailabilityGenerations.get(target.sourceId) ?? 0;
this.pendingTargetSourceId = target.sourceId;
this.patchState({ pending: true, errorCode: null });
try {
const collection = await this.fetchFeatures(target.sourceId, [target.featureId]);
if (
requestRevision !== this.targetRequestRevision ||
sourceAvailabilityGeneration !== (this.sourceAvailabilityGenerations.get(target.sourceId) ?? 0)
) return null;
this.pendingTargetSourceId = null;
const feature = collection.features[0];
if (!feature) {
this.patchState({ pending: false, errorCode: "FEATURE_NOT_FOUND" });
return null;
}
this.cachedFeatures.set(key, feature);
if (!dataDependent) this.cachedFeatures.set(key, feature);
this.patchState({ pending: false });
return feature;
} catch {
this.patchState({ pending: false, errorCode: "WFS_UNAVAILABLE" });
if (requestRevision !== this.targetRequestRevision) return null;
this.pendingTargetSourceId = null;
this.patchState({
pending: false,
errorCode: target.sourceId === "scada" ? "SCADA_API_UNAVAILABLE" : "WFS_UNAVAILABLE"
});
return null;
}
}
private async fetchFeatures(sourceId: WaterNetworkSourceId, featureIds?: string[], signal?: AbortSignal) {
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 });
if (!parsed.ok) throw new Error(parsed.code);
const response = await fetch(createMapFeatureWfsUrl(parsed.value), {
@@ -484,9 +598,11 @@ export class WorkbenchMapController implements WorkbenchMapCommands {
}
private setHighlight(target: FeatureTarget) {
const map = this.requireMap();
const map = this.requireTargetMap(target);
if (!map) return;
if (this.highlightedTarget) map.removeFeatureState(toFeatureStateTarget(this.highlightedTarget), "highlighted");
if (this.highlightedTarget && map.getSource(this.highlightedTarget.sourceId)) {
map.removeFeatureState(toFeatureStateTarget(this.highlightedTarget), "highlighted");
}
map.setFeatureState(toFeatureStateTarget(target), { highlighted: true });
this.highlightedTarget = target;
this.patchState({ target, errorCode: null });
@@ -501,6 +617,16 @@ export class WorkbenchMapController implements WorkbenchMapCommands {
return map;
}
private requireTargetMap(target: FeatureTarget) {
const map = this.requireMap();
if (!map) return null;
if (!map.getSource(target.sourceId)) {
this.setError("FEATURE_NOT_FOUND");
return null;
}
return map;
}
private setError(errorCode: WorkbenchMapErrorCode) {
this.patchState({ errorCode });
}
@@ -516,7 +642,9 @@ function createEmptyPipeFlowSummary(): PipeFlowSummary {
}
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[] {
@@ -4,15 +4,33 @@ import {
canCenterTimelineBesideMapScale,
clusterOperationalEvents,
createOperationalEvents,
DEFAULT_OPERATIONAL_EVENT_VISIBILITY,
filterOperationalEvents,
getOperationalEventCategory,
OPERATIONAL_TIMELINE_LAYOUT,
resolveTimelineScaleLayout
} from "./operational-timeline-model";
import type { OperationalEvent } from "./operational-timeline-model";
const workOrder: ScheduledConditionItem = {
id: "work-1", kind: "work_order", code: "WO-1", scheduledAt: "2026-08-19T10:00:00.000Z",
title: "阀门调整", summary: "调整阀门", status: "running", riskLevel: "attention", updatedAt: 1,
durationMinutes: 40, source: "调度方案", location: "边界阀门 BV-07", dispatcher: "调度",
assignee: "班组", priority: "urgent", replyWindowMinutes: 20, stages: [], replyRequirements: []
id: "work-1",
kind: "work_order",
code: "WO-1",
scheduledAt: "2026-08-19T10:00:00.000Z",
title: "阀门调整",
summary: "调整阀门",
status: "running",
riskLevel: "attention",
updatedAt: 1,
durationMinutes: 40,
source: "调度方案",
location: "边界阀门 BV-07",
dispatcher: "调度",
assignee: "班组",
priority: "urgent",
replyWindowMinutes: 20,
stages: [],
replyRequirements: []
};
describe("operational timeline model", () => {
@@ -26,13 +44,57 @@ describe("operational timeline model", () => {
expect(events[0].correlationId).toBe(events[1].correlationId);
});
it("filters event lanes from the interactive legend", () => {
const events = createOperationalEvents([workOrder]);
const filtered = filterOperationalEvents(events, {
...DEFAULT_OPERATIONAL_EVENT_VISIBILITY,
actual: false
});
expect(filtered.map((event) => event.lane)).toEqual(["plan"]);
});
it("separates attention events from failed exceptions", () => {
const [planEvent] = createOperationalEvents([workOrder]);
const attentionEvent = {
...planEvent,
id: "attention-1",
lane: "exception",
status: "delayed"
} satisfies OperationalEvent;
const exceptionEvent = {
...attentionEvent,
id: "exception-1",
status: "failed"
} satisfies OperationalEvent;
expect(getOperationalEventCategory(attentionEvent)).toBe("attention");
expect(getOperationalEventCategory(exceptionEvent)).toBe("exception");
expect(filterOperationalEvents([attentionEvent, exceptionEvent], {
...DEFAULT_OPERATIONAL_EVENT_VISIBILITY,
attention: false
})).toEqual([exceptionEvent]);
});
it("only promotes abnormal condition runs into exception events", () => {
const normal = {
id: "condition-normal", kind: "condition", taskId: "scada-diagnosis", sessionId: "s-1",
scheduledAt: "2026-08-19T10:00:00.000Z", title: "诊断", summary: "正常", status: "completed",
riskLevel: "normal", updatedAt: 1
id: "condition-normal",
kind: "condition",
taskId: "scada-diagnosis",
sessionId: "s-1",
scheduledAt: "2026-08-19T10:00:00.000Z",
title: "诊断",
summary: "正常",
status: "completed",
riskLevel: "normal",
updatedAt: 1
} satisfies ScheduledConditionItem;
const abnormal = {
...normal,
id: "condition-error",
status: "error",
riskLevel: "critical"
} satisfies ScheduledConditionItem;
const abnormal = { ...normal, id: "condition-error", status: "error", riskLevel: "critical" } satisfies ScheduledConditionItem;
expect(createOperationalEvents([normal])).toHaveLength(0);
expect(createOperationalEvents([abnormal])[0].lane).toBe("exception");
@@ -1,10 +1,16 @@
import type { FeatureTarget } from "../map/workbench-map-controller";
import type { ScheduledConditionItem, ScheduledConditionRecord, ScheduledWorkOrderItem } from "../types";
import type {
ScheduledConditionItem,
ScheduledConditionRecord,
ScheduledWorkOrderItem
} from "../types";
export type OperationalTimelineMode = "compact" | "expanded" | "summary";
export type OperationalEventLane = "plan" | "actual" | "exception";
export type OperationalEventStatus = "planned" | "executed" | "delayed" | "failed" | "cancelled";
export type OperationalEventImportance = "normal" | "important" | "critical";
export type OperationalEventCategory = "plan" | "actual" | "attention" | "exception";
export type OperationalEventVisibility = Record<OperationalEventCategory, boolean>;
export type OperationalEvent = {
id: string;
@@ -42,6 +48,13 @@ export const OPERATIONAL_TIMELINE_LAYOUT = {
overlayReservedHeight: 0
} as const;
export const DEFAULT_OPERATIONAL_EVENT_VISIBILITY: OperationalEventVisibility = {
plan: true,
actual: true,
attention: true,
exception: true
};
export type TimelineScaleLayout = {
scaleBottom: number;
timelineBottom: number;
@@ -54,11 +67,10 @@ export function canCenterTimelineBesideMapScale(
) {
if (!visible) return true;
const layout = OPERATIONAL_TIMELINE_LAYOUT[mode];
return businessWorkspaceWidth >=
return (
businessWorkspaceWidth >=
layout.maxWidth +
2 * (
OPERATIONAL_TIMELINE_LAYOUT.mapScaleSafeWidth +
OPERATIONAL_TIMELINE_LAYOUT.floatingGap
2 * (OPERATIONAL_TIMELINE_LAYOUT.mapScaleSafeWidth + OPERATIONAL_TIMELINE_LAYOUT.floatingGap)
);
}
@@ -83,9 +95,11 @@ export function resolveTimelineScaleLayout(
}
export function createOperationalEvents(items: ScheduledConditionItem[]): OperationalEvent[] {
return items.flatMap((item) =>
return items
.flatMap((item) =>
item.kind === "work_order" ? createWorkOrderEvents(item) : createConditionEvents(item)
).sort((left, right) => Date.parse(left.cursorTime) - Date.parse(right.cursorTime));
)
.sort((left, right) => Date.parse(left.cursorTime) - Date.parse(right.cursorTime));
}
export function clusterOperationalEvents(
@@ -116,17 +130,33 @@ export function getOperationalEventSummary(events: OperationalEvent[]) {
return {
planned: events.filter((event) => event.lane === "plan").length,
active: events.filter((event) => event.lane === "actual").length,
exceptions: events.filter((event) => event.lane === "exception").length,
attention: events.filter((event) => getOperationalEventCategory(event) === "attention").length,
exceptions: events.filter((event) => getOperationalEventCategory(event) === "exception").length,
critical: events.filter((event) => event.importance === "critical").length
};
}
export function getOperationalEventCategory(
event: OperationalEvent
): OperationalEventCategory {
if (event.lane === "plan" || event.lane === "actual") return event.lane;
return event.status === "failed" ? "exception" : "attention";
}
export function filterOperationalEvents(
events: OperationalEvent[],
visibility: OperationalEventVisibility
) {
return events.filter((event) => visibility[getOperationalEventCategory(event)]);
}
function createConditionEvents(item: ScheduledConditionRecord): OperationalEvent[] {
if (item.status !== "warning" && item.status !== "error") {
return [];
}
return [{
return [
{
id: `exception-${item.id}`,
title: item.title,
description: item.report?.conclusion ?? item.summary,
@@ -141,7 +171,8 @@ function createConditionEvents(item: ScheduledConditionRecord): OperationalEvent
conditionId: item.id,
sourceLabel: "工况诊断",
mapTargets: getConditionMapTargets(item)
}];
}
];
}
function createWorkOrderEvents(item: ScheduledWorkOrderItem): OperationalEvent[] {
@@ -9,15 +9,20 @@ import {
TriangleAlert
} from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { useState } from "react";
import { cn } from "@/shared/ui/cn";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/shared/ui/tooltip";
import {
clusterOperationalEvents,
DEFAULT_OPERATIONAL_EVENT_VISIBILITY,
filterOperationalEvents,
getOperationalEventSummary,
OPERATIONAL_TIMELINE_LAYOUT,
type OperationalEvent,
type OperationalEventCategory,
type OperationalEventCluster,
type OperationalEventLane,
type OperationalEventVisibility,
type OperationalTimelineMode
} from "./operational-timeline-model";
@@ -40,6 +45,13 @@ const LANES: Array<{ id: OperationalEventLane; label: string }> = [
{ id: "exception", label: "异常" }
];
const LEGEND_ITEMS: Array<{ id: OperationalEventCategory; label: string }> = [
{ id: "plan", label: "计划" },
{ id: "actual", label: "实际" },
{ id: "attention", label: "关注" },
{ id: "exception", label: "异常" }
];
export function OperationalTimeline({
date,
cursorTime,
@@ -53,8 +65,12 @@ export function OperationalTimeline({
onReturnNow
}: OperationalTimelineProps) {
const prefersReducedMotion = useReducedMotion();
const [eventVisibility, setEventVisibility] = useState<OperationalEventVisibility>(() => ({
...DEFAULT_OPERATIONAL_EVENT_VISIBILITY
}));
const summary = getOperationalEventSummary(events);
const clusters = clusterOperationalEvents(events, mobile ? 60 : 10);
const visibleEvents = filterOperationalEvents(events, eventVisibility);
const clusters = clusterOperationalEvents(visibleEvents, mobile ? 60 : 10);
const selectedEvent = events.find((event) => event.id === selectedEventId) ?? null;
const cursorPosition = getDayPosition(cursorTime);
const height = mobile ? "100%" : OPERATIONAL_TIMELINE_LAYOUT[mode].height;
@@ -86,6 +102,10 @@ export function OperationalTimeline({
) : (
<span className="min-w-0 truncate text-xs text-slate-500"></span>
)}
<TimelineLegend
visibility={eventVisibility}
onVisibilityChange={setEventVisibility}
/>
<TimelineActions mode={mode} onModeChange={onModeChange} onReturnNow={onReturnNow} />
</motion.section>
);
@@ -114,12 +134,19 @@ export function OperationalTimeline({
<div className="flex min-w-0 items-center gap-2">
<Clock3 size={15} className="shrink-0 text-blue-600" aria-hidden="true" />
<div className="flex items-center gap-2">
<p className={cn("text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500", mode === "compact" && !mobile && "hidden")}></p>
<p
className={cn(
"text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500",
((mode === "compact" && !mobile) || mobile) && "hidden"
)}
>
</p>
<p className="text-xs font-semibold text-slate-900">{formatDate(date)}</p>
</div>
</div>
<span className="hidden h-5 w-px bg-slate-300 sm:block" />
<TimelineSummary summary={summary} />
{!mobile ? <TimelineSummary summary={summary} /> : null}
{selectedEvent && !mobile ? (
<button
type="button"
@@ -130,6 +157,10 @@ export function OperationalTimeline({
{formatClock(selectedEvent.cursorTime)} · {selectedEvent.title}
</button>
) : null}
<TimelineLegend
visibility={eventVisibility}
onVisibilityChange={setEventVisibility}
/>
<TimelineActions mode={mode} onModeChange={onModeChange} onReturnNow={onReturnNow} />
</div>
@@ -137,16 +168,18 @@ export function OperationalTimeline({
{mode === "expanded" || mobile ? (
<motion.div
key="expanded-timeline"
className="grid min-h-0 flex-1 grid-cols-[52px_minmax(0,1fr)] px-3 pb-2 pt-1.5 sm:grid-cols-[76px_minmax(0,1fr)]"
className="grid min-h-0 flex-1 grid-cols-[40px_minmax(0,1fr)] px-2 pb-2 pt-1.5 sm:grid-cols-[48px_minmax(0,1fr)]"
initial={prefersReducedMotion ? false : { opacity: 0, y: 5 }}
animate={{ opacity: 1, y: 0 }}
exit={prefersReducedMotion ? { opacity: 0 } : { opacity: 0, y: -4 }}
transition={prefersReducedMotion ? { duration: 0 } : { duration: 0.14 }}
>
<div className="grid grid-rows-[18px_repeat(3,1fr)] pr-2 text-[10px] text-slate-500">
<div className="grid grid-rows-[18px_repeat(3,1fr)] text-[11px] text-slate-500">
<span />
{LANES.map((lane) => (
<span key={lane.id} className="flex items-center font-semibold">{lane.label}</span>
<span key={lane.id} className="flex items-center justify-center font-semibold">
{lane.label}
</span>
))}
</div>
<div className="surface-reading relative grid min-h-[142px] grid-rows-[18px_repeat(3,1fr)] rounded-xl border px-2">
@@ -262,7 +295,8 @@ function TimelineMarker({
"rounded-md border border-blue-400 bg-blue-50 text-blue-700 shadow-[0_2px_7px_rgba(37,99,235,0.28)]",
event.lane === "actual" &&
"rounded-full border border-emerald-300 bg-emerald-500 text-white shadow-[0_2px_8px_rgba(16,185,129,0.34)]",
exception && event.status !== "failed" &&
exception &&
event.status !== "failed" &&
"rounded-[7px] border border-amber-300 bg-amber-100 text-amber-800 shadow-[0_2px_9px_rgba(245,158,11,0.38)]",
event.status === "failed" &&
"rounded-[7px] border border-rose-300 bg-rose-500 text-white shadow-[0_2px_10px_rgba(244,63,94,0.42)]",
@@ -279,23 +313,58 @@ function TimelineMarker({
) : null}
</button>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-72 bg-white px-3 py-2 text-slate-900 shadow-xl">
<div className="flex items-center gap-2">
<span className={cn(
"rounded-md px-1.5 py-0.5 text-[10px] font-semibold",
event.lane === "plan" && "bg-blue-50 text-blue-700",
event.lane === "actual" && "bg-emerald-50 text-emerald-700",
event.lane === "exception" && event.status !== "failed" && "bg-amber-50 text-amber-800",
event.status === "failed" && "bg-rose-50 text-rose-700"
)}>
<TooltipContent
side="top"
sideOffset={10}
className={cn(
"acrylic-panel w-[min(300px,calc(100vw-24px))] overflow-hidden rounded-2xl border p-1 text-slate-900 shadow-[0_18px_48px_rgba(15,23,42,0.18)]"
)}
>
<div className="surface-reading min-w-0 rounded-[13px] border p-3">
<div className="flex items-start gap-2.5">
<span
className={cn(
"grid h-8 w-8 shrink-0 place-items-center rounded-xl border",
event.lane === "plan" && "border-blue-200 bg-blue-50 text-blue-700",
event.lane === "actual" && "border-emerald-200 bg-emerald-50 text-emerald-700",
event.lane === "exception" &&
event.status !== "failed" &&
"border-amber-200 bg-amber-50 text-amber-800",
event.status === "failed" && "border-rose-200 bg-rose-50 text-rose-700"
)}
>
<OperationalEventGlyph event={event} />
</span>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-2">
<span
className={cn(
"text-[10px] font-bold tracking-[0.08em]",
event.lane === "plan" && "text-blue-700",
event.lane === "actual" && "text-emerald-700",
event.lane === "exception" && event.status !== "failed" && "text-amber-800",
event.status === "failed" && "text-rose-700"
)}
>
{operationalEventNatureLabel(event)}
</span>
<span className="text-[10px] text-slate-500">{event.sourceLabel}</span>
<time className="text-[11px] font-semibold tabular-nums text-slate-700">
{formatClock(event.cursorTime)}
</time>
</div>
<p className="mt-0.5 truncate text-[10px] text-slate-500">{event.sourceLabel}</p>
</div>
</div>
<p className="mt-2.5 text-[13px] font-semibold leading-5 text-slate-950">
{event.title}
</p>
<p className="mt-1 line-clamp-2 text-[11px] leading-[18px] text-slate-600">
{event.description}
</p>
{cluster.events.length > 1 ? (
<p className="mt-2 text-[10px] text-slate-500"> {cluster.events.length} </p>
) : null}
</div>
<p className="mt-1.5 font-semibold">{formatClock(event.cursorTime)} · {event.title}</p>
<p className="mt-1 leading-5 text-slate-600">{event.description}</p>
{cluster.events.length > 1 ? <p className="mt-1 text-slate-500"> {cluster.events.length} </p> : null}
{event.conditionId ? <p className="mt-1 font-medium text-blue-700"></p> : null}
</TooltipContent>
</Tooltip>
);
@@ -315,8 +384,8 @@ function OperationalEventGlyph({ event }: { event: OperationalEvent }) {
}
function operationalEventNatureLabel(event: OperationalEvent) {
if (event.status === "failed") return "关键异常";
if (event.lane === "exception") return "运行偏差";
if (event.status === "failed") return "异常";
if (event.lane === "exception") return "关注";
if (event.lane === "actual") return "实际执行";
return "计划任务";
}
@@ -324,9 +393,18 @@ function operationalEventNatureLabel(event: OperationalEvent) {
function TimelineSummary({ summary }: { summary: ReturnType<typeof getOperationalEventSummary> }) {
return (
<div className="flex shrink-0 items-center gap-2 text-[10px] text-slate-500 sm:gap-3">
<span> <strong className="text-blue-700">{summary.planned}</strong></span>
<span> <strong className="text-emerald-700">{summary.active}</strong></span>
<span> <strong className="text-amber-700">{summary.exceptions}</strong></span>
<span>
<strong className="text-blue-700">{summary.planned}</strong>
</span>
<span>
<strong className="text-emerald-700">{summary.active}</strong>
</span>
<span>
<strong className="text-amber-700">{summary.attention}</strong>
</span>
<span>
<strong className="text-rose-700">{summary.exceptions}</strong>
</span>
</div>
);
}
@@ -341,7 +419,7 @@ function TimelineActions({
onReturnNow: () => void;
}) {
return (
<div className="surface-control ml-auto flex shrink-0 items-center rounded-xl border border-white/70 p-0.5 shadow-[0_4px_14px_rgba(15,23,42,0.08)]">
<div className="surface-control flex shrink-0 items-center rounded-xl border border-white/70 p-0.5 shadow-[0_4px_14px_rgba(15,23,42,0.08)]">
<button
type="button"
title="返回现在"
@@ -375,6 +453,60 @@ function TimelineActions({
);
}
function TimelineLegend({
visibility,
onVisibilityChange
}: {
visibility: OperationalEventVisibility;
onVisibilityChange: (visibility: OperationalEventVisibility) => void;
}) {
return (
<div
aria-label="时间轴图例"
className="ml-auto flex shrink-0 items-center gap-0.5 px-1"
>
{LEGEND_ITEMS.map((item) => {
const visible = visibility[item.id];
return (
<button
key={item.id}
type="button"
title={`${visible ? "隐藏" : "显示"}${item.label}事件`}
aria-label={`${visible ? "隐藏" : "显示"}${item.label}事件`}
aria-pressed={visible}
onClick={() =>
onVisibilityChange({
...visibility,
[item.id]: !visible
})
}
className={cn(
"grid h-7 w-7 place-items-center rounded-lg outline-hidden transition-[transform,filter,opacity] hover:scale-110 focus-visible:ring-2 focus-visible:ring-blue-500 active:scale-95",
visible ? "opacity-100" : "grayscale opacity-25"
)}
>
<LegendSwatch category={item.id} />
</button>
);
})}
</div>
);
}
function LegendSwatch({ category }: { category: OperationalEventCategory }) {
return (
<span
className={cn(
"block h-3.5 w-3.5 shrink-0 border",
category === "plan" && "rounded-[4px] border-blue-500 bg-blue-100",
category === "actual" && "rounded-full border-emerald-300 bg-emerald-500",
category === "attention" && "rotate-45 rounded-[3px] border-amber-300 bg-amber-100",
category === "exception" && "rounded-[4px] border-rose-300 bg-rose-500"
)}
/>
);
}
function TimeTicks() {
return (
<div className="relative text-[9px] text-slate-600">
@@ -414,7 +546,9 @@ function CompactTimeTicks() {
}
function pickClusterEvent(events: OperationalEvent[]) {
return [...events].sort((left, right) => importanceRank(right.importance) - importanceRank(left.importance))[0];
return [...events].sort(
(left, right) => importanceRank(right.importance) - importanceRank(left.importance)
)[0];
}
function importanceRank(value: OperationalEvent["importance"]) {
@@ -428,7 +562,11 @@ function getDayPosition(value: string) {
}
function formatClock(value: string) {
return new Intl.DateTimeFormat("zh-CN", { hour: "2-digit", minute: "2-digit", hour12: false }).format(new Date(value));
return new Intl.DateTimeFormat("zh-CN", {
hour: "2-digit",
minute: "2-digit",
hour12: false
}).format(new Date(value));
}
function formatDate(value: string) {
@@ -8,8 +8,8 @@ import {
describe("feature panel properties", () => {
it.each([
["pipes", ["id", "diameter", "length", "material"]],
["junctions", ["id", "elevation", "demand", "pressure"]]
["pipes", ["id", "start_node_id", "end_node_id", "diameter", "length", "roughness", "status"]],
["junctions", ["id", "elevation", "base_demand"]]
] as const)("shows the configured %s fields", (layer, expectedKeys) => {
const entries = getFeaturePanelProperties(layer, {}, "feature-id");
@@ -20,16 +20,22 @@ describe("feature panel properties", () => {
it("formats pipe diameter from GeoServer DN millimeter values", () => {
const entries = getFeaturePanelProperties("pipes", {
id: "P-1",
start_node_id: "J-1",
end_node_id: "J-2",
diameter: 600,
length: 42.5,
material: "DI"
roughness: 120,
status: "OPEN"
});
expect(entries.map(({ label, value }) => [label, value])).toEqual([
["编号", "P-1"],
["起点节点", "J-1"],
["终点节点", "J-2"],
["管径", "600 mm"],
["长度", "42.50 m"],
["材质", "球墨铸铁"]
["粗糙系数", "120"],
["状态", "开启"]
]);
});
@@ -37,14 +43,14 @@ describe("feature panel properties", () => {
const metrics = getFeatureInsightMetrics("pipes", {
diameter: 600,
length: 42.5,
material: "DI",
roughness: 120,
status: "OPEN"
});
expect(metrics).toEqual([
{ label: "管径", value: "600 mm" },
{ label: "长度", value: "42.50 m" },
{ label: "材质", value: "球墨铸铁" },
{ label: "粗糙系数", value: "120" },
{ label: "状态", value: "开启" }
]);
});
@@ -52,56 +58,62 @@ describe("feature panel properties", () => {
it("localizes common enumerated property values in the panel", () => {
expect(getFeaturePanelProperties("valves", {
id: "V-1",
node1: "J-1",
node2: "J-2",
start_node_id: "J-1",
end_node_id: "J-2",
diameter: 300,
v_type: "PRV",
setting: "OPEN"
valve_type: "PRV",
setting: "OPEN",
minor_loss: 0.2
}).map(({ label, value }) => [label, value])).toEqual([
["编号", "V-1"],
["起点节点", "J-1"],
["终点节点", "J-2"],
["管径", "300 mm"],
["阀门类型", "减压阀"],
["阀门设定", "开启"]
["阀门设定", "开启"],
["局部损失系数", "0.20"]
]);
expect(getFeaturePanelProperties("scada", {
id: "S-1",
type: "pressure",
associated_element_id: "J-1",
device_id: "S-1",
device_type: "pressure",
node_id: "J-1",
link_id: null,
api_query_id: "query-1",
transmission_mode: "non_realtime",
transmission_frequency: "15min",
reliability: "high"
}).map(({ label, value }) => [label, value])).toEqual([
["编号", "S-1"],
["类型", "压力监测"],
["关联资产", "J-1"],
["设备 ID", "S-1"],
["设备类型", "压力监测"],
["关联节点", "J-1"],
["关联连线", "暂无"],
["采集接口", "query-1"],
["传输方式", "非实时"],
["传输频率", "15min"],
["可靠性", "高"]
]);
expect(getFeaturePanelProperties("scada", {
id: "S-2",
type: "pipe_flow"
}).find((entry) => entry.key === "type")?.value).toBe("流量监测");
device_id: "S-2",
device_type: "pipe_flow"
}).find((entry) => entry.key === "device_type")?.value).toBe("流量监测");
});
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.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([
["valves", ["id", "node1", "node2", "diameter", "v_type", "setting"]],
["reservoirs", ["id", "head", "pattern"]],
["scada", ["id", "type", "associated_element_id", "transmission_mode", "transmission_frequency", "reliability"]],
["pumps", ["id", "node1", "node2", "status"]],
["tanks", ["id", "elevation", "init_level", "min_level", "max_level"]]
["valves", ["id", "start_node_id", "end_node_id", "diameter", "valve_type", "setting", "minor_loss"]],
["reservoirs", ["id", "head", "pattern_id"]],
["scada", ["device_id", "device_type", "node_id", "link_id", "api_query_id", "transmission_mode", "transmission_frequency", "reliability"]],
["pumps", ["id", "start_node_id", "end_node_id", "power", "head_curve_id", "speed", "pattern_id"]],
["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) => {
const entries = getFeaturePanelProperties(layer, {}, "feature-id");
@@ -112,7 +124,7 @@ describe("feature panel properties", () => {
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_]*$/));
});
});
@@ -29,8 +29,6 @@ const PROPERTY_LABELS: Record<string, string> = {
fid: "GeoServer 要素 ID",
id: "编号",
scada_id: "编号 UUID",
sensor_id: "传感器 ID",
sensor_name: "传感器名称",
swmm_node: "SWMM 节点",
topology_order: "拓扑序",
distance_to_wwtp_m: "距处理厂距离",
@@ -48,8 +46,11 @@ const PROPERTY_LABELS: Record<string, string> = {
name: "名称",
code: "编码",
type: "类型",
device_type: "设备类型",
node1: "起点节点",
node2: "终点节点",
start_node_id: "起点节点",
end_node_id: "终点节点",
from_node: "起点节点",
to_node: "终点节点",
diameter: "管径",
@@ -60,6 +61,7 @@ const PROPERTY_LABELS: Record<string, string> = {
max_depth: "最大深度",
invert_elevation: "井底高程",
v_type: "阀门类型",
valve_type: "阀门类型",
setting: "阀门设定",
minor_loss: "局部损失系数",
orifice_type: "孔口类型",
@@ -76,12 +78,24 @@ const PROPERTY_LABELS: Record<string, string> = {
status: "状态",
head: "水头",
pattern: "模式",
pattern_id: "模式",
head_curve_id: "扬程曲线",
volume_curve_id: "容积曲线",
init_level: "初始水位",
min_level: "最低水位",
max_level: "最高水位",
initial_level: "初始水位",
minimum_level: "最低水位",
maximum_level: "最高水位",
minimum_volume: "最小容积",
overflow: "允许溢流",
power: "功率",
speed: "转速倍率",
material: "材质",
elevation: "高程",
demand: "需水量",
base_demand: "基础需水量",
demands: "需水配置",
pressure: "压力",
flow: "流量",
velocity: "流速",
@@ -93,6 +107,9 @@ const PROPERTY_LABELS: Record<string, string> = {
point_external_id: "测点编号",
point_name: "测点名称",
associated_element_id: "关联资产",
node_id: "关联节点",
link_id: "关联连线",
api_query_id: "采集接口",
transmission_mode: "传输方式",
transmission_frequency: "传输频率",
reliability: "可靠性",
@@ -114,8 +131,6 @@ const PROPERTY_ORDER: Record<string, number> = {
fid: 9,
id: 10,
scada_id: 10,
sensor_id: 10,
sensor_name: 12,
swmm_node: 13,
topology_order: 14,
distance_to_wwtp_m: 15,
@@ -143,10 +158,13 @@ const PROPERTY_ORDER: Record<string, number> = {
material: 23,
node1: 24,
from_node: 24,
start_node_id: 24,
node2: 25,
to_node: 25,
end_node_id: 25,
elevation: 30,
demand: 31,
base_demand: 31,
pressure: 32,
flow: 33,
velocity: 34,
@@ -275,13 +293,13 @@ const PROPERTY_UNITS: Record<string, string> = {
};
const FEATURE_PANEL_PROPERTY_KEYS: Record<WaterNetworkSourceId, readonly string[]> = {
pipes: ["id", "diameter", "length", "material"],
junctions: ["id", "elevation", "demand", "pressure"],
valves: ["id", "node1", "node2", "diameter", "v_type", "setting"],
reservoirs: ["id", "head", "pattern"],
scada: ["id", "type", "associated_element_id", "transmission_mode", "transmission_frequency", "reliability"],
pumps: ["id", "node1", "node2", "status"],
tanks: ["id", "elevation", "init_level", "min_level", "max_level"]
pipes: ["id", "start_node_id", "end_node_id", "diameter", "length", "roughness", "status"],
junctions: ["id", "elevation", "base_demand"],
valves: ["id", "start_node_id", "end_node_id", "diameter", "valve_type", "setting", "minor_loss"],
reservoirs: ["id", "head", "pattern_id"],
scada: ["device_id", "device_type", "node_id", "link_id", "api_query_id", "transmission_mode", "transmission_frequency", "reliability"],
pumps: ["id", "start_node_id", "end_node_id", "power", "head_curve_id", "speed", "pattern_id"],
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>> = {
@@ -299,42 +317,44 @@ const FEATURE_INSIGHT_METRICS: Record<WaterNetworkSourceId, readonly FeatureInsi
pipes: [
{ label: "管径", key: "diameter" },
{ label: "长度", key: "length" },
{ label: "材质", key: "material" },
{ label: "粗糙系数", key: "roughness" },
{ label: "状态", key: "status" }
],
junctions: [
{ label: "需水量", key: "demand" },
{ label: "基础需水量", key: "base_demand" },
{ label: "高程", key: "elevation" },
{ label: "压力", key: "pressure" },
{ label: "状态", value: "在线" }
{ label: "X 坐标", key: "x" },
{ label: "Y 坐标", key: "y" }
],
valves: [
{ label: "管径", key: "diameter" },
{ label: "类型", key: "v_type" },
{ label: "类型", key: "valve_type" },
{ label: "设定", key: "setting" },
{ label: "局部损失", key: "minor_loss" }
],
reservoirs: [
{ label: "水头", key: "head" },
{ label: "模式", key: "pattern" },
{ label: "状态", value: "在线" }
{ label: "模式", key: "pattern_id" },
{ label: "X 坐标", key: "x" },
{ label: "Y 坐标", key: "y" }
],
scada: [
{ label: "类型", key: "type" },
{ label: "关联资产", key: "associated_element_id" },
{ label: "传输方式", key: "transmission_mode" },
{ label: "可靠性", key: "reliability" }
{ label: "类型", key: "device_type" },
{ label: "关联节点", key: "node_id" },
{ label: "关联连线", key: "link_id" },
{ label: "传输方式", key: "transmission_mode" }
],
pumps: [
{ label: "起点", key: "node1" },
{ label: "终点", key: "node2" },
{ label: "状态", key: "status" }
{ label: "功率", key: "power" },
{ label: "扬程曲线", key: "head_curve_id" },
{ label: "转速倍率", key: "speed" },
{ label: "模式", key: "pattern_id" }
],
tanks: [
{ label: "高程", key: "elevation" },
{ label: "初始水位", key: "init_level" },
{ label: "最水位", key: "max_level" },
{ label: "状态", value: "在线" }
{ label: "初始水位", key: "initial_level" },
{ label: "最水位", key: "minimum_level" },
{ label: "最高水位", key: "maximum_level" }
]
};
@@ -353,7 +373,7 @@ export function getFeaturePanelProperties(
): LocalizedFeatureProperty[] {
return FEATURE_PANEL_PROPERTY_KEYS[layer].map((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 {
key,
@@ -391,7 +411,7 @@ export function getFeaturePanelModel(
featureId?: string
): FeaturePanelModel {
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 badgeKey = FEATURE_PANEL_BADGE_KEYS[layer];
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;
}
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);
}
@@ -452,7 +472,7 @@ export function formatFeaturePropertyValue(key: string, value: unknown) {
: 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 !== "") {
return `${formatValue(value)} m`;
}
@@ -485,7 +505,7 @@ function getMappedPropertyValue(normalizedKey: string, value: string) {
return STATUS_LABELS[normalizedValue];
}
if (normalizedKey === "v_type") {
if (normalizedKey === "v_type" || normalizedKey === "valve_type") {
return VALVE_TYPE_LABELS[normalizedValue];
}
@@ -493,7 +513,7 @@ function getMappedPropertyValue(normalizedKey: string, value: string) {
return MATERIAL_LABELS[normalizedValue];
}
if (normalizedKey === "type") {
if (normalizedKey === "type" || normalizedKey === "device_type") {
return ASSET_TYPE_LABELS[normalizedValue];
}
@@ -505,7 +525,7 @@ function getMappedPropertyValue(normalizedKey: string, value: string) {
return RELIABILITY_LABELS[normalizedValue];
}
if (normalizedKey === "pattern") {
if (normalizedKey === "pattern" || normalizedKey === "pattern_id") {
return PATTERN_LABELS[normalizedValue];
}
@@ -7,52 +7,46 @@ import {
Clock3,
Database,
FileSearch,
Focus,
Map as MapIcon,
Minimize2,
PanelRightClose,
PanelRightOpen,
ShieldCheck,
Sparkles,
Trash2,
X
} from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { useEffect, useState } from "react";
import { showMapNotice } from "@/features/map/core";
import { cn } from "@/shared/ui/cn";
import {
ARTIFACT_DANGER_ICON_BUTTON_CLASS_NAME,
ARTIFACT_ICON_BUTTON_CLASS_NAME,
ARTIFACT_PRIMARY_ACTION_BUTTON_CLASS_NAME
} from "./artifact-view-control-styles";
import type {
AnalysisArtifact,
AnalysisArtifactAction,
AnalysisBlock,
AnalysisMetric,
ArtifactRequestedView,
WorkbenchSurfaceMode
AnalysisMetric
} from "./workspace-model";
type AnalysisArtifactWorkspaceProps = {
artifact: AnalysisArtifact;
surfaceMode: WorkbenchSurfaceMode;
mapSplitAvailable: boolean;
onSetView: (view: ArtifactRequestedView) => void;
onCollapse: () => void;
onDestroy: () => void;
};
type EvidenceBlock = Exclude<AnalysisBlock, { kind: "metric-grid" }>;
export function AnalysisArtifactWorkspace({
artifact,
surfaceMode,
mapSplitAvailable,
onSetView,
onCollapse,
onDestroy
}: AnalysisArtifactWorkspaceProps) {
const [selectedBlockId, setSelectedBlockId] = useState<string | null>(null);
const metricBlock = artifact.blocks.find((block) => block.kind === "metric-grid");
const evidenceBlocks = artifact.blocks.filter((block) => block.kind !== "metric-grid");
const selectedBlock = useMemo(
() => artifact.blocks.find((block) => block.id === selectedBlockId) ?? null,
[artifact.blocks, selectedBlockId]
const evidenceBlocks = artifact.blocks.filter(
(block): block is EvidenceBlock => block.kind !== "metric-grid"
);
const selectedBlock = evidenceBlocks.find((block) => block.id === selectedBlockId) ?? null;
useEffect(() => {
setSelectedBlockId(null);
@@ -116,11 +110,6 @@ export function AnalysisArtifactWorkspace({
<ArtifactActionBar
artifact={artifact}
surfaceMode={surfaceMode}
mapSplitAvailable={mapSplitAvailable}
detailOpen={Boolean(selectedBlock)}
onToggleDetail={() => setSelectedBlockId((current) => current ? null : evidenceBlocks[0]?.id ?? null)}
onSetView={onSetView}
onCollapse={onCollapse}
onDestroy={onDestroy}
/>
@@ -176,7 +165,7 @@ function AnalysisBlockView({
selected,
onSelect
}: {
block: Exclude<AnalysisBlock, { kind: "metric-grid" }>;
block: EvidenceBlock;
selected: boolean;
onSelect: () => void;
}) {
@@ -198,11 +187,11 @@ function AnalysisBlockView({
>
<button
type="button"
className="flex min-h-14 w-full items-start gap-3 px-4 py-3 text-left active:scale-[0.99]"
className="flex min-h-14 w-full items-start gap-3 px-4 py-3 text-left transition-colors hover:bg-blue-50/40 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-blue-500/60"
aria-label={`查看${block.title}详情`}
onClick={onSelect}
>
<span className="mt-0.5 grid h-8 w-8 shrink-0 place-items-center rounded-md bg-blue-50 text-blue-700">
<span className="mt-0.5 grid h-8 w-8 shrink-0 place-items-center rounded-lg bg-blue-50 text-blue-700">
<FileSearch size={16} aria-hidden="true" />
</span>
<span className="min-w-0 flex-1">
@@ -218,7 +207,7 @@ function AnalysisBlockView({
);
}
function AnalysisBlockBody({ block }: { block: Exclude<AnalysisBlock, { kind: "metric-grid" }> }) {
function AnalysisBlockBody({ block }: { block: EvidenceBlock }) {
if (block.kind === "chart") {
return (
<div className="h-[258px] min-h-0">
@@ -280,14 +269,28 @@ function AnalysisBlockBody({ block }: { block: Exclude<AnalysisBlock, { kind: "m
function ArtifactProvenance({ artifact }: { artifact: AnalysisArtifact }) {
return (
<section className="mt-5 grid gap-3 pb-2 xl:grid-cols-2" aria-label="成果依据与限制">
<div className="surface-well rounded-xl border px-4 py-3.5">
<h3 className="flex items-center gap-2 text-xs font-semibold text-slate-800"><Database size={14} aria-hidden="true" /></h3>
<ul className="mt-2 flex flex-wrap gap-2">{artifact.sources.map((source) => <li key={source} className="rounded-md bg-white px-2.5 py-1 text-xs text-slate-600 shadow-[0_1px_2px_rgba(15,23,42,0.08)]">{source}</li>)}</ul>
<section className="surface-well mt-5 grid overflow-hidden rounded-xl border xl:grid-cols-2 xl:divide-x xl:divide-slate-300/70" aria-label="成果依据与限制">
<div className="flex min-w-0 items-start gap-3 px-4 py-3">
<h3 className="flex shrink-0 items-center gap-1.5 pt-1 text-xs font-semibold text-slate-700">
<Database size={14} aria-hidden="true" />
</h3>
<ul className="flex min-w-0 flex-wrap gap-x-2 gap-y-1 pt-1 text-xs text-slate-600">
{artifact.sources.map((source) => (
<li key={source} className="after:ml-2 after:text-slate-300 after:content-['/'] last:after:hidden">{source}</li>
))}
</ul>
</div>
<div className="material-tone-warning rounded-xl border px-4 py-3.5">
<h3 className="flex items-center gap-2 text-xs font-semibold text-amber-900"><AlertTriangle size={14} aria-hidden="true" /></h3>
<ul className="mt-2 space-y-1 text-xs leading-5 text-amber-900/75">{artifact.limitations.map((item) => <li key={item}> {item}</li>)}</ul>
<div className="flex min-w-0 items-start gap-3 border-t border-slate-300/70 px-4 py-3 xl:border-t-0">
<h3 className="flex shrink-0 items-center gap-1.5 pt-0.5 text-xs font-semibold text-amber-800">
<AlertTriangle size={14} aria-hidden="true" />
</h3>
<ul className="min-w-0 space-y-1 text-xs leading-5 text-slate-600">
{artifact.limitations.map((item) => (
<li key={item} className="before:mr-2 before:text-amber-600 before:content-['·']">{item}</li>
))}
</ul>
</div>
</section>
);
@@ -299,79 +302,140 @@ function ArtifactDetailDrawer({
onClose
}: {
artifact: AnalysisArtifact;
block: AnalysisBlock;
block: EvidenceBlock;
onClose: () => void;
}) {
const blockFacts = getEvidenceBlockFacts(block);
return (
<aside aria-label="证据详情" className="surface-reading absolute bottom-[57px] right-0 top-0 z-30 w-full max-w-[420px] overflow-y-auto overscroll-contain border-l shadow-[-12px_0_36px_rgba(15,23,42,0.18)]">
<div className="sticky top-0 z-10 flex items-center justify-between bg-slate-950 px-4 py-3 text-slate-100">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.14em] text-blue-300">Evidence detail</p>
<h3 className="mt-1 text-sm font-semibold">{block.title}</h3>
<aside
aria-label="证据详情"
className="surface-reading absolute bottom-[57px] right-0 top-0 z-30 w-full max-w-[420px] overflow-y-auto overscroll-contain border-l border-slate-300/80 shadow-[-12px_0_32px_rgba(15,23,42,0.12)]"
>
<div className="surface-control sticky top-0 z-10 flex items-start justify-between gap-4 border-b border-slate-300/70 px-5 py-4">
<div className="flex min-w-0 items-start gap-3">
<FileSearch size={17} className="mt-0.5 shrink-0 text-blue-700" aria-hidden="true" />
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2 text-[11px] font-semibold text-slate-500">
<span className="uppercase tracking-[0.12em]"></span>
<span className="h-1 w-1 rounded-full bg-slate-300" aria-hidden="true" />
<span className="text-blue-700">{evidenceBlockKindLabel(block)}</span>
</div>
<button type="button" aria-label="关闭证据详情" onClick={onClose} className="grid h-10 w-10 place-items-center rounded-md text-slate-300 hover:bg-white/10 hover:text-white active:scale-95">
<X size={18} aria-hidden="true" />
<h3 className="mt-1 text-base font-semibold leading-6 text-slate-950">{block.title}</h3>
</div>
</div>
<button
type="button"
aria-label="关闭证据详情"
onClick={onClose}
className={ARTIFACT_ICON_BUTTON_CLASS_NAME}
>
<X size={15} aria-hidden="true" />
</button>
</div>
<div className="space-y-5 p-5">
<div>
<p className="text-xs font-semibold text-slate-500"></p>
<p className="mt-2 text-sm leading-7 text-slate-700">{artifact.summary}</p>
<div className="space-y-5 px-5 py-5">
<section aria-labelledby="evidence-purpose-heading">
<p id="evidence-purpose-heading" className="text-xs font-semibold text-slate-500"></p>
<p className="mt-2 text-sm leading-6 text-slate-700">
{evidenceBlockDescription(block)}
</p>
</section>
<section className="border-t border-slate-200 pt-5" aria-labelledby="evidence-data-heading">
<div className="flex items-center justify-between gap-3">
<h4 id="evidence-data-heading" className="text-xs font-semibold text-slate-500"></h4>
<span className="text-[11px] font-medium text-slate-400">Artifact R{artifact.revision}</span>
</div>
<div>
<p className="text-xs font-semibold text-slate-500"></p>
<p className="mt-2 text-sm leading-7 text-slate-700">{"description" in block ? block.description : "该模块汇总了当前成果中的关键判断依据。"}</p>
<dl className="mt-2 divide-y divide-slate-200">
{blockFacts.map((fact) => (
<div key={fact.label} className="flex items-center justify-between gap-4 py-2 text-xs">
<dt className="text-slate-500">{fact.label}</dt>
<dd className="font-medium text-slate-800 tabular-nums">{fact.value}</dd>
</div>
<div className="bg-slate-100 p-4">
<p className="text-xs font-semibold text-slate-700"></p>
<dl className="mt-3 grid grid-cols-[auto_1fr] gap-x-4 gap-y-2 text-xs">
<dt className="text-slate-500"></dt><dd className="text-right font-medium text-slate-800">R{artifact.revision}</dd>
<dt className="text-slate-500"></dt><dd className="text-right font-medium text-slate-800">{artifact.scope}</dd>
<dt className="text-slate-500"></dt><dd className="text-right font-medium text-slate-800">{artifact.confidence}</dd>
))}
</dl>
</div>
</section>
<section className="border-t border-slate-200 pt-5" aria-labelledby="evidence-context-heading">
<h4 id="evidence-context-heading" className="text-xs font-semibold text-slate-500"></h4>
<dl className="mt-3 grid grid-cols-[72px_minmax(0,1fr)] gap-x-4 gap-y-2.5 text-xs leading-5">
<dt className="text-slate-500"></dt>
<dd className="text-right font-medium text-slate-800">{formatAnalyticalTimeRange(artifact.analyticalTimeRange)}</dd>
<dt className="text-slate-500"></dt>
<dd className="text-right font-medium text-slate-800">{artifact.scope}</dd>
<dt className="text-slate-500"></dt>
<dd className="text-right font-medium text-slate-800 tabular-nums">{artifact.confidence}</dd>
</dl>
</section>
</div>
</aside>
);
}
function evidenceBlockDescription(block: EvidenceBlock) {
return "description" in block
? block.description
: block.paragraphs[0] ?? "该模块汇总了当前成果中的关键判断依据。";
}
function evidenceBlockKindLabel(block: EvidenceBlock) {
if (block.kind === "chart") return block.chartType === "line" ? "趋势图" : "对比图";
if (block.kind === "process-flow") return "流程证据";
if (block.kind === "data-table") return "明细数据";
return "分析说明";
}
function getEvidenceBlockFacts(block: EvidenceBlock): Array<{ label: string; value: string }> {
if (block.kind === "chart") {
return [
{ label: "数据序列", value: `${block.series.length}` },
{ label: "时间 / 类别点", value: `${block.categories.length}` },
{ label: "计量单位", value: block.unit }
];
}
if (block.kind === "data-table") {
return [
{ label: "证据记录", value: `${block.rows.length}` },
{ label: "数据字段", value: `${block.columns.length}` }
];
}
if (block.kind === "process-flow") {
return [
{ label: "流程步骤", value: `${block.nodes.length}` },
{ label: "已完成", value: `${block.nodes.filter((node) => node.status === "complete").length}` },
{ label: "当前进行", value: `${block.nodes.filter((node) => node.status === "active").length}` }
];
}
return [{ label: "分析段落", value: `${block.paragraphs.length}` }];
}
function formatAnalyticalTimeRange(range: AnalysisArtifact["analyticalTimeRange"]) {
const formatter = new Intl.DateTimeFormat("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hour12: false
});
return `${formatter.format(new Date(range.start))}${formatter.format(new Date(range.end))}`;
}
function ArtifactActionBar({
artifact,
surfaceMode,
mapSplitAvailable,
detailOpen,
onToggleDetail,
onSetView,
onCollapse,
onDestroy
}: {
artifact: AnalysisArtifact;
surfaceMode: WorkbenchSurfaceMode;
mapSplitAvailable: boolean;
detailOpen: boolean;
onToggleDetail: () => void;
onSetView: (view: ArtifactRequestedView) => void;
onCollapse: () => void;
onDestroy: () => void;
}) {
return (
<footer className="surface-control relative z-20 mb-[calc(var(--workbench-timeline-height)+0.25rem)] flex min-h-14 items-center justify-between gap-3 border-t px-3">
<div className="flex min-w-0 items-center gap-1">
{artifact.mapRelation !== "none" ? (
<button type="button" onClick={() => onSetView(surfaceMode === "map_split" ? "focus" : mapSplitAvailable ? "map_split" : "map_only")} className="inline-flex h-10 items-center gap-2 rounded-md px-3 text-xs font-semibold text-slate-700 hover:bg-slate-100 active:scale-95">
{surfaceMode === "map_split" ? <Focus size={15} aria-hidden="true" /> : <MapIcon size={15} aria-hidden="true" />}
{surfaceMode === "map_split" ? "专注分析" : "显示地图"}
</button>
) : null}
<button type="button" aria-pressed={detailOpen} onClick={onToggleDetail} className="inline-flex h-10 items-center gap-2 rounded-md px-3 text-xs font-semibold text-slate-700 hover:bg-slate-100 active:scale-95">
{detailOpen ? <PanelRightClose size={15} aria-hidden="true" /> : <PanelRightOpen size={15} aria-hidden="true" />}
</button>
</div>
<span />
<div className="flex shrink-0 items-center gap-1">
{artifact.actions.slice(0, 1).map((action) => <ArtifactPrimaryAction key={action.id} action={action} artifact={artifact} />)}
<button type="button" aria-label="将成果收起为预览" title="收起为预览" onClick={onCollapse} className="grid h-10 w-10 place-items-center rounded-md text-slate-600 hover:bg-slate-100 active:scale-95"><Minimize2 size={16} aria-hidden="true" /></button>
<button type="button" aria-label="销毁当前成果" title="销毁当前成果" onClick={onDestroy} className="grid h-10 w-10 place-items-center rounded-md text-slate-500 hover:bg-rose-50 hover:text-rose-700 active:scale-95"><Trash2 size={16} aria-hidden="true" /></button>
<button type="button" aria-label="将成果收起为预览" title="收起为预览" onClick={onCollapse} className={ARTIFACT_ICON_BUTTON_CLASS_NAME}><Minimize2 size={15} aria-hidden="true" /></button>
<button type="button" aria-label="销毁当前成果" title="销毁当前成果" onClick={onDestroy} className={ARTIFACT_DANGER_ICON_BUTTON_CLASS_NAME}><Trash2 size={15} aria-hidden="true" /></button>
</div>
</footer>
);
@@ -382,9 +446,9 @@ function ArtifactPrimaryAction({ action, artifact }: { action: AnalysisArtifactA
<button
type="button"
onClick={() => showMapNotice({ tone: "info", title: action.label, message: `${artifact.title}${action.description}` })}
className="hidden h-10 items-center gap-2 rounded-md bg-blue-600 px-3 text-xs font-semibold text-white shadow-[0_2px_6px_rgba(37,99,235,0.24)] hover:bg-blue-700 active:scale-95 sm:inline-flex"
className={cn("hidden sm:inline-flex", ARTIFACT_PRIMARY_ACTION_BUTTON_CLASS_NAME)}
>
<ShieldCheck size={15} aria-hidden="true" />
<ShieldCheck size={14} aria-hidden="true" />
{action.label}
</button>
);
@@ -1,5 +1,9 @@
import { ChevronUp, FileChartColumnIncreasing, Sparkles, Trash2 } from "lucide-react";
import { cn } from "@/shared/ui/cn";
import {
ARTIFACT_DANGER_ICON_BUTTON_CLASS_NAME,
ARTIFACT_PRIMARY_ACTION_BUTTON_CLASS_NAME
} from "./artifact-view-control-styles";
import type { AnalysisArtifact, WorkbenchSurfaceMode } from "./workspace-model";
export function ArtifactPeekBar({
@@ -17,7 +21,7 @@ export function ArtifactPeekBar({
<aside
aria-label="Agent 成果预览"
className={cn(
"acrylic-panel pointer-events-auto absolute top-28 z-40 flex min-h-[76px] w-[min(680px,calc(100%-2rem))] items-center gap-3 rounded-2xl border px-3 py-2.5 text-slate-900",
"acrylic-panel pointer-events-auto absolute bottom-14 z-40 flex min-h-[76px] w-[min(680px,calc(100%-2rem))] items-center gap-3 rounded-2xl border px-3 py-2.5 text-slate-900",
"left-1/2 -translate-x-1/2",
surfaceMode === "map_split" && "2xl:left-[25%]"
)}
@@ -36,9 +40,9 @@ export function ArtifactPeekBar({
<button
type="button"
onClick={onOpen}
className="inline-flex h-10 shrink-0 items-center gap-1.5 rounded-md bg-blue-500 px-3 text-xs font-semibold text-white hover:bg-blue-400 active:scale-95"
className={ARTIFACT_PRIMARY_ACTION_BUTTON_CLASS_NAME}
>
<ChevronUp size={15} aria-hidden="true" />
<ChevronUp size={14} aria-hidden="true" />
</button>
<button
@@ -46,9 +50,9 @@ export function ArtifactPeekBar({
aria-label="销毁预览成果"
title="销毁预览成果"
onClick={onDestroy}
className="surface-control grid h-10 w-10 shrink-0 place-items-center rounded-xl border text-slate-500 hover:text-rose-700 active:scale-95"
className={cn("surface-control border", ARTIFACT_DANGER_ICON_BUTTON_CLASS_NAME)}
>
<Trash2 size={16} aria-hidden="true" />
<Trash2 size={15} aria-hidden="true" />
</button>
</aside>
);
@@ -0,0 +1,14 @@
export const ARTIFACT_VIEW_CONTROL_SHELL_CLASS_NAME =
"surface-control inline-flex shrink-0 items-center rounded-xl border border-white/70 p-0.5 shadow-[0_4px_14px_rgba(15,23,42,0.08)]";
export const ARTIFACT_VIEW_CONTROL_BUTTON_CLASS_NAME =
"inline-flex h-7 items-center gap-1.5 rounded-lg px-2 text-[10px] font-medium text-slate-500 transition-colors hover:bg-white/80 hover:text-blue-700 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-blue-500/60";
export const ARTIFACT_PRIMARY_ACTION_BUTTON_CLASS_NAME =
"inline-flex h-8 shrink-0 items-center gap-1.5 rounded-lg bg-blue-600 px-2.5 text-[11px] font-semibold text-white shadow-[0_2px_6px_rgba(37,99,235,0.2)] transition-colors hover:bg-blue-700 active:bg-blue-800 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-blue-500/60 focus-visible:ring-offset-2";
export const ARTIFACT_ICON_BUTTON_CLASS_NAME =
"grid h-8 w-8 shrink-0 place-items-center rounded-lg text-slate-500 transition-colors hover:bg-white/80 hover:text-blue-700 active:bg-slate-200/70 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-blue-500/60";
export const ARTIFACT_DANGER_ICON_BUTTON_CLASS_NAME =
"grid h-8 w-8 shrink-0 place-items-center rounded-lg text-slate-500 transition-colors hover:bg-rose-50 hover:text-rose-700 active:bg-rose-100 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-rose-500/60";
@@ -1,10 +1,14 @@
import { ArrowLeft, FileChartColumnIncreasing } from "lucide-react";
import { ArrowLeft, Focus, Map as MapIcon } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import type { ReactNode } from "react";
import { cn } from "@/shared/ui/cn";
import { OPERATIONAL_TIMELINE_LAYOUT } from "../operational-timeline/operational-timeline-model";
import { AnalysisArtifactWorkspace } from "./analysis-document-view";
import { ArtifactPeekBar } from "./artifact-peek-bar";
import {
ARTIFACT_VIEW_CONTROL_BUTTON_CLASS_NAME,
ARTIFACT_VIEW_CONTROL_SHELL_CLASS_NAME
} from "./artifact-view-control-styles";
import type {
AnalysisArtifact,
ArtifactRequestedView,
@@ -46,10 +50,28 @@ export function WorkbenchMainFrame({
}: WorkbenchMainFrameProps) {
const prefersReducedMotion = useReducedMotion();
const showMap = surfaceMode !== "focus";
const showArtifact = activeArtifact && surfaceMode !== "map_only";
const showArtifact = Boolean(activeArtifact && surfaceMode !== "map_only");
const showArtifactViewControl = Boolean(
activeArtifact &&
activeArtifact.mapRelation !== "none"
);
const mapOnly = surfaceMode === "map_only";
const mapSplit = surfaceMode === "map_split";
const viewControlLabel = mapOnly
? "返回分析成果"
: mapSplit
? "专注分析"
: "显示地图";
const viewControlLeft = mapSplit ? "calc(50% + 24px)" : "24px";
const timelineTransition = prefersReducedMotion
? { duration: 0 }
: { duration: 0.28, ease: [0.22, 1, 0.36, 1] as const };
const viewControlTransition = prefersReducedMotion
? { duration: 0 }
: { duration: 0.18, ease: [0.22, 1, 0.36, 1] as const };
const artifactTransition = prefersReducedMotion
? { duration: 0 }
: { duration: 0.24, ease: [0.22, 1, 0.36, 1] as const };
return (
<div
@@ -77,39 +99,41 @@ export function WorkbenchMainFrame({
>
{mapContent}
{mapOverlay}
{activeArtifact && surfaceMode === "map_only" ? (
<button
type="button"
onClick={() => onSetArtifactView("focus")}
className="pointer-events-auto absolute left-3 top-3 z-20 inline-flex h-10 items-center gap-2 rounded-md bg-slate-950 px-3 text-xs font-semibold text-white shadow-[0_8px_24px_rgba(15,23,42,0.22)] hover:bg-slate-800 active:scale-95 lg:left-4 lg:top-4"
>
<ArrowLeft size={15} aria-hidden="true" />
<FileChartColumnIncreasing size={15} aria-hidden="true" />
</button>
) : null}
</section>
<AnimatePresence initial={false} mode="popLayout">
{activeArtifact ? (
<section
<motion.section
key={activeArtifact.id}
id="artifact-workspace"
aria-label={`${activeArtifact.title}工作区`}
aria-hidden={!showArtifact}
className={cn(
"relative z-30 min-h-0 min-w-0 overflow-hidden",
!showArtifact && "hidden"
"z-30 min-h-0 min-w-0 overflow-hidden",
showArtifact ? "relative" : "pointer-events-none absolute inset-0"
)}
initial={prefersReducedMotion ? false : { opacity: 0, y: 10, scale: 0.992 }}
animate={showArtifact
? { opacity: 1, y: 0, scale: 1, visibility: "visible" }
: {
opacity: 0,
y: 8,
scale: 0.994,
transitionEnd: { visibility: "hidden" }
}}
exit={prefersReducedMotion
? { opacity: 0 }
: { opacity: 0, y: 10, scale: 0.992 }}
transition={artifactTransition}
>
<AnalysisArtifactWorkspace
artifact={activeArtifact}
surfaceMode={surfaceMode}
mapSplitAvailable={mapSplitAvailable}
onSetView={onSetArtifactView}
onCollapse={onCollapseArtifact}
onDestroy={onDestroyArtifact}
/>
</section>
</motion.section>
) : null}
</AnimatePresence>
{pendingArtifact ? (
<ArtifactPeekBar
@@ -119,6 +143,54 @@ export function WorkbenchMainFrame({
onDestroy={onDestroyPendingArtifact}
/>
) : null}
<AnimatePresence initial={false}>
{showArtifactViewControl ? (
<motion.div
className={cn(
"pointer-events-auto absolute bottom-[72px] z-40 lg:bottom-6",
ARTIFACT_VIEW_CONTROL_SHELL_CLASS_NAME
)}
initial={prefersReducedMotion
? false
: { opacity: 0, y: 6, scale: 0.98, left: viewControlLeft }}
animate={{ opacity: 1, y: 0, scale: 1, left: viewControlLeft }}
exit={prefersReducedMotion ? { opacity: 0 } : { opacity: 0, y: 5, scale: 0.98 }}
transition={viewControlTransition}
>
<button
type="button"
aria-label={viewControlLabel}
onClick={() => onSetArtifactView(
surfaceMode === "focus"
? mapSplitAvailable ? "map_split" : "map_only"
: "focus"
)}
className={ARTIFACT_VIEW_CONTROL_BUTTON_CLASS_NAME}
>
<AnimatePresence initial={false} mode="wait">
<motion.span
key={surfaceMode}
className="inline-flex items-center gap-1.5"
initial={prefersReducedMotion ? false : { opacity: 0, x: mapOnly ? 5 : -5 }}
animate={{ opacity: 1, x: 0 }}
exit={prefersReducedMotion ? { opacity: 0 } : { opacity: 0, x: mapOnly ? -5 : 5 }}
transition={viewControlTransition}
>
{mapOnly ? (
<ArrowLeft size={13} aria-hidden="true" />
) : mapSplit ? (
<Focus size={13} aria-hidden="true" />
) : (
<MapIcon size={13} aria-hidden="true" />
)}
{viewControlLabel}
</motion.span>
</AnimatePresence>
</button>
</motion.div>
) : null}
</AnimatePresence>
</div>
<motion.div
+2 -1
View File
@@ -40,7 +40,8 @@ describe("Keycloak authentication", () => {
expect(client.init).toHaveBeenCalledWith({
onLoad: "login-required",
flow: "standard",
pkceMethod: "S256"
pkceMethod: "S256",
checkLoginIframe: false
});
expect(authentication.user).toEqual({
name: "张调度",
+2 -1
View File
@@ -59,7 +59,8 @@ export async function initializeAuthentication(
const authenticated = await keycloak.init({
onLoad: "login-required",
flow: "standard",
pkceMethod: "S256"
pkceMethod: "S256",
checkLoginIframe: false
});
if (!authenticated) {
+2
View File
@@ -12,6 +12,7 @@ describe("runtime frontend configuration", () => {
TJWATER_MAPBOX_ACCESS_TOKEN: "token",
TJWATER_MAP_URL: "https://maps.example.test/geoserver",
TJWATER_GEOSERVER_WORKSPACE: "project-a",
TJWATER_SERVER_API_BASE_URL: "https://server.example.test",
TJWATER_AGENT_API_BASE_URL: "https://agent.example.test",
TJWATER_ENABLE_DEV_PANEL: "true",
TJWATER_ENABLE_MSW: "false"
@@ -23,6 +24,7 @@ describe("runtime frontend configuration", () => {
TJWATER_MAPBOX_ACCESS_TOKEN: "token",
TJWATER_MAP_URL: "https://maps.example.test/geoserver",
TJWATER_GEOSERVER_WORKSPACE: "project-a",
TJWATER_SERVER_API_BASE_URL: "https://server.example.test",
TJWATER_AGENT_API_BASE_URL: "https://agent.example.test",
TJWATER_ENABLE_DEV_PANEL: true,
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_MAPBOX_ACCESS_TOKEN: z.string().default(""),
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_ENABLE_DEV_PANEL: runtimeBoolean(false),
TJWATER_ENABLE_MSW: runtimeBoolean(false)
+63 -1
View File
@@ -1,4 +1,5 @@
import { expect, test, type Page } from "@playwright/test";
import { mockScadaApi } from "./support/mock-scada-api";
test.beforeEach(async ({ 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);
});
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 }) => {
await page.setViewportSize({ width: 1440, height: 900 });
await page.goto("/", { waitUntil: "domcontentloaded" });
@@ -154,7 +214,8 @@ async function prepareWorkbench(page: Page) {
TJWATER_AUTH_MODE: "disabled",
TJWATER_MAPBOX_ACCESS_TOKEN: "",
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_ENABLE_DEV_PANEL: "false",
TJWATER_ENABLE_MSW: "false"
@@ -170,4 +231,5 @@ async function prepareWorkbench(page: Page) {
await page.route("**/*.riv", async (route) => {
await route.abort();
});
await mockScadaApi(page);
}
+161
View File
@@ -0,0 +1,161 @@
import { expect, test, type Page } 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 mockRuntimeAndMap(page, { valves: 0, reservoirs: 0, pumps: 0, tanks: 0 });
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" });
const layerTool = page.getByRole("button", { name: /图层:管理地图图层/ });
await expect(layerTool).toBeEnabled();
await layerTool.click();
await expect(page.getByRole("button", { name: /^管线/ })).toBeVisible();
await expect(page.getByRole("button", { name: /^节点/ })).toBeVisible();
await expect(page.getByRole("button", { name: /^阀门/ })).toHaveCount(0);
await expect(page.getByRole("button", { name: /^水库/ })).toHaveCount(0);
await expect(page.getByRole("button", { name: /^水泵/ })).toHaveCount(0);
await expect(page.getByRole("button", { name: /^水箱/ })).toHaveCount(0);
});
test("shows data-dependent layer controls only when GeoServer reports features", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await mockRuntimeAndMap(page, { valves: 1, reservoirs: 1, pumps: 2, tanks: 1 });
await mockScadaApi(page);
await page.goto("/", { waitUntil: "domcontentloaded" });
const layerTool = page.getByRole("button", { name: /图层:管理地图图层/ });
await expect(layerTool).toBeEnabled();
await layerTool.click();
await expect(page.getByRole("button", { name: /^阀门/ })).toBeVisible();
await expect(page.getByRole("button", { name: /^水库/ })).toBeVisible();
await expect(page.getByRole("button", { name: /^水泵/ })).toBeVisible();
await expect(page.getByRole("button", { name: /^水箱/ })).toBeVisible();
await expect.poll(() => page.evaluate(() => ({
valve: Boolean(globalThis.__waterNetworkMap?.getLayer("valves-symbol")),
valveLabel: Boolean(globalThis.__waterNetworkMap?.getLayer("workbench-value-label-valves")),
reservoir: Boolean(globalThis.__waterNetworkMap?.getLayer("reservoirs-symbol")),
reservoirLabel: Boolean(globalThis.__waterNetworkMap?.getLayer("workbench-value-label-reservoirs")),
pump: Boolean(globalThis.__waterNetworkMap?.getLayer("pumps-symbol")),
tank: Boolean(globalThis.__waterNetworkMap?.getLayer("tanks-symbol"))
}))).toEqual({
valve: true,
valveLabel: true,
reservoir: true,
reservoirLabel: true,
pump: true,
tank: true
});
await expect.poll(() => page.evaluate(() => {
const layerIds = globalThis.__waterNetworkMap?.getStyle().layers.map((layer) => layer.id) ?? [];
return {
valveLabel: layerIds.indexOf("workbench-value-label-valves"),
simulationLabel: layerIds.indexOf("simulation-burst-label")
};
})).toMatchObject({
valveLabel: expect.any(Number),
simulationLabel: expect.any(Number)
});
const layerOrder = await page.evaluate(() => {
const layerIds = globalThis.__waterNetworkMap?.getStyle().layers.map((layer) => layer.id) ?? [];
return [
layerIds.indexOf("workbench-value-label-valves"),
layerIds.indexOf("simulation-burst-label")
];
});
expect(layerOrder[0]).toBeGreaterThanOrEqual(0);
expect(layerOrder[0]).toBeLessThan(layerOrder[1]);
});
test("loads required map layers while optional layer probes are still pending", async ({ page }) => {
let releaseProbe = () => {};
const probeGate = new Promise<void>((resolve) => {
releaseProbe = resolve;
});
await mockRuntimeAndMap(
page,
{ valves: 0, reservoirs: 0, pumps: 1, tanks: 0 },
{ probeGate }
);
await mockScadaApi(page);
await page.goto("/", { waitUntil: "domcontentloaded" });
try {
await expect.poll(() => page.evaluate(() => ({
pipes: Boolean(globalThis.__waterNetworkMap?.getLayer("pipes-casing")),
pumps: Boolean(globalThis.__waterNetworkMap?.getLayer("pumps-symbol"))
}))).toEqual({ pipes: true, pumps: false });
} finally {
releaseProbe();
}
await expect.poll(() => page.evaluate(() =>
Boolean(globalThis.__waterNetworkMap?.getLayer("pumps-symbol"))
)).toBe(true);
});
async function mockRuntimeAndMap(
page: Page,
featureCounts: Record<"valves" | "reservoirs" | "pumps" | "tanks", number>,
options: { probeGate?: Promise<void> } = {}
) {
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 page.route("https://scada-map.invalid/geoserver/tjwater_next/ows?**", async (route) => {
await options.probeGate;
const typeNames = new URL(route.request().url()).searchParams.get("typeNames");
const sourceId = typeNames?.split(":").at(-1);
const featureCount = sourceId && sourceId in featureCounts
? featureCounts[sourceId as keyof typeof featureCounts]
: 0;
await route.fulfill({
contentType: "application/xml",
body: `<wfs:FeatureCollection numberMatched="${featureCount}" numberReturned="0" />`
});
});
}
+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 { mockAgentApi } from "./support/mock-agent-api";
import { mockScadaApi } from "./support/mock-scada-api";
const EMPTY_RASTER_TILE = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
@@ -120,7 +121,8 @@ async function prepareDeterministicWorkbench(page: Page) {
TJWATER_AUTH_MODE: "disabled",
TJWATER_MAPBOX_ACCESS_TOKEN: "visual-test-token",
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_ENABLE_DEV_PANEL: "false",
TJWATER_ENABLE_MSW: "false"
@@ -142,6 +144,7 @@ async function prepareDeterministicWorkbench(page: Page) {
await page.route("**/*.riv", async (route) => {
await route.abort();
});
await mockScadaApi(page);
}
async function openWorkbench(page: Page) {
+4 -1
View File
@@ -1,5 +1,6 @@
import { expect, test, type Page } from "@playwright/test";
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 }) => {
await page.setViewportSize({ width: 1600, height: 900 });
@@ -138,7 +139,8 @@ async function prepareMainFrame(page: Page) {
TJWATER_AUTH_MODE: "disabled",
TJWATER_MAPBOX_ACCESS_TOKEN: "",
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_ENABLE_DEV_PANEL: "true",
TJWATER_ENABLE_MSW: "false"
@@ -148,4 +150,5 @@ async function prepareMainFrame(page: Page) {
await page.route("https://workspace-map.invalid/**", async (route) => {
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_MAPBOX_ACCESS_TOKEN: values.TJWATER_MAPBOX_ACCESS_TOKEN || "",
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_ENABLE_DEV_PANEL: values.TJWATER_ENABLE_DEV_PANEL || "false",
TJWATER_ENABLE_MSW: values.TJWATER_ENABLE_MSW || "false"